Artículos /

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.

¿Por qué? Dirección desde ratón, no desde cámara
El personaje mira hacia la posición del ratón en el mundo (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.

C#
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.

C#
// 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]
Tuning del timing

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.

C#
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.

C#
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);
    }
}