Slash Lab
Wind-up, strike, recovery — tune a 2D swing and the hit it lands.
Light slash
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.
Presets
Light
Heavy
Exotic
Timing
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
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
Impact
3.3f at 60 fps, running at 5% speed.
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.
Your sprites
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.