Slice Combat Depth (MC-2): enemy-variety server spine — Spitter, Swarmer, 4-type mix (DR-041)
Adds the server-authoritative mechanics for three new enemy archetypes on top of the Grunt/Charger base, plus the weighted wave-composition that introduces them: - Spitter: a ranged Husk variant (SpitterState) that holds a preferred range-band (advance/retreat/hold via EnemyAIMath.BandVelocity) and fires a telegraphed, dodgeable EnemyProjectile. New server EnemyProjectileMoveSystem (integrate + store LastStep) + EnemyProjectileDamageSystem (region-filtered swept hit-test rebuilt from LastStep — DR-018 anti-tunnelling; players use HitRadius, structures a const radius; at-most-once destroy). Concurrent-spit soft cap, soft-fail retry. - Swarmer: marker tag + deterministic cluster spawn (1 slot = 1 pack; EnemyAIMath.ClusterOffset), MaxAlive counts ENTITIES so a pack defers if it won't fit. - 4-type weighted mix: MixBands -> ZoneEnemyMath.WaveSlots/KindForSlot/ PackSizeForSlot drives both the expedition director and (fork-4a) the base siege, with a mandatory MaxAlive cap. Legacy WaveSize/IsChargerSlot kept + parity-tested. - Discriminator stays component-presence (no enum in Bursted systems): query- partition guards keep each enemy moved by exactly one EnemyAISystem pass (sole-Position-writer). EnemyTelegraph.IsCharger -> Kind byte for the client cue. New authoring (Spitter/Swarmer/EnemyProjectile) + expanded director authorings with tunable mix/cluster defaults. 13 new EditMode tests (mix composition + legacy parity, band/cluster math, projectile move + cross-region + swept anti-tunnelling regressions); full suite green before commit. Dormant until the prefab/subscene wiring lands (next): the new systems guard on TryGetSingleton/RequireForUpdate, so with no prefabs wired the new types stay inert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Mathematics;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-2 pure-math tests for the new EnemyAIMath helpers: BandVelocity (Spitter range-band keep-distance) and
|
||||
/// ClusterOffset (swarmer pack placement). No ECS world.
|
||||
/// </summary>
|
||||
public class EnemyAIMathMC2Tests
|
||||
{
|
||||
[Test]
|
||||
public void BandVelocity_AdvancesWhenTooFar()
|
||||
{
|
||||
var v = EnemyAIMath.BandVelocity(new float3(0, 1, 0), new float3(20, 1, 0), 5f, 9f, 1.5f);
|
||||
Assert.Greater(v.x, 0.1f, "too far -> moves toward the target");
|
||||
Assert.AreEqual(0f, v.y, 1e-5f, "planar");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BandVelocity_RetreatsWhenTooClose()
|
||||
{
|
||||
var v = EnemyAIMath.BandVelocity(new float3(0, 1, 0), new float3(3, 1, 0), 5f, 9f, 1.5f);
|
||||
Assert.Less(v.x, -0.1f, "too close -> backs away from the target");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BandVelocity_HoldsInBand()
|
||||
{
|
||||
var v = EnemyAIMath.BandVelocity(new float3(0, 1, 0), new float3(9, 1, 0), 5f, 9f, 1.5f);
|
||||
Assert.AreEqual(0f, math.length(v), 1e-4f, "inside the dead-zone band -> hold and fire");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClusterOffset_SingleAtCentre_PackSpread()
|
||||
{
|
||||
var c = new float3(100, 1, 5);
|
||||
var single = EnemyAIMath.ClusterOffset(c, 0, 1, 2.5f);
|
||||
Assert.AreEqual(c.x, single.x, 1e-5f, "a lone swarmer spawns at the pack centre");
|
||||
Assert.AreEqual(c.z, single.z, 1e-5f);
|
||||
var a = EnemyAIMath.ClusterOffset(c, 0, 4, 2.5f);
|
||||
var b = EnemyAIMath.ClusterOffset(c, 1, 4, 2.5f);
|
||||
Assert.Greater(math.distance(a, b), 0.01f, "pack members get distinct offsets");
|
||||
var again = EnemyAIMath.ClusterOffset(c, 2, 4, 2.5f);
|
||||
Assert.AreEqual(0f, math.distance(again, EnemyAIMath.ClusterOffset(c, 2, 4, 2.5f)), 1e-5f, "deterministic");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9242a20ea43527243a4e93c343f0cb49
|
||||
@@ -0,0 +1,132 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Server;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-2 tests for the hostile Spitter projectile systems (server-only, plain SimulationSystemGroup):
|
||||
/// EnemyProjectileMoveSystem integrates + writes LastStep; EnemyProjectileDamageSystem swept-hit-tests players +
|
||||
/// structures, REGION-FILTERED, appending a DamageEvent + destroying the spit at-most-once. Covers the two
|
||||
/// review-mandated regressions: swept anti-TUNNELLING (a per-tick step bigger than the target radius still
|
||||
/// registers) and the cross-region damage guard (an Expedition spit must not damage a Base target on its path).
|
||||
/// </summary>
|
||||
public class EnemyProjectileTests
|
||||
{
|
||||
static void SetTick(World w, uint tick)
|
||||
{
|
||||
var em = w.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, SimulationSystemGroup) MoveWorld()
|
||||
{
|
||||
var w = new World("EnemyProjMove");
|
||||
var g = w.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
g.AddSystemToUpdateList(w.GetOrCreateSystem<EnemyProjectileMoveSystem>());
|
||||
g.SortSystems();
|
||||
w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 0.1f));
|
||||
return (w, g);
|
||||
}
|
||||
|
||||
static (World, SimulationSystemGroup) DamageWorld()
|
||||
{
|
||||
var w = new World("EnemyProjDmg");
|
||||
var g = w.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
g.AddSystemToUpdateList(w.GetOrCreateSystem<EnemyProjectileDamageSystem>());
|
||||
g.SortSystems();
|
||||
w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 0.1f));
|
||||
SetTick(w, 200);
|
||||
return (w, g);
|
||||
}
|
||||
|
||||
static Entity MakeSpit(EntityManager em, float3 pos, float2 dir, float speed, float range, byte region, float lastStep = 0f, float damage = 10f)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.AddComponentData(e, new EnemyProjectile { Direction = dir, Speed = speed, Damage = damage, Range = range, DistanceTravelled = 0f, LastStep = lastStep, Region = region });
|
||||
return e;
|
||||
}
|
||||
|
||||
static Entity MakePlayerTarget(EntityManager em, float3 pos, byte region, float radius = 0.6f)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
|
||||
em.AddComponentData(e, new HitRadius { Value = radius });
|
||||
em.AddComponentData(e, new RegionTag { Region = region });
|
||||
em.AddBuffer<DamageEvent>(e);
|
||||
em.AddComponent<PlayerTag>(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Move_IntegratesAndStoresLastStep()
|
||||
{
|
||||
var (w, g) = MoveWorld();
|
||||
using (w)
|
||||
{
|
||||
var em = w.EntityManager;
|
||||
var spit = MakeSpit(em, new float3(0, 1, 0), new float2(1, 0), 10f, 5f, RegionId.Base);
|
||||
g.Update(); // dt 0.1 * speed 10 = step 1
|
||||
var p = em.GetComponentData<EnemyProjectile>(spit);
|
||||
Assert.AreEqual(1f, p.LastStep, 1e-4f, "LastStep = Speed*dt (for the swept segment)");
|
||||
Assert.AreEqual(1f, p.DistanceTravelled, 1e-4f);
|
||||
Assert.AreEqual(1f, em.GetComponentData<LocalTransform>(spit).Position.x, 1e-4f, "moved along +X");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Damage_HitsSameRegionPlayer_DestroysAtMostOnce()
|
||||
{
|
||||
var (w, g) = DamageWorld();
|
||||
using (w)
|
||||
{
|
||||
var em = w.EntityManager;
|
||||
var player = MakePlayerTarget(em, new float3(5, 1, 0), RegionId.Base);
|
||||
var spit = MakeSpit(em, new float3(5, 1, 0), new float2(1, 0), 10f, 20f, RegionId.Base, lastStep: 1f);
|
||||
g.Update();
|
||||
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(player).Length, "same-region player takes the hit");
|
||||
Assert.IsFalse(em.Exists(spit), "the spit is consumed on hit");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Damage_RegionFilter_ExpeditionSpitSparesBasePlayer()
|
||||
{
|
||||
var (w, g) = DamageWorld();
|
||||
using (w)
|
||||
{
|
||||
var em = w.EntityManager;
|
||||
var basePlayer = MakePlayerTarget(em, new float3(5, 1, 0), RegionId.Base);
|
||||
var spit = MakeSpit(em, new float3(5, 1, 0), new float2(1, 0), 10f, 20f, RegionId.Expedition, lastStep: 1f);
|
||||
g.Update();
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(basePlayer).Length, "cross-region spit must NOT damage an off-region player");
|
||||
Assert.IsTrue(em.Exists(spit), "and it is not consumed by an off-region target");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Damage_SweptSegment_NoTunnelThroughSmallTarget()
|
||||
{
|
||||
var (w, g) = DamageWorld();
|
||||
using (w)
|
||||
{
|
||||
var em = w.EntityManager;
|
||||
// target radius 0.5 at x=5; spit now at x=10 but stepped 8 this tick (start x=2) -> segment [2..10] crosses x=5.
|
||||
var player = MakePlayerTarget(em, new float3(5, 1, 0), RegionId.Base, radius: 0.5f);
|
||||
var spit = MakeSpit(em, new float3(10, 1, 0), new float2(1, 0), 80f, 50f, RegionId.Base, lastStep: 8f);
|
||||
g.Update();
|
||||
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(player).Length,
|
||||
"swept segment hits even when the per-tick step exceeds the target radius (no tunnelling)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e63d6c6d98027f248be3fc163961ca95
|
||||
@@ -0,0 +1,91 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Simulation;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-2 pure-math tests for the 4-type weighted composition (ZoneEnemyMath.WaveSlots / KindForSlot /
|
||||
/// PackSizeForSlot) shared by both enemy directors. Deterministic integer math (no ECS world). The PARITY test
|
||||
/// pins that the legacy band reproduces the old 2-type WaveSize/IsChargerSlot EXACTLY, so the base-siege size +
|
||||
/// composition is provably controlled where it must be (the fork-4a safety net).
|
||||
/// </summary>
|
||||
public class ZoneEnemyMixTests
|
||||
{
|
||||
static MixBands Bands(int g, int c, int sp, int sw, int cPer, int spPer, int swPer, int packPer = 0) => new MixBands
|
||||
{
|
||||
GruntBase = g, ChargerBase = c, SpitterBase = sp, SwarmerSlotBase = sw,
|
||||
ChargerPerEpoch = cPer, SpitterPerEpoch = spPer, SwarmerSlotPerEpoch = swPer, SwarmerPackPerEpoch = packPer,
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void WaveSlots_LowerBoundedAtOne_AndSumsTheBands()
|
||||
{
|
||||
Assert.AreEqual(1, ZoneEnemyMath.WaveSlots(1, Bands(0, 0, 0, 0, 0, 0, 0)), "empty band still yields a fight");
|
||||
Assert.AreEqual(5, ZoneEnemyMath.WaveSlots(1, Bands(4, 1, 0, 0, 1, 0, 0)), "4 grunts + 1 charger at epoch 1");
|
||||
Assert.AreEqual(7, ZoneEnemyMath.WaveSlots(3, Bands(4, 1, 0, 0, 1, 0, 0)), "epoch 3: +1 charger/epoch -> 4+(1+2)");
|
||||
Assert.AreEqual(4 + 2 + 1 + 1, ZoneEnemyMath.WaveSlots(2, Bands(4, 1, 0, 0, 1, 1, 1)), "epoch 2: 4 grunts + 2 chargers + 1 spitter + 1 swarmer-slot");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void KindForSlot_Deterministic()
|
||||
{
|
||||
var b = Bands(4, 1, 1, 1, 1, 1, 1);
|
||||
for (int slot = 0; slot < 30; slot++)
|
||||
Assert.AreEqual(ZoneEnemyMath.KindForSlot(5, slot, b), ZoneEnemyMath.KindForSlot(5, slot, b), "stable per (epoch,slot)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void KindForSlot_GruntFloorFixed_ThreatsGrowWithEpoch()
|
||||
{
|
||||
var b = Bands(4, 1, 0, 0, 1, 0, 0); // grunts fixed at 4, chargers grow
|
||||
CountKinds(b, 1, out int g1, out int c1, out int _, out int _);
|
||||
Assert.AreEqual(4, g1); Assert.AreEqual(1, c1);
|
||||
CountKinds(b, 5, out int g5, out int c5, out int _, out int _);
|
||||
Assert.AreEqual(4, g5, "grunt count is a fixed floor"); Assert.AreEqual(5, c5, "chargers = base + (epoch-1)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void KindForSlot_ParityWithLegacyIsChargerSlot()
|
||||
{
|
||||
for (int g = 0; g <= 6; g++)
|
||||
for (int c = 0; c <= 4; c++)
|
||||
for (int e = 1; e <= 6; e++)
|
||||
{
|
||||
var b = Bands(g, c, 0, 0, 1, 0, 0); // legacy band: charger ramps +1/epoch, no spitter/swarmer
|
||||
int size = ZoneEnemyMath.WaveSlots(e, b);
|
||||
Assert.AreEqual(ZoneEnemyMath.WaveSize(e, g, c), size, $"WaveSlots vs WaveSize g{g} c{c} e{e}");
|
||||
for (int slot = 0; slot < size + 3; slot++)
|
||||
{
|
||||
bool legacy = ZoneEnemyMath.IsChargerSlot(e, slot, g, c);
|
||||
bool now = ZoneEnemyMath.KindForSlot(e, slot, b) == ZoneEnemyMath.KindCharger;
|
||||
Assert.AreEqual(legacy, now, $"parity g{g} c{c} e{e} slot{slot}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PackSizeForSlot_FixedByDefault_RampsWhenSet()
|
||||
{
|
||||
var fixedBand = Bands(0, 0, 0, 1, 0, 0, 1);
|
||||
Assert.AreEqual(4, ZoneEnemyMath.PackSizeForSlot(1, 0, fixedBand, 4), "base pack");
|
||||
Assert.AreEqual(4, ZoneEnemyMath.PackSizeForSlot(5, 0, fixedBand, 4), "no ramp -> fixed across epochs");
|
||||
var rampBand = Bands(0, 0, 0, 1, 0, 0, 1, packPer: 2);
|
||||
Assert.AreEqual(4 + 2 * 2, ZoneEnemyMath.PackSizeForSlot(3, 0, rampBand, 4), "epoch 3 ramp +2*(3-1)");
|
||||
Assert.GreaterOrEqual(ZoneEnemyMath.PackSizeForSlot(1, 0, fixedBand, 0), 1, "lower-bounded at 1");
|
||||
}
|
||||
|
||||
static void CountKinds(MixBands b, int epoch, out int g, out int c, out int sp, out int sw)
|
||||
{
|
||||
g = c = sp = sw = 0;
|
||||
int size = ZoneEnemyMath.WaveSlots(epoch, b);
|
||||
for (int slot = 0; slot < size; slot++)
|
||||
{
|
||||
byte k = ZoneEnemyMath.KindForSlot(epoch, slot, b);
|
||||
if (k == ZoneEnemyMath.KindGrunt) g++;
|
||||
else if (k == ZoneEnemyMath.KindCharger) c++;
|
||||
else if (k == ZoneEnemyMath.KindSpitter) sp++;
|
||||
else sw++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6853067864d6bc342bac525cdee324f8
|
||||
Reference in New Issue
Block a user