Tests: MC-4 combo + cone-math EditMode coverage
23 plain-Entities tests: cone predicate (range/bearing/coincident/planar), combo advance/chain-window/lockout/reset/idempotency, movement-commit + SwingStartTick rollback lower-bound, dash precedence (active window + same-tick tie + recovery tail), server cleave (cone select, finisher scaling, knockback, dead-exclusion, client-no-damage), comboLen=2 finisher, two-player co-op cleave, death-clears. 294/294 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,433 @@
|
|||||||
|
using NUnit.Framework;
|
||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Core;
|
||||||
|
using Unity.Entities;
|
||||||
|
using Unity.Mathematics;
|
||||||
|
using Unity.NetCode;
|
||||||
|
using Unity.Transforms;
|
||||||
|
|
||||||
|
namespace ProjectM.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// MC-4 — plain-Entities EditMode tests for the predicted MeleeComboSystem: the combo state machine (advance /
|
||||||
|
/// chain-window / lockout / reset / idempotent absolute-write start), the movement-commit (SwingStartTick rollback
|
||||||
|
/// lower-bound + dash precedence), and the SERVER-only cleave (cone selection, finisher scaling, knockback,
|
||||||
|
/// dead-enemy exclusion, client-no-damage). The state half runs on a plain world; the cleave half needs a
|
||||||
|
/// GameServer-flagged world so WorldUnmanaged.IsServer() gates the damage on (the same pattern ProjectileDamageSystem
|
||||||
|
/// relies on). True under-prediction rollback is a Play-validation item; here absolute-write idempotency is asserted.
|
||||||
|
/// </summary>
|
||||||
|
public class MeleeComboTests
|
||||||
|
{
|
||||||
|
static void SetServerTick(World world, uint tick)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
using var q = em.CreateEntityQuery(typeof(NetworkTime));
|
||||||
|
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
|
||||||
|
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
|
||||||
|
}
|
||||||
|
|
||||||
|
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint tick, bool server = false)
|
||||||
|
{
|
||||||
|
var world = server ? new World(name, WorldFlags.Game | WorldFlags.GameServer) : new World(name);
|
||||||
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||||
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<MeleeComboSystem>());
|
||||||
|
group.SortSystems();
|
||||||
|
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||||
|
SetServerTick(world, tick);
|
||||||
|
return (world, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Entity MakePlayer(EntityManager em, float2 facing, float3 pos = default)
|
||||||
|
{
|
||||||
|
var e = em.CreateEntity();
|
||||||
|
em.AddComponentData(e, new MeleeCombo());
|
||||||
|
em.AddComponentData(e, new CharacterControl { MoveVelocity = float3.zero });
|
||||||
|
em.AddComponentData(e, new PlayerInput());
|
||||||
|
em.AddComponentData(e, new PlayerFacing { Direction = facing });
|
||||||
|
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||||
|
em.AddComponentData(e, new GhostOwner { NetworkId = 7 });
|
||||||
|
em.AddComponentData(e, new DashState());
|
||||||
|
em.AddComponent<Simulate>(e);
|
||||||
|
em.AddComponent<Dead>(e);
|
||||||
|
em.SetComponentEnabled<Dead>(e, false);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Entity MakeEnemy(EntityManager em, float3 pos, float hp = 50f, bool knockback = true)
|
||||||
|
{
|
||||||
|
var e = em.CreateEntity();
|
||||||
|
em.AddComponent<EnemyTag>(e);
|
||||||
|
em.AddComponentData(e, new Health { Current = hp, Max = 50f });
|
||||||
|
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||||
|
em.AddBuffer<DamageEvent>(e);
|
||||||
|
if (knockback) em.AddComponentData(e, new KnockbackState());
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Press(EntityManager em, Entity player)
|
||||||
|
{
|
||||||
|
var pi = em.GetComponentData<PlayerInput>(player);
|
||||||
|
pi.Attack.Set();
|
||||||
|
em.SetComponentData(player, pi);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- state machine (plain world) ----
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Swing_Starts_On_Press_From_Idle_Step1()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("MeleeStart", 100);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var p = MakePlayer(em, new float2(0, 1));
|
||||||
|
Press(em, p);
|
||||||
|
group.Update();
|
||||||
|
var mc = em.GetComponentData<MeleeCombo>(p);
|
||||||
|
Assert.AreEqual(1, mc.Step, "first swing is step 1");
|
||||||
|
Assert.AreEqual(100u, mc.SwingStartTick, "SwingStartTick = now");
|
||||||
|
Assert.AreEqual(116u, mc.LockUntilTick, "LockUntilTick = now + recover 16");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Lockout_Blocks_A_Repress_Mid_Swing()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("MeleeLock", 100);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var p = MakePlayer(em, new float2(0, 1));
|
||||||
|
em.SetComponentData(p, new MeleeCombo { Step = 1, SwingStartTick = 95, LockUntilTick = 116 });
|
||||||
|
Press(em, p);
|
||||||
|
group.Update(); // now 100 < lock 116 -> locked
|
||||||
|
var mc = em.GetComponentData<MeleeCombo>(p);
|
||||||
|
Assert.AreEqual(1, mc.Step, "locked: no new swing");
|
||||||
|
Assert.AreEqual(95u, mc.SwingStartTick, "swing start unchanged while locked");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Chain_Advances_Step_When_Repressed_In_Grace_Window()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("MeleeChain", 116); // lock just ended
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var p = MakePlayer(em, new float2(0, 1));
|
||||||
|
em.SetComponentData(p, new MeleeCombo { Step = 1, SwingStartTick = 100, LockUntilTick = 116 });
|
||||||
|
Press(em, p);
|
||||||
|
group.Update(); // 116 in [116, 116+18) -> chain to 2
|
||||||
|
Assert.AreEqual(2, em.GetComponentData<MeleeCombo>(p).Step, "re-press in the grace window chains to step 2");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Finisher_Chain_And_Longer_Recovery()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("MeleeFinish", 116);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var p = MakePlayer(em, new float2(0, 1));
|
||||||
|
em.SetComponentData(p, new MeleeCombo { Step = 2, SwingStartTick = 100, LockUntilTick = 116 });
|
||||||
|
Press(em, p);
|
||||||
|
group.Update(); // step 2 -> 3 (finisher); recover = round(16 * 1.8) = 29 -> lock 116 + 29 = 145
|
||||||
|
var mc = em.GetComponentData<MeleeCombo>(p);
|
||||||
|
Assert.AreEqual(3, mc.Step, "step 2 chains to the finisher (3)");
|
||||||
|
Assert.AreEqual(145u, mc.LockUntilTick, "finisher recover = round(16 * 1.8) = 29 ticks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Repress_After_Grace_Resets_To_Step1()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("MeleeReset", 140); // 140 > lock 116 + grace 18 = 134
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var p = MakePlayer(em, new float2(0, 1));
|
||||||
|
em.SetComponentData(p, new MeleeCombo { Step = 2, SwingStartTick = 100, LockUntilTick = 116 });
|
||||||
|
Press(em, p);
|
||||||
|
group.Update();
|
||||||
|
Assert.AreEqual(1, em.GetComponentData<MeleeCombo>(p).Step, "a press past the grace window restarts the combo at 1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Start_Is_Idempotent_At_The_Same_Tick()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("MeleeIdem", 100);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var p = MakePlayer(em, new float2(0, 1));
|
||||||
|
Press(em, p);
|
||||||
|
group.Update();
|
||||||
|
var first = em.GetComponentData<MeleeCombo>(p);
|
||||||
|
group.Update(); // same tick, Attack still set -> locked, no re-trigger
|
||||||
|
var second = em.GetComponentData<MeleeCombo>(p);
|
||||||
|
Assert.AreEqual(first.Step, second.Step, "re-running the start tick must not advance the combo");
|
||||||
|
Assert.AreEqual(first.SwingStartTick, second.SwingStartTick, "swing start set exactly once");
|
||||||
|
Assert.AreEqual(first.LockUntilTick, second.LockUntilTick, "lock set exactly once");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Movement_Is_Scaled_During_The_Swing_Window()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("MeleeMove", 100);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var p = MakePlayer(em, new float2(0, 1));
|
||||||
|
em.SetComponentData(p, new MeleeCombo { Step = 1, SwingStartTick = 95, LockUntilTick = 116 }); // mid-swing
|
||||||
|
em.SetComponentData(p, new CharacterControl { MoveVelocity = new float3(0, 0, 10) });
|
||||||
|
group.Update(); // no press; just the movement commit
|
||||||
|
Assert.AreEqual(3.5f, em.GetComponentData<CharacterControl>(p).MoveVelocity.z, 1e-3f,
|
||||||
|
"mid-swing scales MoveVelocity by MeleeSwingMoveScale (0.35)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ReSimulated_PreSwing_Tick_Gets_No_Movement_Scale()
|
||||||
|
{
|
||||||
|
// SwingStartTick is the lower bound: a re-simulated tick BEFORE the swing keeps PlayerControl's velocity.
|
||||||
|
var (world, group) = MakeWorld("MeleePreSwing", 90); // 90 < SwingStartTick 95
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var p = MakePlayer(em, new float2(0, 1));
|
||||||
|
em.SetComponentData(p, new MeleeCombo { Step = 1, SwingStartTick = 95, LockUntilTick = 116 });
|
||||||
|
em.SetComponentData(p, new CharacterControl { MoveVelocity = new float3(0, 0, 10) });
|
||||||
|
group.Update();
|
||||||
|
Assert.AreEqual(10f, em.GetComponentData<CharacterControl>(p).MoveVelocity.z, 1e-3f,
|
||||||
|
"a re-simulated PRE-swing tick keeps PlayerControl's velocity (SwingStartTick lower bound)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Active_Dash_Blocks_Swing_And_Movement_Scale()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("MeleeDashCancel", 100);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var p = MakePlayer(em, new float2(0, 1));
|
||||||
|
em.SetComponentData(p, new DashState { StartTick = 90, IFrameUntilTick = 112, RecoverUntilTick = 121 });
|
||||||
|
em.SetComponentData(p, new MeleeCombo { Step = 1, SwingStartTick = 95, LockUntilTick = 116 });
|
||||||
|
em.SetComponentData(p, new CharacterControl { MoveVelocity = new float3(0, 0, 10) });
|
||||||
|
Press(em, p);
|
||||||
|
group.Update();
|
||||||
|
Assert.AreEqual(1, em.GetComponentData<MeleeCombo>(p).Step, "dash active: a swing press does not advance the combo");
|
||||||
|
Assert.AreEqual(10f, em.GetComponentData<CharacterControl>(p).MoveVelocity.z, 1e-3f,
|
||||||
|
"dash active: melee does NOT scale movement (the dash owns it)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- server cleave (GameServer world) ----
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Cleave_Damages_Living_Enemies_In_Cone_Only()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("MeleeCleave", 100, server: true);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var p = MakePlayer(em, new float2(0, 1)); // facing +Z, at origin
|
||||||
|
var inCone = MakeEnemy(em, new float3(0, 0, 2)); // ahead, in range/cone -> hit
|
||||||
|
var behind = MakeEnemy(em, new float3(0, 0, -2)); // behind -> miss
|
||||||
|
var dead = MakeEnemy(em, new float3(0, 0, 1.5f), hp: 0f); // in cone but dead -> excluded
|
||||||
|
Press(em, p);
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
var dIn = em.GetBuffer<DamageEvent>(inCone);
|
||||||
|
Assert.AreEqual(1, dIn.Length, "the in-cone living enemy takes one cleave hit");
|
||||||
|
Assert.AreEqual(18f, dIn[0].Amount, 1e-3f, "light-hit damage = MeleeDamage default 18");
|
||||||
|
Assert.AreEqual(100u, dIn[0].SourceTick, "SourceTick stamped = swing tick");
|
||||||
|
Assert.AreEqual(7, dIn[0].SourceNetworkId, "attributed to the swinging player's NetworkId");
|
||||||
|
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(behind).Length, "the enemy behind is outside the cone");
|
||||||
|
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(dead).Length, "a dead enemy is excluded from the cleave");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Finisher_Hits_Harder_And_Wider()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("MeleeFinisherDmg", 116, server: true);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var p = MakePlayer(em, new float2(0, 1));
|
||||||
|
em.SetComponentData(p, new MeleeCombo { Step = 2, SwingStartTick = 100, LockUntilTick = 116 }); // chain to 3
|
||||||
|
// distance 4: beyond light range 2.6, within finisher range 2.6 * 1.8 = 4.68 -> only the finisher reaches.
|
||||||
|
var far = MakeEnemy(em, new float3(0, 0, 4));
|
||||||
|
Press(em, p);
|
||||||
|
group.Update();
|
||||||
|
var d = em.GetBuffer<DamageEvent>(far);
|
||||||
|
Assert.AreEqual(1, d.Length, "the finisher's widened range reaches the far enemy");
|
||||||
|
Assert.AreEqual(18f * 1.8f, d[0].Amount, 1e-2f, "finisher damage = 18 * finisher mult 1.8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Light_Swing_Does_Not_Reach_The_Finisher_Range()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("MeleeLightRange", 100, server: true);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var p = MakePlayer(em, new float2(0, 1));
|
||||||
|
var far = MakeEnemy(em, new float3(0, 0, 4)); // beyond light range 2.6
|
||||||
|
Press(em, p); // step 1 light
|
||||||
|
group.Update();
|
||||||
|
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(far).Length, "a light swing (range 2.6) does not reach an enemy at distance 4");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Cleave_Stamps_Knockback_Away_From_The_Player()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("MeleeKnock", 100, server: true);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var p = MakePlayer(em, new float2(0, 1));
|
||||||
|
var enemy = MakeEnemy(em, new float3(0, 0, 2));
|
||||||
|
Press(em, p);
|
||||||
|
group.Update();
|
||||||
|
var kb = em.GetComponentData<KnockbackState>(enemy);
|
||||||
|
Assert.Greater(kb.Speed, 0f, "knockback speed stamped");
|
||||||
|
Assert.AreEqual(0f, kb.Dir.x, 1e-3f, "knockback points straight away (+Z): no X component");
|
||||||
|
Assert.AreEqual(1f, kb.Dir.y, 1e-3f, "knockback dir planar-y = world +Z (away from the player)");
|
||||||
|
Assert.AreNotEqual(0u, kb.UntilTick, "knockback window scheduled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Client_World_Predicts_The_Swing_But_Applies_No_Damage()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("MeleeClientNoDamage", 100, server: false);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var p = MakePlayer(em, new float2(0, 1));
|
||||||
|
var enemy = MakeEnemy(em, new float3(0, 0, 2));
|
||||||
|
Press(em, p);
|
||||||
|
group.Update();
|
||||||
|
Assert.AreEqual(1, em.GetComponentData<MeleeCombo>(p).Step, "the client still PREDICTS the swing state");
|
||||||
|
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(enemy).Length,
|
||||||
|
"but a non-server world never applies enemy damage (server-authoritative)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- death cleanup ----
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Death_Clears_The_Combo()
|
||||||
|
{
|
||||||
|
var world = new World("MeleeDeath");
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||||
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<PlayerDeathStateSystem>());
|
||||||
|
group.SortSystems();
|
||||||
|
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||||
|
SetServerTick(world, 100);
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var e = em.CreateEntity();
|
||||||
|
em.AddComponent<PlayerTag>(e);
|
||||||
|
em.AddComponentData(e, new Health { Current = 0f, Max = 100f }); // dead
|
||||||
|
em.AddComponentData(e, new CharacterControl { MoveVelocity = float3.zero });
|
||||||
|
em.AddComponentData(e, new MeleeCombo { Step = 2, SwingStartTick = 90, LockUntilTick = 116 }); // mid-combo
|
||||||
|
em.AddComponent<Simulate>(e);
|
||||||
|
em.AddComponent<Dead>(e);
|
||||||
|
em.SetComponentEnabled<Dead>(e, false);
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
var mc = em.GetComponentData<MeleeCombo>(e);
|
||||||
|
Assert.AreEqual(0, mc.Step, "death clears the combo step");
|
||||||
|
Assert.AreEqual(0u, mc.LockUntilTick, "death clears the combo lock");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ComboLength2_Makes_Step2_The_Finisher()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("MeleeLen2", 116, server: true);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var cfg = TuningConfig.Defaults();
|
||||||
|
cfg.MeleeComboLength = 2f;
|
||||||
|
em.SetComponentData(em.CreateEntity(typeof(TuningConfig)), cfg);
|
||||||
|
|
||||||
|
var p = MakePlayer(em, new float2(0, 1));
|
||||||
|
em.SetComponentData(p, new MeleeCombo { Step = 1, SwingStartTick = 100, LockUntilTick = 116 }); // chain
|
||||||
|
var enemy = MakeEnemy(em, new float3(0, 0, 2));
|
||||||
|
Press(em, p);
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
Assert.AreEqual(2, em.GetComponentData<MeleeCombo>(p).Step, "comboLen=2: step 1 chains to the finisher (2)");
|
||||||
|
var d = em.GetBuffer<DamageEvent>(enemy);
|
||||||
|
Assert.AreEqual(1, d.Length, "the finisher cleave lands");
|
||||||
|
Assert.AreEqual(18f * 1.8f, d[0].Amount, 1e-2f, "comboLen=2 step-2 IS the finisher: 18 * 1.8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Same_Tick_Attack_And_Dash_Yields_To_The_Dash()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("MeleeAttackDashTie", 100);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var p = MakePlayer(em, new float2(0, 1)); // idle
|
||||||
|
var pi = new PlayerInput();
|
||||||
|
pi.Attack.Set();
|
||||||
|
pi.Dash.Set();
|
||||||
|
em.SetComponentData(p, pi);
|
||||||
|
group.Update();
|
||||||
|
Assert.AreEqual(0, em.GetComponentData<MeleeCombo>(p).Step,
|
||||||
|
"Attack + Dash the same tick: the dash wins, no swing starts (Step stays idle)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Attack_During_Dash_Recovery_Tail_Is_Blocked()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("MeleeDashRecoveryBlock", 110);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var p = MakePlayer(em, new float2(0, 1));
|
||||||
|
// i-frames ended at 100, recovery active until 121: tick 110 is the recovery-only tail.
|
||||||
|
em.SetComponentData(p, new DashState { StartTick = 90, IFrameUntilTick = 100, RecoverUntilTick = 121 });
|
||||||
|
Press(em, p);
|
||||||
|
group.Update();
|
||||||
|
Assert.AreEqual(0, em.GetComponentData<MeleeCombo>(p).Step,
|
||||||
|
"a swing press during the dash RECOVERY tail (not just i-frames) is blocked");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Two_Players_Both_Cleave_The_Same_Enemy()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("MeleeCoOpCleave", 100, server: true);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var a = MakePlayer(em, new float2(0, 1), new float3(-0.3f, 0, 0));
|
||||||
|
var b = MakePlayer(em, new float2(0, 1), new float3(0.3f, 0, 0));
|
||||||
|
var enemy = MakeEnemy(em, new float3(0, 0, 2)); // in both players' cones
|
||||||
|
Press(em, a);
|
||||||
|
Press(em, b);
|
||||||
|
group.Update();
|
||||||
|
Assert.AreEqual(2, em.GetBuffer<DamageEvent>(enemy).Length,
|
||||||
|
"both co-op players' cleaves accumulate on the shared enemy's DamageEvent buffer");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0af52c7d172579945a6dfde5ea06b234
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using NUnit.Framework;
|
||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Mathematics;
|
||||||
|
|
||||||
|
namespace ProjectM.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// MC-4 — pure cone hit-test (<see cref="MeleeConeMath.InCone"/>): the planar XZ range gate + the
|
||||||
|
/// dot(bearing, facing) >= cos(halfAngle) cone gate, plus the coincident / zero-facing / non-positive-range
|
||||||
|
/// guards. Shares the exact predicate AutoTarget uses, but as a per-candidate boolean for the collect-all cleave.
|
||||||
|
/// </summary>
|
||||||
|
public class MeleeConeMathTests
|
||||||
|
{
|
||||||
|
const float Cos60 = 0.5f; // cos(60deg): half-angle 60 -> 120deg full cone
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void In_Range_And_Straight_Ahead_Hits()
|
||||||
|
{
|
||||||
|
Assert.IsTrue(MeleeConeMath.InCone(float3.zero, new float2(0, 1), 3f, Cos60, new float3(0, 0, 2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Out_Of_Range_Misses()
|
||||||
|
{
|
||||||
|
Assert.IsFalse(MeleeConeMath.InCone(float3.zero, new float2(0, 1), 3f, Cos60, new float3(0, 0, 4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void At_90_Degrees_Outside_Cone_Misses()
|
||||||
|
{
|
||||||
|
// bearing +X vs facing +Z: dot = 0 < cos60 (0.5) -> outside the cone.
|
||||||
|
Assert.IsFalse(MeleeConeMath.InCone(float3.zero, new float2(0, 1), 3f, Cos60, new float3(2, 0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void At_45_Degrees_Inside_Cone_Hits()
|
||||||
|
{
|
||||||
|
// bearing ~45deg: dot ~0.707 > 0.5 -> inside.
|
||||||
|
Assert.IsTrue(MeleeConeMath.InCone(float3.zero, new float2(0, 1), 5f, Cos60, new float3(2, 0, 2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Behind_The_Player_Misses()
|
||||||
|
{
|
||||||
|
Assert.IsFalse(MeleeConeMath.InCone(float3.zero, new float2(0, 1), 5f, Cos60, new float3(0, 0, -2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Coincident_Target_Excluded()
|
||||||
|
{
|
||||||
|
Assert.IsFalse(MeleeConeMath.InCone(float3.zero, new float2(0, 1), 3f, Cos60, float3.zero));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Zero_Facing_Or_NonPositive_Range_Misses()
|
||||||
|
{
|
||||||
|
Assert.IsFalse(MeleeConeMath.InCone(float3.zero, float2.zero, 3f, Cos60, new float3(0, 0, 2)), "zero facing -> miss");
|
||||||
|
Assert.IsFalse(MeleeConeMath.InCone(float3.zero, new float2(0, 1), 0f, Cos60, new float3(0, 0, 1)), "range 0 -> miss");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Uses_Planar_XZ_And_Ignores_Y()
|
||||||
|
{
|
||||||
|
// straight ahead in XZ but far in Y: still a hit (Y ignored, top-down).
|
||||||
|
Assert.IsTrue(MeleeConeMath.InCone(float3.zero, new float2(0, 1), 3f, Cos60, new float3(0, 5, 2)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 2dbd5a5c09dd1fb4dbcb68a1afb15274
|
||||||
Reference in New Issue
Block a user