Melee feel overhaul: SwordCombat anims at heavy cadence, salvage axe, damage-at-contact + buffer + dash law (guidelines forks 1-6)

07-18/19/20 arc: LightCombo01/HeavyCombo01 clips onto Swing1-3/Slam at
~natural speed (26-tick window, 30-tick recover, DPS-held damage retune),
Menacing01 combat idle (InCombat), SM_Wep_Axe_Large_01 rigid-skinned to
Hand_R at x1.25, LANTERN FX retone + blade-smear ribbon. Forks 1-6 per
Combat_Attack_Feel_Guidelines (design review wf_000bc247): cleave resolves
at the CONTACT tick (MeleeCleavePending schedule-and-consume, knob 31,
0=legacy; connect cues moved to contact), MeleeRange 2.2 + reach-only
finisher mult 1.25 (knob 30), BufferedAttackTick GhostField input buffer
(knob 29, unlock-edge validity), dash refused pre-contact (lookup-based).
Tests: MeleeComboTests pin legacy knobs; +3 fork tests (414 green); live
server proof: HP drop exactly at swing+16 under tick batching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 21:03:18 -07:00
parent 2c187a17a9
commit 7b65912f30
22 changed files with 1327 additions and 112 deletions
@@ -35,6 +35,15 @@ namespace ProjectM.Simulation
const float DefaultSharpness = CharacterComponent.DefaultGroundedSharpness; // CharacterComponent.GetDefault() base
const float SimTickRate = 60f;
// 07-20 G7 dash law (design review wf_000bc247 C4): a LOOKUP, not a query add -- melee-less entities
// (dash test harnesses) must keep dashing; only entities that carry MeleeCombo get the commitment rule.
ComponentLookup<MeleeCombo> m_MeleeLookup;
public void OnCreate(ref SystemState state)
{
m_MeleeLookup = state.GetComponentLookup<MeleeCombo>(isReadOnly: true);
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
@@ -50,19 +59,31 @@ namespace ProjectM.Simulation
float dashSharpness = t.DashSharpness;
// 07-15 underwater feel: the restore target honors the MoveSharpness dev-override (0 = authored const).
float baseSharpness = t.MoveSharpness > 0f ? t.MoveSharpness : DefaultSharpness;
float contactKnob = math.max(0f, t.MeleeContactTicks); // 07-20 G7: pre-contact swing = dash refused
m_MeleeLookup.Update(ref state);
foreach (var (ds, cd, control, character, input, facing) in
foreach (var (ds, cd, control, character, input, facing, entity) in
SystemAPI.Query<RefRW<DashState>, RefRW<DashCooldown>, RefRW<CharacterControl>,
RefRW<CharacterComponent>, RefRO<PlayerInput>, RefRO<PlayerFacing>>()
.WithAll<Simulate>().WithDisabled<Dead>())
.WithAll<Simulate>().WithDisabled<Dead>().WithEntityAccess())
{
// --- START (idempotent: fresh press + cooldown ready + not already mid-dash) ---
bool ready = cd.ValueRO.NextTick == 0u
|| !new NetworkTick(cd.ValueRO.NextTick).IsNewerThan(serverTick);
bool inWindow = ds.ValueRO.RecoverUntilTick != 0u
&& new NetworkTick(ds.ValueRO.RecoverUntilTick).IsNewerThan(serverTick);
if (input.ValueRO.Dash.IsSet && ready && !inWindow)
// 07-20 G7 (the Bathynaut COMMITS): a dash start is refused while a swing is PRE-CONTACT -- the
// blade hasn't landed; post-contact recovery dash-cancel stays allowed. Same wrap-safe window
// family as the anim pulse (TickWindowMath.SwingActive over the per-step contact ticks);
// contact knob 0 -> zero-length window -> no refusal (legacy). Deterministic both worlds.
bool preContactCommit = false;
if (m_MeleeLookup.HasComponent(entity))
{
var mcRO = m_MeleeLookup[entity];
preContactCommit = TickWindowMath.SwingActive(mcRO, serverTick, MeleeTiming.ContactTicks(mcRO.Step, contactKnob));
}
if (input.ValueRO.Dash.IsSet && ready && !inWindow && !preContactCommit)
{
// C1 + 07-15 fork: dash toward MOVEMENT input when moving (a panic 'dash away' still works); else
// toward the CURSOR aim (stationary aim-and-dash keeps today's KBM feel under move-facing —
@@ -28,5 +28,25 @@ namespace ProjectM.Simulation
/// <summary>Raw ServerTick the swing's movement-commit / recovery lock ends (NonZero). Gates the next press
/// (locked while now &lt; this) and anchors the chain window [LockUntilTick, LockUntilTick + grace).</summary>
[GhostField] public uint LockUntilTick;
/// <summary>07-20 (guidelines G7 input buffer): raw ServerTick of an Attack press that landed during the
/// swing lock's LAST MeleeBufferTicks (NonZero; 0 = empty). Consumed (zeroed) when it starts the chained
/// swing; validity is anchored to the UNLOCK edge, not the press tick, so server tick-batching cannot
/// drop an edge press (design review wf_000bc247 C1). Predicted + rollback-restored like its siblings.</summary>
[GhostField] public uint BufferedAttackTick;
}
/// <summary>07-20 (guidelines G2.1 damage-at-contact): SERVER-ONLY scheduled cleave — the AttackWindup
/// schedule-and-consume idiom, entity-local (design review wf_000bc247 C11: no container, no disposal, no
/// staleness window). Stamped when a swing starts (ResolveTick = start + MeleeTiming.ContactTicks), fired on
/// a wrap-safe elapsed compare (tick-batch-proof), CONSUMED by zeroing. A still-armed pending is FLUSHED
/// (fired early) before a chained swing overwrites it, so no knob combination can lose a cleave (C0/C12).
/// Not replicated; the client's contact cues are presentation-side (CombatFeedbackSystem). Baked zeroed.</summary>
public struct MeleeCleavePending : IComponentData
{
/// <summary>NonZero tick the armed swing's blade lands; 0 = nothing pending.</summary>
public uint ResolveTick;
/// <summary>Combo step of the ARMED swing (finisher scaling resolved against the live combo length).</summary>
public byte Step;
}
}
@@ -58,10 +58,38 @@ namespace ProjectM.Simulation
BufferLookup<InventorySlot> m_InvLookup;
BufferLookup<StatModifier> m_StatModLookup;
ComponentLookup<BoonEffects> m_BoonEffectsLookup; // Phase 1.7 (player query is at the 7-type cap -> lookup)
ComponentLookup<MeleeCleavePending> m_PendingLookup; // 07-20 G2.1 scheduled cleave (query at cap -> lookup)
/// <summary>Phase 1.7 Detonating Finisher blast radius (planar, tunable).</summary>
const float k_DetonateRadius = 3.5f;
/// <summary>07-20 G2.1: build a cleave payload from LIVE player state at the moment it FIRES (contact tick,
/// flush, or legacy same-tick) -- the cast-turn keeps steering the cone until the blade lands. Static with
/// explicit params (no captures) so Burst lowering stays trivial. Reach uses the RANGE-only finisher mult
/// (G2.2); damage/knockback keep the classic finisher mult.</summary>
static PendingCleave BuildCleave(byte step, byte comboLen, float baseDamage, float baseRange, float knockSpeed,
float finisherMult, float finisherRangeMult, uint stamp, uint knockUntil, float3 from, float2 aim,
float2 facingDir, int ownerId, byte bflags, bool hasMods, DynamicBuffer<StatModifier> mods)
{
bool fin = step >= comboLen;
float d = math.max(0f, hasMods ? StatMath.Apply(baseDamage, StatTarget.MeleeDamage, mods) : baseDamage);
float r = math.max(0f, hasMods ? StatMath.Apply(baseRange, StatTarget.MeleeRange, mods) : baseRange);
return new PendingCleave
{
From = from,
Face = FacingMath.ResolveAim(aim, facingDir),
Damage = fin ? d * finisherMult : d,
Range = fin ? r * finisherRangeMult : r,
KnockSpeed = fin ? knockSpeed * finisherMult : knockSpeed,
OwnerId = ownerId,
Stamp = stamp,
KnockUntil = knockUntil,
IsFinisher = fin,
Detonate = (bflags & BoonFlag.FinisherDetonate) != 0,
Pull = (bflags & BoonFlag.KnockToPull) != 0,
};
}
[BurstCompile]
public void OnCreate(ref SystemState state)
{
@@ -72,6 +100,7 @@ namespace ProjectM.Simulation
m_InvLookup = state.GetBufferLookup<InventorySlot>(isReadOnly: false);
m_StatModLookup = state.GetBufferLookup<StatModifier>(isReadOnly: true);
m_BoonEffectsLookup = state.GetComponentLookup<BoonEffects>(isReadOnly: true);
m_PendingLookup = state.GetComponentLookup<MeleeCleavePending>(isReadOnly: false);
state.RequireForUpdate<NetworkTime>();
}
@@ -92,6 +121,9 @@ namespace ProjectM.Simulation
float moveScale = math.max(0f, t.MeleeSwingMoveScale);
float knockSpeed = math.max(0f, t.MeleeKnockbackSpeed);
float finisherMult = math.max(1f, t.MeleeFinisherMult);
float finisherRangeMult = math.max(0f, t.MeleeFinisherRangeMult); // 07-20 G2.2: REACH-only finisher scaling
float bufferKnob = math.max(0f, t.MeleeBufferTicks); // 07-20 G7: 0 = buffer off
float contactKnob = math.max(0f, t.MeleeContactTicks); // 07-20 G2.1: 0 = immediate (legacy)
byte comboLen = (byte)math.clamp((int)t.MeleeComboLength, 1, 3);
uint stamp = TickUtil.NonZero(now);
uint knockUntil = TickUtil.NonZero(now + (uint)math.max(1, Tuning.KnockbackDurationTicks));
@@ -103,6 +135,7 @@ namespace ProjectM.Simulation
var cleaves = isServer ? new NativeList<PendingCleave>(Allocator.Temp) : default;
m_StatModLookup.Update(ref state);
m_BoonEffectsLookup.Update(ref state); // Phase 1.7: per-player boon flags (read inside the player loop)
m_PendingLookup.Update(ref state); // 07-20 G2.1: scheduled cleave slots (server-only writes)
foreach (var (mc, control, input, facing, xform, owner, ds, entity) in
SystemAPI.Query<RefRW<MeleeCombo>, RefRW<CharacterControl>, RefRO<PlayerInput>,
@@ -119,10 +152,48 @@ namespace ProjectM.Simulation
bool locked = mc.ValueRO.LockUntilTick != 0u
&& new NetworkTick(mc.ValueRO.LockUntilTick).IsNewerThan(serverTick);
// --- ADVANCE (idempotent ABSOLUTE writes; dash wins same-tick ties via !Dash.IsSet) ---
// --- 07-20 G2.1: fire a DUE scheduled cleave BEFORE anything can overwrite the swing this tick
// (wrap-safe elapsed compare = tick-batch-proof; consumed by zeroing -- the AttackWindup idiom,
// design review wf_000bc247 C11). Server-only; the pending slot never replicates.
bool hasPending = m_PendingLookup.HasComponent(entity);
if (isServer && hasPending)
{
var pend = m_PendingLookup[entity];
if (pend.ResolveTick != 0u && !new NetworkTick(pend.ResolveTick).IsNewerThan(serverTick))
{
cleaves.Add(BuildCleave(pend.Step, comboLen, baseDamage, baseRange, knockSpeed,
finisherMult, finisherRangeMult, stamp, knockUntil, xform.ValueRO.Position,
input.ValueRO.Aim, facing.ValueRO.Direction, owner.ValueRO.NetworkId,
m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0,
m_StatModLookup.HasBuffer(entity), m_StatModLookup.HasBuffer(entity) ? m_StatModLookup[entity] : default));
m_PendingLookup[entity] = default;
}
}
// --- 07-20 G7 buffer: a press in the lock's LAST MeleeBufferTicks is remembered; validity anchors
// to the UNLOCK edge so server tick-batching can't drop an edge press (review wf_000bc247 C1). ---
uint bufTicks = (uint)bufferKnob;
if (bufTicks > 0u && input.ValueRO.Attack.IsSet && locked)
{
var lockEnd = new NetworkTick(mc.ValueRO.LockUntilTick);
if (lockEnd.IsValid && lockEnd.TicksSince(serverTick) <= (int)bufTicks)
mc.ValueRW.BufferedAttackTick = TickUtil.NonZero(now);
}
bool buffered = false;
if (!locked && mc.ValueRO.BufferedAttackTick != 0u)
{
var lockEnd = new NetworkTick(mc.ValueRO.LockUntilTick);
if (bufTicks > 0u && lockEnd.IsValid && serverTick.TicksSince(lockEnd) <= (int)MeleeTiming.BatchSlackTicks)
buffered = true;
else
mc.ValueRW.BufferedAttackTick = 0u; // stale (missed the slack / buffer tuned off) -- drop
}
// --- ADVANCE (idempotent ABSOLUTE writes; dash wins same-tick ties via !Dash.IsSet; a buffered
// press counts as a press -- G7) ---
bool swingStarted = false;
byte swingStep = 0;
if (input.ValueRO.Attack.IsSet && !locked && !dashActive && !input.ValueRO.Dash.IsSet)
if ((input.ValueRO.Attack.IsSet || buffered) && !locked && !dashActive && !input.ValueRO.Dash.IsSet)
{
bool inChainWindow = false;
if (mc.ValueRO.LockUntilTick != 0u)
@@ -139,6 +210,7 @@ namespace ProjectM.Simulation
mc.ValueRW.Step = swingStep;
mc.ValueRW.SwingStartTick = TickUtil.NonZero(now);
mc.ValueRW.LockUntilTick = TickUtil.NonZero(now + stepRecover);
mc.ValueRW.BufferedAttackTick = 0u; // consumed (or superseded by a fresh press)
swingStarted = true;
}
@@ -150,33 +222,33 @@ namespace ProjectM.Simulation
if (inSwingWindow && !dashActive)
control.ValueRW.MoveVelocity *= moveScale;
// --- SERVER: queue the cleave (resolved after the loop). Damage/cone are SERVER-authoritative. ---
// --- SERVER: schedule the cleave at the CONTACT tick (07-20 G2.1) -- or fire same-tick when the
// knob is 0 (legacy) / the entity has no pending slot (plain test worlds). Damage stays SERVER-only;
// payload is built from LIVE state at the moment it fires (the cast-turn steers until the blade lands).
if (swingStarted && isServer)
{
bool isFin = swingStep >= comboLen;
// Slice 2: fold the player's class/run StatModifiers onto the live-tunable melee base so the
// PRIMARY verb scales with class identity (Warrior +MeleeDamage/+reach) + run augments.
byte bflags = m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0;
bool hasMods = m_StatModLookup.HasBuffer(entity);
float pDamage = math.max(0f, hasMods ? StatMath.Apply(baseDamage, StatTarget.MeleeDamage, m_StatModLookup[entity]) : baseDamage);
float pRange = math.max(0f, hasMods ? StatMath.Apply(baseRange, StatTarget.MeleeRange, m_StatModLookup[entity]) : baseRange);
// Manual-aim cleave (07-15): the cone follows the CURRENT tick's replicated Aim (SoD skillshot
// grammar) — PlayerFacing is body-yaw only; the facing fallback covers a resting gamepad stick.
float2 face = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction);
cleaves.Add(new PendingCleave
uint ct = MeleeTiming.ContactTicks(swingStep, contactKnob);
if (ct > 0u && hasPending)
{
From = xform.ValueRO.Position,
Face = face,
Damage = isFin ? pDamage * finisherMult : pDamage,
Range = isFin ? pRange * finisherMult : pRange,
KnockSpeed = isFin ? knockSpeed * finisherMult : knockSpeed,
OwnerId = owner.ValueRO.NetworkId,
Stamp = stamp,
KnockUntil = knockUntil,
IsFinisher = isFin,
Detonate = (bflags & BoonFlag.FinisherDetonate) != 0,
Pull = (bflags & BoonFlag.KnockToPull) != 0,
});
// FLUSH a still-armed older cleave first (pathological knob combos can push contact past the
// recover lock -- fire it early rather than lose it; review wf_000bc247 C0/C12).
var pend = m_PendingLookup[entity];
if (pend.ResolveTick != 0u)
cleaves.Add(BuildCleave(pend.Step, comboLen, baseDamage, baseRange, knockSpeed,
finisherMult, finisherRangeMult, stamp, knockUntil, xform.ValueRO.Position,
input.ValueRO.Aim, facing.ValueRO.Direction, owner.ValueRO.NetworkId,
m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0,
m_StatModLookup.HasBuffer(entity), m_StatModLookup.HasBuffer(entity) ? m_StatModLookup[entity] : default));
m_PendingLookup[entity] = new MeleeCleavePending { ResolveTick = TickUtil.NonZero(now + ct), Step = swingStep };
}
else
{
cleaves.Add(BuildCleave(swingStep, comboLen, baseDamage, baseRange, knockSpeed,
finisherMult, finisherRangeMult, stamp, knockUntil, xform.ValueRO.Position,
input.ValueRO.Aim, facing.ValueRO.Direction, owner.ValueRO.NetworkId,
m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0,
m_StatModLookup.HasBuffer(entity), m_StatModLookup.HasBuffer(entity) ? m_StatModLookup[entity] : default));
}
}
}
@@ -33,7 +33,7 @@ namespace ProjectM.Simulation
/// <summary>Ticks facing stays aimed after a swing/fire starts (~0.22s @60Hz — matches the anim pulse
/// k_AttackAnimTicks so body yaw and the swing animation agree).</summary>
public const uint CastFacingTicks = 13;
public const uint CastFacingTicks = 26; // 07-19 heavy-weapon retime: cast-turn + swing-anim window tracks the full ~0.43s swing (was 13; MUST stay < MeleeRecoverTicks so the anim pulse re-triggers between chained swings)
[BurstCompile]
public void OnUpdate(ref SystemState state)