All tools
MeleeDOTweenGame feel

Slash Lab

Wind-up, strike, recovery — tune a 2D swing and the hit it lands.

Light slash

Wind-up
Strike
Recovery
hit
0 ms360 ms · 22 frames @ 60 fps360 ms

The baseline attack: short wind-up, fast arc, recovery you can cancel out of. Contact lands early in the strike so the swing keeps travelling after the hit — stopping the blade on contact is what makes cheap combat look like the animation ran out.

Wind-up
5.4f
90 ms of tell
Strike
6.6f
110 ms
Hit stop
3.3f
55 ms hold
Commit
360 ms
press to neutral
Contacts
0
since the last reset
Trauma
0.00
clamped at 1
Sweep
120°
1091°/s
State
running

Presets

Light

Heavy

Exotic

Timing

90 ms
110 ms
160 ms
42%
1

Wind-up is the tell. Under about 60 ms — four frames — the swing arrives before the player can read it, which is fine for a flurry and unfair for anything that hurts.

Arc

-55°
120°
30°
74 px
16 px
10°

Curves

An Out ease on the strike front-loads the speed, so the blade covers most of the arc in the first few frames and the rest reads as follow-through. An In ease does the opposite and is what a hammer wants.

Blade & trail

5.00 px
130 ms
0.42
0.75

Impact

55 ms
0.05

3.3f at 60 fps, running at 5% speed.

0.38
14 px
1.40°
22 Hz
1.80 /s
3.0%

Shake is trauma squared, so it settles fast and small hits stay small. Trauma adds up across hits and clamps at 1 — that is what stops a burst from turning the screen into noise.

18 px
420 ms
0.16
70 ms

Your sprites

Attacker
Target
1.00×

Nothing is uploaded — the files are decoded in the page. The blade is still drawn procedurally from the hand point, so line it up with where your character actually holds the weapon.

using DG.Tweening;
using UnityEngine;

/// <summary>
/// Light slash — a 2D swing built as wind-up, strike, recovery.
/// Tuned in Slash Lab on burakkaan.dev.
///
/// Put the blade sprite under an empty "pivot" transform positioned at the hand.
/// This rotates the pivot; the blade never moves in its own local space, which is
/// what keeps the arc clean when the character also translates.
/// </summary>
[RequireComponent(typeof(CombatImpact))]
public class SlashAttack : MonoBehaviour
{
    [Header("Timing (seconds)")]
    public float windup = 0.090f;
    public float strike = 0.110f;
    public float recovery = 0.160f;
    [Range(0.05f, 1f)]
    [Tooltip("Where inside the strike contact lands. Below 1 the blade keeps travelling through the hit.")]
    public float contactAt = 0.42f;

    [Header("Arc (degrees, Unity Z)")]
    public float restAngle = 55.0f;
    public float sweep = -120.0f;
    [Tooltip("Pull-back against the sweep. This is the anticipation the player reads.")]
    public float anticipation = 30.0f;

    [Header("Combo")]
    [Min(1)] public int hits = 1;
    public float comboGap = 0.180f;
    public bool alternate = true;

    [Header("Movement")]
    [Tooltip("World units the attacker steps in over the strike.")]
    public float stepIn = 0.160f;

    [Header("Refs")]
    public Transform pivot;
    public TrailRenderer trail;
    public Transform body;

    CombatImpact _impact;
    Sequence _seq;
    Vector3 _bodyRest;

    public bool IsSwinging => _seq != null && _seq.IsActive() && _seq.IsPlaying();

    void Awake()
    {
        _impact = GetComponent<CombatImpact>();
        if (body == null) body = transform;
        _bodyRest = body.localPosition;
        if (trail != null) trail.emitting = false;
    }

    /// <summary>Call from input. Ignores the press while a swing is committed.</summary>
    public void Attack()
    {
        if (IsSwinging) return;
        _seq?.Kill();
        _seq = Build();
    }

    Sequence Build()
    {
        var seq = DOTween.Sequence();
        float cursor = 0f;

        for (int i = 0; i < hits; i++)
        {
            bool flip = alternate && i % 2 == 1;
            float from = flip ? restAngle + sweep : restAngle;
            float to = flip ? restAngle : restAngle + sweep;
            float dir = Mathf.Sign(to - from);
            float back = from + dir * anticipation;   // against the sweep

            // wind-up
            seq.Insert(cursor, pivot.DOLocalRotate(new Vector3(0f, 0f, back), windup)
                .SetEase(Ease.OutQuad));
            cursor += windup;

            // strike — trail on for the swing only
            float hitTime = cursor + strike * contactAt;
            seq.InsertCallback(cursor, () => SetTrail(true));
            seq.Insert(cursor, pivot.DOLocalRotate(new Vector3(0f, 0f, to), strike)
                .SetEase(Ease.OutQuart));
            seq.Insert(cursor, body.DOLocalMove(_bodyRest + Vector3.right * stepIn, strike)
                .SetEase(Ease.OutQuart));
            seq.InsertCallback(hitTime, Contact);
            cursor += strike;
            seq.InsertCallback(cursor, () => SetTrail(false));

            if (i < hits - 1) cursor += comboGap;
        }

        // recovery back to neutral
        seq.Insert(cursor, pivot.DOLocalRotate(new Vector3(0f, 0f, restAngle), recovery)
            .SetEase(Ease.InOutSine));
        seq.Insert(cursor, body.DOLocalMove(_bodyRest, recovery)
            .SetEase(Ease.InOutSine));

        seq.SetLink(gameObject);
        return seq;
    }

    void SetTrail(bool on)
    {
        if (trail == null) return;
        // Clearing on the rising edge stops the previous swing's ribbon from
        // being dragged into this one across the gap.
        if (on) trail.Clear();
        trail.emitting = on;
    }

    void Contact()
    {
        // Swap this for your own overlap query. Keeping the hitbox active for a
        // single frame at contactAt is deliberate: a hitbox that lives for the
        // whole strike double-hits anything standing inside the arc.
        var hit = Physics2D.OverlapCircle(pivot.position + pivot.right * 0.74f,
            0.34f, LayerMask.GetMask("Hittable"));
        if (hit == null) return;

        _impact.Hit(hit.transform, (hit.transform.position - transform.position).normalized,
            hit.GetComponentInChildren<SpriteRenderer>());
    }

    void OnDestroy() => _seq?.Kill();
}

Rotation is negated on the way into Unity — canvas angles grow clockwise, Unity Z grows counter-clockwise. Contact is scheduled inside the strike at 42%, so the blade is still moving when the hit registers.