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:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user