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 —