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:
2026-06-24 20:06:56 -07:00
parent 3109b86d71
commit 56cf60cce3
34 changed files with 1204 additions and 64 deletions
@@ -51,6 +51,21 @@ namespace ProjectM.Server
var wave = SystemAPI.GetComponent<WaveState>(directorEntity);
// MC-2 fork-4a: the base siege adopts the 4-type weighted mix (BaseCount = the Grunt base). The size
// curve becomes WaveSlots(wave, bands) — a deliberate, operator-approved redefinition; MaxAlive is the
// mandatory cap so spitter spits + swarmer packs can't spike the relevancy loop during the END-game climax.
var bands = new MixBands
{
GruntBase = director.BaseCount,
ChargerBase = director.ChargerBase,
SpitterBase = director.SpitterBase,
SwarmerSlotBase = director.SwarmerSlotBase,
ChargerPerEpoch = director.ChargerPerEpoch,
SpitterPerEpoch = director.SpitterPerEpoch,
SwarmerSlotPerEpoch = director.SwarmerSlotPerEpoch,
SwarmerPackPerEpoch = director.SwarmerPackPerEpoch,
};
// Ring centre on the base plot when present.
float3 center = new float3(0f, 1f, 0f);
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var baseAnchor))
@@ -65,8 +80,7 @@ namespace ProjectM.Server
{
// Start the next (bigger) wave.
wave.WaveNumber += 1;
wave.RemainingToSpawn =
math.max(1, director.BaseCount + (wave.WaveNumber - 1) * director.CountPerWave);
wave.RemainingToSpawn = ZoneEnemyMath.WaveSlots(wave.WaveNumber, bands);
wave.Phase = WavePhase.Spawning;
wave.NextActionTick = TickUtil.NonZero(now); // spawn the first Husk this tick
}
@@ -78,24 +92,41 @@ namespace ProjectM.Server
if (dueNow)
{
int slots = math.max(1, director.RingSlots);
int prefabIdx = wave.SpawnCounter % prefabs.Length;
float3 pos = EnemyAIMath.RingPosition(center, wave.SpawnCounter, slots, director.RingRadius);
pos.y = center.y;
byte kind = ZoneEnemyMath.KindForSlot(wave.WaveNumber, wave.SpawnCounter, bands);
int packSize = kind == ZoneEnemyMath.KindSwarmer
? ZoneEnemyMath.PackSizeForSlot(wave.WaveNumber, wave.SpawnCounter, bands, director.SwarmerPackSize) : 1;
var ecb = new EntityCommandBuffer(Allocator.Temp);
var husk = ecb.Instantiate(prefabs[prefabIdx].Prefab);
// Preserve the prefab's baked variant Scale (a replicated [GhostField]) + rotation;
// LocalTransform.FromPosition() would reset Scale->1, shrinking/growing animated variants.
var baked = state.EntityManager.GetComponentData<LocalTransform>(prefabs[prefabIdx].Prefab);
ecb.SetComponent(husk, baked.WithPosition(pos));
// Husks belong to the base region (hidden from expedition players by relevancy).
ecb.AddComponent(husk, new RegionTag { Region = RegionId.Base });
ecb.Playback(state.EntityManager);
ecb.Dispose();
// Live BASE husks for the entity cap (expedition zone enemies are EnemyTag too -> excluded).
int aliveBase = 0;
foreach (var hr in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>())
if (hr.ValueRO.Region == RegionId.Base) aliveBase++;
wave.SpawnCounter += 1;
wave.RemainingToSpawn -= 1;
wave.NextActionTick = TickUtil.NonZero(now + (uint)math.max(1, director.SpawnIntervalTicks));
// MaxAlive counts ENTITIES; spawn the whole pack only if it fits (else WAIT — don't consume the slot).
if (aliveBase + packSize <= math.max(1, director.MaxAlive))
{
int prefabIdx = kind;
if (prefabIdx >= prefabs.Length) prefabIdx = 0; // 4-entry buffer expected; clamp defensively
float3 packCenter = EnemyAIMath.RingPosition(center, wave.SpawnCounter, slots, director.RingRadius);
packCenter.y = center.y;
var baked = state.EntityManager.GetComponentData<LocalTransform>(prefabs[prefabIdx].Prefab);
var ecb = new EntityCommandBuffer(Allocator.Temp);
for (int k = 0; k < packSize; k++)
{
float3 pos = packSize > 1
? EnemyAIMath.ClusterOffset(packCenter, k, packSize, director.ClusterTightRadius) : packCenter;
pos.y = center.y;
var husk = ecb.Instantiate(prefabs[prefabIdx].Prefab);
ecb.SetComponent(husk, baked.WithPosition(pos)); // preserve baked [GhostField] Scale
ecb.AddComponent(husk, new RegionTag { Region = RegionId.Base });
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
wave.SpawnCounter += 1; // ONE slot consumed even for a pack
wave.RemainingToSpawn -= 1;
wave.NextActionTick = TickUtil.NonZero(now + (uint)math.max(1, director.SpawnIntervalTicks));
}
}
}
else