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
@@ -34,6 +34,17 @@ namespace ProjectM.Tests
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetServerTick(world, tick);
// 07-19: PIN the melee knobs these mechanics tests were authored against. The LIVE feel defaults
// (TuningConfig.Defaults()) are free to drift for game feel (heavy-weapon retime moved recover 16->30,
// dmg 18->30, finisher 1.8->1.5); the chain/lock/cone MECHANICS under test are not.
var pinned = TuningConfig.Defaults();
pinned.MeleeDamage = 18f; pinned.MeleeRange = 2.6f; pinned.MeleeConeHalfAngleRad = 0.9f;
pinned.MeleeRecoverTicks = 16f; pinned.MeleeChainGraceTicks = 18f; pinned.MeleeSwingMoveScale = 0.35f;
pinned.MeleeKnockbackSpeed = 6f; pinned.MeleeFinisherMult = 1.8f; pinned.MeleeComboLength = 3f;
// 07-20 forks pinned LEGACY: finisher reach = the old combined mult, buffer OFF, same-tick cleave
// (contact 0 = the IMMEDIATE sentinel) -- new-behavior tests override per-test.
pinned.MeleeFinisherRangeMult = 1.8f; pinned.MeleeBufferTicks = 0f; pinned.MeleeContactTicks = 0f;
world.EntityManager.SetComponentData(world.EntityManager.CreateEntity(typeof(TuningConfig)), pinned);
return (world, group);
}
@@ -41,6 +52,7 @@ namespace ProjectM.Tests
{
var e = em.CreateEntity();
em.AddComponentData(e, new MeleeCombo());
em.AddComponent<MeleeCleavePending>(e); // 07-20 G2.1: the scheduled-cleave slot (baked zeroed on the real player)
em.AddComponentData(e, new CharacterControl { MoveVelocity = float3.zero });
em.AddComponentData(e, new PlayerInput());
em.AddComponentData(e, new PlayerFacing { Direction = facing });
@@ -71,6 +83,15 @@ namespace ProjectM.Tests
em.SetComponentData(player, pi);
}
/// <summary>07-20 (review C6): clear the held Attack event -- plain test worlds never clear InputEvents,
/// and a still-set Attack silently re-chains at any unlock tick the test advances across.</summary>
static void Release(EntityManager em, Entity player)
{
var pi = em.GetComponentData<PlayerInput>(player);
pi.Attack = default;
em.SetComponentData(player, pi);
}
// ---- state machine (plain world) ----
[Test]
@@ -359,9 +380,11 @@ namespace ProjectM.Tests
using (world)
{
var em = world.EntityManager;
var cfg = TuningConfig.Defaults();
// Modify the MakeWorld-pinned singleton (a second TuningConfig entity would break TryGetSingleton).
var cfgEnt = em.CreateEntityQuery(typeof(TuningConfig)).GetSingletonEntity();
var cfg = em.GetComponentData<TuningConfig>(cfgEnt);
cfg.MeleeComboLength = 2f;
em.SetComponentData(em.CreateEntity(typeof(TuningConfig)), cfg);
em.SetComponentData(cfgEnt, cfg);
var p = MakePlayer(em, new float2(0, 1));
em.SetComponentData(p, new MeleeCombo { Step = 1, SwingStartTick = 100, LockUntilTick = 116 }); // chain
@@ -488,5 +511,117 @@ namespace ProjectM.Tests
}
}
}
// ---- 07-20 melee feel forks (guidelines G2.1/G7; design review wf_000bc247) ----
[Test]
public void Cleave_Resolves_At_The_Contact_Tick_Exactly_Once()
{
var (world, group) = MakeWorld("MeleeContactDelay", 100, server: true);
using (world)
{
var em = world.EntityManager;
// contact 16 / recover 24: the resolve tick (116) lands INSIDE the lock (124) so a held press
// cannot re-chain at the observation tick (review C6's collision trap).
var cfgEnt = em.CreateEntityQuery(typeof(TuningConfig)).GetSingletonEntity();
var cfg = em.GetComponentData<TuningConfig>(cfgEnt);
cfg.MeleeContactTicks = 16f; cfg.MeleeRecoverTicks = 24f;
em.SetComponentData(cfgEnt, cfg);
var p = MakePlayer(em, new float2(0, 1));
var enemy = MakeEnemy(em, new float3(0, 0, 2));
Press(em, p);
group.Update(); // tick 100: the swing starts; the cleave is SCHEDULED for 116
Release(em, p);
Assert.AreEqual(1, em.GetComponentData<MeleeCombo>(p).Step, "swing started");
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(enemy).Length, "no damage at the swing-start tick (the blade hasn't landed)");
SetServerTick(world, 108); group.Update();
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(enemy).Length, "no damage pre-contact");
SetServerTick(world, 116); group.Update(); // the blade lands
var d = em.GetBuffer<DamageEvent>(enemy);
Assert.AreEqual(1, d.Length, "exactly one cleave at the contact tick");
Assert.AreEqual(116u, d[0].SourceTick, "damage is stamped with the CONTACT tick");
SetServerTick(world, 117); group.Update();
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(enemy).Length, "the pending cleave is consumed (no double resolve)");
}
}
[Test]
public void Buffered_Press_Starts_The_Chained_Swing_At_Unlock()
{
var (world, group) = MakeWorld("MeleeBuffer", 100, server: true);
using (world)
{
var em = world.EntityManager;
var cfgEnt = em.CreateEntityQuery(typeof(TuningConfig)).GetSingletonEntity();
var cfg = em.GetComponentData<TuningConfig>(cfgEnt);
cfg.MeleeBufferTicks = 8f; // contact stays pinned 0 (legacy resolve; the buffer is what's under test)
em.SetComponentData(cfgEnt, cfg);
var p = MakePlayer(em, new float2(0, 1));
Press(em, p);
group.Update(); // tick 100: swing 1, lock until 116 (pinned recover 16)
Release(em, p);
Assert.AreEqual(1, em.GetComponentData<MeleeCombo>(p).Step);
SetServerTick(world, 110); Press(em, p); group.Update(); // locked, 6 ticks remain (<= 8): buffered
Release(em, p);
Assert.AreNotEqual(0u, em.GetComponentData<MeleeCombo>(p).BufferedAttackTick, "the late press is remembered");
Assert.AreEqual(1, em.GetComponentData<MeleeCombo>(p).Step, "still swing 1 (locked)");
SetServerTick(world, 116); group.Update(); // unlock: the buffer fires the chain WITHOUT a live press
var mc = em.GetComponentData<MeleeCombo>(p);
Assert.AreEqual(2, mc.Step, "the buffered press chains to swing 2 at the unlock tick");
Assert.AreEqual(0u, mc.BufferedAttackTick, "the buffer is consumed");
}
}
[Test]
public void Dash_Is_Refused_Pre_Contact_And_Allowed_Post_Contact()
{
var world = new World("MeleeDashLaw", WorldFlags.Game | WorldFlags.GameServer);
using (world)
{
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<MeleeComboSystem>());
group.AddSystemToUpdateList(world.GetOrCreateSystem<DashSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetServerTick(world, 100);
var em = world.EntityManager;
var pinned = TuningConfig.Defaults();
pinned.MeleeDamage = 18f; pinned.MeleeRange = 2.6f; pinned.MeleeConeHalfAngleRad = 0.9f;
pinned.MeleeRecoverTicks = 30f; pinned.MeleeChainGraceTicks = 18f; pinned.MeleeSwingMoveScale = 0.35f;
pinned.MeleeKnockbackSpeed = 6f; pinned.MeleeFinisherMult = 1.8f; pinned.MeleeComboLength = 3f;
pinned.MeleeFinisherRangeMult = 1.8f; pinned.MeleeBufferTicks = 0f;
pinned.MeleeContactTicks = 16f; // the law under test needs a REAL pre-contact window
em.SetComponentData(em.CreateEntity(typeof(TuningConfig)), pinned);
var p = MakePlayer(em, new float2(0, 1));
em.AddComponentData(p, new DashCooldown { NextTick = 0 });
em.AddComponentData(p, CharacterComponent.GetDefault());
Press(em, p);
group.Update(); // tick 100: swing 1 starts (contact at 116)
Release(em, p);
Assert.AreEqual(1, em.GetComponentData<MeleeCombo>(p).Step, "swing started");
// pre-contact (tick 105): a dash press is REFUSED -- the Bathynaut commits (G7).
SetServerTick(world, 105);
var pi = em.GetComponentData<PlayerInput>(p); pi.Dash.Set(); em.SetComponentData(p, pi);
group.Update();
Assert.AreEqual(0u, em.GetComponentData<DashState>(p).StartTick, "dash refused while the blade is pre-contact");
// post-contact (tick 120, still inside the recover lock): the recovery dash-cancel opens.
SetServerTick(world, 120);
pi = em.GetComponentData<PlayerInput>(p); pi.Dash.Set(); em.SetComponentData(p, pi);
group.Update();
Assert.AreEqual(120u, em.GetComponentData<DashState>(p).StartTick, "post-contact recovery dash-cancel is allowed");
}
}
}
}