Hitbox combat:
WeaponHitbox,
Animation Events y AoE
Proyecto M usa combate basado en hitboxes, no auto-targeting. El jugador apunta con el ratón, el personaje mira en esa dirección y las habilidades afectan a lo que hay delante. No hay "objetivo actual", no hay target lock, no hay lógica de cambio de objetivo.
Esto simplifica enormemente la implementación — no hay un sistema de targeting que mantener —, y el resultado de gameplay es más expresivo: posición y dirección importan. Un golpe que falla porque miraste mal es información, no un fallo del sistema.
GetMouseWorldPosition()
con un raycast al plano de juego). Esto da rotación frame a frame
sin latencia. Se puede añadir un Slerp para suavizar visualmente,
pero el input real es siempre inmediato — la animación se suaviza,
el control no.
WeaponHitbox es un MonoBehaviour que
vive en el GameObject del arma (o de la mano). Tiene un
Collider trigger que se activa y desactiva durante el
ataque. Cuando está activo, detecta colisiones con enemigos y
procesa el golpe.
El punto clave es el HashSet<Collider>: una vez
que un collider de enemigo entra en el trigger y se procesa, se
añade al set. Si el mismo collider vuelve a entrar durante el
mismo swing — cosa que pasa con colisionadores complejos o con el
arma moviéndose rápido —, se ignora. El set se limpia al comenzar
cada nuevo ataque.
public class WeaponHitbox : MonoBehaviour
{
[SerializeField] private Collider _triggerCollider;
private readonly HashSet<Collider> _hitThisAttack = new();
private DamageInfo _currentDamageInfo;
private AbilityExecutor _owner;
// Llamado desde AbilityExecutor al recibir el Animation Event
public void Activate(AbilityExecutor owner, in DamageInfo info)
{
_owner = owner;
_currentDamageInfo = info;
_hitThisAttack.Clear(); // nuevo ataque, nueva lista
_triggerCollider.enabled = true;
}
public void Deactivate() => _triggerCollider.enabled = false;
private void OnTriggerEnter(Collider other)
{
if (!_hitThisAttack.Add(other)) return; // ya procesado en este swing
if (!other.TryGetComponent<IDamageable>(out var target)) return;
_owner.ProcessHit(target, _currentDamageInfo);
}
}
La hitbox no se activa por tiempo — se activa por
Animation Event. En el Animator Controller, cada
animación de ataque tiene dos eventos insertados en los frames
exactos: uno que llama a OnHitboxActivate y otro que
llama a OnHitboxDeactivate. Eso desacopla
completamente el timing del combate de cualquier lógica de Update.
El beneficio es que ajustar cuándo "pega" un ataque es trabajo de animación, no de código. Mueves el evento en el timeline del Animator y listo.
// En AbilityExecutor — los Animation Events del Animator llaman a estos métodos
public void OnHitboxActivate()
{
var info = BuildDamageInfo(_activeAbility); // construye el DamageInfo actual
_weaponHitbox.Activate(this, info);
}
public void OnHitboxDeactivate() => _weaponHitbox.Deactivate();
// El flujo completo de un ataque melee:
// ExecuteAbility() → Animator.SetTrigger("Attack")
// → Animation Event Frame 4 → OnHitboxActivate()
// → [Hitbox activa 0.15s]
// → Animation Event Frame 8 → OnHitboxDeactivate()
// → [Recovery]
La activación debería sentirse inmediata. Activar demasiado tarde crea la sensación de que el ataque "no conecta". El parámetro crítico es el frame de activación: demasiado pronto y la hitbox aparece antes de que la animación lo justifique, demasiado tarde y el jugador percibe lag entre el input y el daño. El rango habitual de duración activa es 0.1s–0.2s.
Para habilidades de área instantáneas no hay trigger collider — se
usa
Physics.OverlapSphere (o OverlapBox /
OverlapCapsule
según la forma) en el frame de ejecución. El resultado es un array
de colliders que se procesa de una vez.
El HashSet se usa igualmente para deduplicar: un enemigo con múltiples colliders (cuerpo, arma, escudo) no debería recibir el daño varias veces.
public void ExecuteAoE(Vector3 origin, float radius, in DamageInfo info)
{
Collider[] hits = Physics.OverlapSphere(origin, radius, _enemyLayer);
// Deduplicar por GameObject — un enemigo puede tener varios colliders
var processed = new HashSet<GameObject>();
foreach (var hit in hits)
{
if (!processed.Add(hit.gameObject)) continue;
if (hit.TryGetComponent<IDamageable>(out var target))
ProcessHit(target, info);
}
}
// Proyectiles: GameObject con Rigidbody + Collider trigger
// OnTriggerEnter → mismo patrón, pero el HashSet es para proyectiles
// que atraviesan (pierce) y pueden tocar el mismo enemigo dos veces
Un problema clásico en action games: el jugador pulsa una habilidad durante el recovery de otra, el input se pierde y el jugador siente que el juego no responde. La solución es una cola de inputs con ventana de expiración.
Cuando llega un input y el personaje está ocupado, en lugar de descartarlo se encola con un timestamp de expiración. Al terminar la acción actual, se comprueba si hay inputs encolados válidos (no expirados) y se ejecuta el primero. Con una ventana de ~0.3s el juego responde a la intención sin acumular inputs viejos.
private readonly struct QueuedInput
{
public readonly AbilitySlot Slot;
public readonly float ExpiresAt;
public bool IsExpired => Time.time > ExpiresAt;
}
private QueuedInput? _queuedInput;
private const float InputWindow = 0.3f;
public void RequestAbility(AbilitySlot slot)
{
if (CanExecute(slot)) { ExecuteAbility(slot); return; }
// Ocupado — encolar con ventana de 0.3s
_queuedInput = new QueuedInput(slot, Time.time + InputWindow);
}
// Llamado al terminar cada acción (Animation Event o callback)
public void OnActionFinished()
{
_currentState = CombatState.Idle;
if (_queuedInput is { } queued && !queued.IsExpired)
{
_queuedInput = null;
RequestAbility(queued.Slot);
}
}