Slice Combat Depth (MC-3 + wiring + review fixes): Spitter aim-line + player-hit punch, rigged enemies, in-band gate (DR-041)
Completes the Combat Depth slice on top of the MC-2 server spine (56cf60cce):
MC-3 impact juice (client, observe-only):
- 7 FeelConfig fields + ResetDefaults; magnitude-scaled player-dealt-hit camera
PunchFov on the enemy-Health-decrease edge (camera-only hit-stop, never timeScale).
- Spitter Kind==2 aim-LANE telegraph (BuildLaneMesh) — reads baked SpitterState
client-side, falls back to a fixed length. True freeze + material flash deferred.
Content / wiring:
- SpitterProjectilePrefabAuthoring (the SpitterProjectilePrefab singleton).
- Both directors rebuilt to a 4-entry KIND-INDEXED roster [Grunt,Charger,Spitter,
Swarmer] + mix/MaxAlive config + the SpitterProjectileConfig singleton in the subscene.
- Real rigged models: EnemySpitter (re-skinned Kaiju, ranged poker) + EnemySwarmerUndead
(Undead-Werewolf, fast/low-HP); grunt/charger keep Werewolf/ChargerMuscle. EnemySpit =
ownerless interpolated ghost (no Health, no collider).
Post-impl adversarial review fixes (wf_febdcfdb-665):
- [MED] in-band fire gate: the Spitter committed its telegraph from ANY range (fired while
advancing from far). Now commits only when sInBand || sCornered (gives CorneredRange a
real read site) — a Spitter out-of-band holds fire and repositions.
- [LOW] EnemyProjectileDamageSystem early-returns on !ServerTick.IsValid (sibling parity).
- [LOW] EnemyAuthoring bake-time guard: errors if a prefab composes both Charger+Spitter
(would match zero AI passes -> never move).
- [LOW] tests: Spitter brain fires from Expedition (kills the Base==0 region false-green);
a direct partition-exclusion test replaces the order-masked claim; added out-of-band +
cornered negative tests.
388/388 EditMode green + two Play smokes (clean boot, fire, swept-hit, region, server==
client; rigged Kaiju spitter bakes + fires with zero console errors). Accepted as-is
(documented in DR-041): global spit soft-cap, co-op punch attribution.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
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 system tests for the SWARMER cluster spawn in the base-siege WaveSystem (fork 4a). A swarmer composition
|
||||
/// slot must instantiate a whole PACK in one tick (EnemyAIMath.ClusterOffset) while consuming exactly ONE wave
|
||||
/// SLOT, and MaxAlive must count ENTITIES — a pack that won't fit is DEFERRED (slot kept) rather than partially
|
||||
/// spawned (the review-flagged slot-vs-entity accounting). Plain-Entities world, server WaveSystem registered
|
||||
/// directly, faked NetworkTime; the 4-entry [Grunt,Charger,Spitter,Swarmer] roster is Prefab-tagged so the
|
||||
/// instances (and only the instances) count as live EnemyTag ghosts.
|
||||
/// </summary>
|
||||
public class SwarmerClusterSpawnTests
|
||||
{
|
||||
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) WaveWorld(uint tick)
|
||||
{
|
||||
var w = new World("SwarmerCluster");
|
||||
var g = w.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
g.AddSystemToUpdateList(w.GetOrCreateSystem<WaveSystem>());
|
||||
g.SortSystems();
|
||||
w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
SetTick(w, tick);
|
||||
return (w, g);
|
||||
}
|
||||
|
||||
// Director with a swarmer-only band so slot 0 is unambiguously a swarmer pack. The 4-entry roster aliases
|
||||
// dummy Prefab-tagged EnemyTag prefabs; WaveSystem reads index [3] (KindSwarmer) and instantiates the pack.
|
||||
static Entity MakeDirector(EntityManager em, int swarmerSlotBase, int packSize, int maxAlive)
|
||||
{
|
||||
// Create the 4 prefab entities FIRST: every em.CreateEntity is a structural change, so a DynamicBuffer
|
||||
// handle grabbed before them would be invalidated (the bug this ordering avoids). The roster aliases
|
||||
// dummy Prefab-tagged EnemyTag prefabs; WaveSystem reads index [3] (KindSwarmer) and instantiates the pack.
|
||||
var prefabs = new Entity[4];
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
var p = em.CreateEntity();
|
||||
em.AddComponentData(p, LocalTransform.FromPosition(float3.zero));
|
||||
em.AddComponent<EnemyTag>(p);
|
||||
em.AddComponent<Prefab>(p);
|
||||
prefabs[i] = p;
|
||||
}
|
||||
var dir = em.CreateEntity();
|
||||
em.AddComponentData(dir, new WaveDirector
|
||||
{
|
||||
RingRadius = 10f, RingSlots = 8, BaseCount = 0, CountPerWave = 0,
|
||||
SpawnIntervalTicks = 1, LullTicks = 1, MaxAlive = maxAlive,
|
||||
ChargerBase = 0, SpitterBase = 0, SwarmerSlotBase = swarmerSlotBase,
|
||||
ChargerPerEpoch = 0, SpitterPerEpoch = 0, SwarmerSlotPerEpoch = 0,
|
||||
SwarmerPackSize = packSize, SwarmerPackPerEpoch = 0, ClusterTightRadius = 2.5f,
|
||||
});
|
||||
em.AddComponentData(dir, new WaveState { WaveNumber = 0, Phase = WavePhase.Lull, NextActionTick = 0u, RemainingToSpawn = 0, SpawnCounter = 0 });
|
||||
// AddBuffer LAST (after every structural change on dir), then populate with no further structural change.
|
||||
var buf = em.AddBuffer<WaveEnemyPrefab>(dir);
|
||||
for (int i = 0; i < 4; i++) buf.Add(new WaveEnemyPrefab { Prefab = prefabs[i] });
|
||||
return dir;
|
||||
}
|
||||
|
||||
static int CountEnemies(EntityManager em)
|
||||
{
|
||||
using var q = em.CreateEntityQuery(ComponentType.ReadOnly<EnemyTag>());
|
||||
return q.CalculateEntityCount();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Swarmer_Slot_SpawnsWholePack_ConsumesOneSlot()
|
||||
{
|
||||
var (w, g) = WaveWorld(200);
|
||||
using (w)
|
||||
{
|
||||
var em = w.EntityManager;
|
||||
var dir = MakeDirector(em, swarmerSlotBase: 1, packSize: 4, maxAlive: 12);
|
||||
g.Update(); // Lull -> start wave: RemainingToSpawn = WaveSlots(1) = 1 swarmer slot
|
||||
Assert.AreEqual(1, em.GetComponentData<WaveState>(dir).RemainingToSpawn, "one swarmer SLOT this wave");
|
||||
g.Update(); // Spawning -> the pack lands in one tick
|
||||
Assert.AreEqual(4, CountEnemies(em), "the whole pack spawns in a single tick");
|
||||
var st = em.GetComponentData<WaveState>(dir);
|
||||
Assert.AreEqual(1, st.SpawnCounter, "exactly ONE slot consumed for the pack");
|
||||
Assert.AreEqual(0, st.RemainingToSpawn, "the swarmer slot is done");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Swarmer_PackOverMaxAlive_Defers_KeepsSlot()
|
||||
{
|
||||
var (w, g) = WaveWorld(200);
|
||||
using (w)
|
||||
{
|
||||
var em = w.EntityManager;
|
||||
var dir = MakeDirector(em, swarmerSlotBase: 1, packSize: 4, maxAlive: 3); // pack(4) > cap(3)
|
||||
g.Update(); // start wave
|
||||
g.Update(); // try to spawn -> 0 + 4 > 3 -> defer (don't partially spawn, don't consume the slot)
|
||||
Assert.AreEqual(0, CountEnemies(em), "a pack that won't fit MaxAlive is NOT partially spawned");
|
||||
var st = em.GetComponentData<WaveState>(dir);
|
||||
Assert.AreEqual(0, st.SpawnCounter, "the slot is NOT consumed when deferred");
|
||||
Assert.AreEqual(1, st.RemainingToSpawn, "the swarmer slot remains pending");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user