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
@@ -27,11 +27,14 @@ namespace ProjectM.Server
[UpdateAfter(typeof(PredictedSimulationSystemGroup))]
public partial struct EnemyAISystem : ISystem
{
EntityQuery m_EnemyProjectiles;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>()));
m_EnemyProjectiles = state.GetEntityQuery(ComponentType.ReadOnly<EnemyProjectile>());
}
[BurstCompile]
@@ -104,7 +107,7 @@ namespace ProjectM.Server
foreach (var (xform, stats, cooldown, knockback, windup, region) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRO<RegionTag>>()
.WithAll<EnemyTag>().WithNone<LungeState>())
.WithAll<EnemyTag>().WithNone<LungeState, SpitterState>())
{
float3 pos = xform.ValueRO.Position;
byte huskRegion = region.ValueRO.Region;
@@ -212,7 +215,7 @@ namespace ProjectM.Server
foreach (var (xform, stats, cooldown, knockback, windup, lunge, region) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRW<LungeState>, RefRO<RegionTag>>()
.WithAll<EnemyTag>())
.WithAll<EnemyTag>().WithNone<SpitterState>())
{
float3 pos = xform.ValueRO.Position;
byte cHuskRegion = region.ValueRO.Region;
@@ -331,6 +334,112 @@ namespace ProjectM.Server
}
}
}
// --- Spitter pass: a Husk variant baked with SpitterState holds a RANGED range-band and fires a
// telegraphed, dodgeable spit. Partitioned .WithAll<SpitterState>().WithNone<LungeState>() (and the Grunt
// pass excludes SpitterState) so a Spitter is moved by EXACTLY this pass — the sole-Position-writer rule.
bool haveSpit = SystemAPI.TryGetSingleton<SpitterProjectilePrefab>(out var spitCfg) && spitCfg.Prefab != Entity.Null;
int liveSpits = m_EnemyProjectiles.CalculateEntityCount();
LocalTransform spitBakedLt = default;
EnemyProjectile spitBakedProj = default;
if (haveSpit)
{
spitBakedLt = state.EntityManager.GetComponentData<LocalTransform>(spitCfg.Prefab);
spitBakedProj = state.EntityManager.GetComponentData<EnemyProjectile>(spitCfg.Prefab);
}
foreach (var (xform, stats, knockback, windup, spitter, region) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<KnockbackState>,
RefRW<AttackWindup>, RefRW<SpitterState>, RefRO<RegionTag>>()
.WithAll<EnemyTag, SpitterState>().WithNone<LungeState>())
{
float3 pos = xform.ValueRO.Position;
byte sRegion = region.ValueRO.Region;
bool sCoreAlive = coreAlive && sRegion == RegionId.Base;
// 1. Knockback overrides everything (sole Position writer preserved).
var kb = knockback.ValueRO;
if (kb.UntilTick != 0)
{
var kbTick = new NetworkTick(kb.UntilTick);
if (kbTick.IsValid && kbTick.IsNewerThan(serverTick))
{
float3 kpos = pos + new float3(kb.Dir.x, 0f, kb.Dir.y) * (kb.Speed * dt);
kpos.y = pos.y;
if (sweep) kpos = SweptMove(in physics, pos, kpos, SweepRadius, envFilter);
xform.ValueRW.Position = kpos;
windup.ValueRW.WindUpUntilTick = 0;
continue;
}
knockback.ValueRW.UntilTick = 0;
}
// 2. Target (region-scoped shared helper); Core fallback like the Grunt/Charger passes.
EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, sRegion, structAggro, out bool sIsStruct, out int sIdx);
if (sIdx < 0 && !sCoreAlive)
continue;
Entity sTargetEntity = sIdx < 0 ? Entity.Null
: (sIsStruct ? structureEntities[sIdx] : playerEntities[sIdx]);
float3 sTargetPos = sIdx < 0 ? corePos
: (sIsStruct ? structurePositions[sIdx] : playerPositions[sIdx]);
// 3. Range-band movement: advance if too far, retreat if too close, hold in-band. Face the target.
var sp = spitter.ValueRO;
float3 bandVel = EnemyAIMath.BandVelocity(pos, sTargetPos, stats.ValueRO.MoveSpeed, sp.PreferredRange, sp.RangeTolerance);
float3 sNewPos = pos + bandVel * dt; sNewPos.y = pos.y;
if (sweep) sNewPos = SweptMove(in physics, pos, sNewPos, SweepRadius, envFilter);
xform.ValueRW.Position = sNewPos;
float3 sToTarget = sTargetPos - pos; sToTarget.y = 0f;
if (math.lengthsq(sToTarget) > 1e-6f)
xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(sToTarget), math.up());
// 4. Telegraphed shot: commit a wind-up (the dodge window) when the shot gate is ready; on elapse,
// spawn a spit toward the target. A cornered Spitter still fires (point-blank) — no safe corner.
uint sWindRaw = windup.ValueRO.WindUpUntilTick;
if (sWindRaw != 0)
{
var sWindTick = new NetworkTick(sWindRaw);
if (!(sWindTick.IsValid && sWindTick.IsNewerThan(serverTick)))
{
float2 dir2 = math.lengthsq(sToTarget) > 1e-6f ? math.normalize(sToTarget.xz) : new float2(0f, 1f);
if (haveSpit && liveSpits < math.max(1, spitCfg.MaxLiveProjectiles))
{
float3 spawnPos = pos + new float3(dir2.x, 0f, dir2.y) * 0.8f;
spawnPos.y = pos.y;
var spit = ecb.Instantiate(spitCfg.Prefab);
ecb.SetComponent(spit, spitBakedLt.WithPosition(spawnPos)); // preserve baked [GhostField] Scale
ecb.SetComponent(spit, new EnemyProjectile
{
Direction = dir2,
Speed = sp.ProjectileSpeed,
Damage = stats.ValueRO.AttackDamage,
Range = spitBakedProj.Range,
DistanceTravelled = 0f,
LastStep = 0f,
Region = sRegion,
});
ecb.AddComponent(spit, new RegionTag { Region = sRegion }); // relevancy (the spit prefab bakes none)
liveSpits++;
uint shotCd = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks);
spitter.ValueRW.NextShotTick = TickUtil.NonZero(now + shotCd);
}
else
{
// Over the concurrent cap (or no prefab wired): soft-fail — short retry, no full cooldown burn.
spitter.ValueRW.NextShotTick = TickUtil.NonZero(now + 8u);
}
windup.ValueRW.WindUpUntilTick = 0;
}
}
else
{
bool sReady = sp.NextShotTick == 0 || !new NetworkTick(sp.NextShotTick).IsNewerThan(serverTick);
if (sReady && (sTargetEntity != Entity.Null || sCoreAlive))
{
uint wTicks = (uint)math.max(1, sp.WindupTicks);
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + wTicks);
}
}
}
// Slice 1 (Feature D): derive the replicated IsLunging cue ONCE per tick from the end-of-tick LungeState
// (single point, idempotent — mirrors PlayerDeathStateSystem deriving Dead from Health). .WithPresent so a
@@ -0,0 +1,135 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// MC-2 — resolves hostile Spitter projectiles against PLAYERS + STRUCTURES (never other enemies — only
/// PlayerTag / PlacedStructure are snapshotted, so a spit can't friendly-fire the Husks), server-only in the
/// plain <see cref="SimulationSystemGroup"/> after <see cref="EnemyProjectileMoveSystem"/> (post-move position).
/// SWEPT planar hit-test (the DR-018 anti-tunnelling discipline): the travel segment is rebuilt from the STORED
/// <see cref="EnemyProjectile.LastStep"/> (cur - Direction*LastStep), NEVER a fresh delta. REGION-FILTERED: a
/// target whose <see cref="RegionTag"/>.Region != the spit's Region is skipped — relevancy hides cross-region
/// ghosts from CLIENTS, but the server world holds base + expedition players 1000u apart, so server damage needs
/// its own guard (the missing-filter blocker the design review caught). On a hit it appends
/// DamageEvent{SourceNetworkId=-1, SourceTick=now} (drained the FOLLOWING tick by the predicted
/// <c>HealthApplyDamageSystem</c> — appending from the predicted loop would double-apply on rollback; SourceTick
/// makes the dash i-frame negation correct across the 1-tick gap, so dash-through-spit works for free) and
/// destroys the spit at-most-once; a spit past its Range expires.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(EnemyProjectileMoveSystem))]
public partial struct EnemyProjectileDamageSystem : ISystem
{
/// <summary>Extra forgiveness for the spit's own size, added to a target's hit radius.</summary>
const float k_ProjectileRadius = 0.2f;
/// <summary>Hit radius used for structures, which (by design) bake no HitRadius (so player shots never hit them).</summary>
const float k_StructureRadius = 1.0f;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<EnemyProjectile>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
uint now = SystemAPI.GetSingleton<NetworkTime>().ServerTick.TickIndexForValidTick;
var ecb = new EntityCommandBuffer(Allocator.Temp);
// Snapshot valid targets once (stable query order). PLAYERS carry HitRadius (PlayerAuthoring);
// STRUCTURES deliberately do NOT (so player projectiles never friendly-fire the base) -> a constant.
var targetEntities = new NativeList<Entity>(Allocator.Temp);
var targetPositions = new NativeList<float3>(Allocator.Temp);
var targetRadii = new NativeList<float>(Allocator.Temp);
var targetRegions = new NativeList<byte>(Allocator.Temp);
foreach (var (xform, hitRadius, health, region, e) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>, RefRO<Health>, RefRO<RegionTag>>()
.WithAll<PlayerTag>().WithEntityAccess())
{
if (health.ValueRO.Current <= 0f) continue; // don't hit a corpse
targetEntities.Add(e);
targetPositions.Add(xform.ValueRO.Position);
targetRadii.Add(hitRadius.ValueRO.Value);
targetRegions.Add(region.ValueRO.Region);
}
foreach (var (xform, health, region, e) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>, RefRO<RegionTag>>()
.WithAll<PlacedStructure>().WithEntityAccess())
{
if (health.ValueRO.Current <= 0f) continue; // skip a structure pending destroy this tick
targetEntities.Add(e);
targetPositions.Add(xform.ValueRO.Position);
targetRadii.Add(k_StructureRadius);
targetRegions.Add(region.ValueRO.Region);
}
var destroyed = new NativeHashSet<Entity>(16, Allocator.Temp);
foreach (var (xform, proj, projEntity) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<EnemyProjectile>>().WithEntityAccess())
{
float3 cur = xform.ValueRO.Position;
float2 segEnd = new float2(cur.x, cur.z);
float2 dir = proj.ValueRO.Direction;
float2 segStart = segEnd - dir * proj.ValueRO.LastStep; // stored move-step, never a fresh dt
float2 seg = segEnd - segStart;
float segLenSq = math.lengthsq(seg);
byte projRegion = proj.ValueRO.Region;
int bestIdx = -1;
float bestT = float.MaxValue;
for (int i = 0; i < targetEntities.Length; i++)
{
if (targetRegions[i] != projRegion) continue; // server-side damage region guard
float2 tp = new float2(targetPositions[i].x, targetPositions[i].z);
float t = segLenSq > 1e-8f
? math.saturate(math.dot(tp - segStart, seg) / segLenSq)
: 0f;
float2 closest = segStart + t * seg;
float hitDist = targetRadii[i] + k_ProjectileRadius;
if (math.distancesq(tp, closest) <= hitDist * hitDist && t < bestT)
{
bestT = t;
bestIdx = i;
}
}
if (bestIdx >= 0)
{
ecb.AppendToBuffer(targetEntities[bestIdx], new DamageEvent
{
Amount = proj.ValueRO.Damage,
SourceNetworkId = -1, // hostile environment, not a player
SourceTick = TickUtil.NonZero(now),
});
if (destroyed.Add(projEntity))
ecb.DestroyEntity(projEntity);
continue;
}
if (proj.ValueRO.DistanceTravelled >= proj.ValueRO.Range && destroyed.Add(projEntity))
ecb.DestroyEntity(projEntity);
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
destroyed.Dispose();
targetEntities.Dispose();
targetPositions.Dispose();
targetRadii.Dispose();
targetRegions.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4f6dbd4ab9a2b154e8d7cb1796904ab6
@@ -0,0 +1,49 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// MC-2 — integrates hostile Spitter projectiles (<see cref="EnemyProjectile"/>) server-only in the plain
/// <see cref="SimulationSystemGroup"/> (the spits are ownerless INTERPOLATED ghosts, not predicted — like the
/// Husks that fire them). Advances each spit along its locked Direction at Speed*dt, accumulates
/// DistanceTravelled, and STORES <see cref="EnemyProjectile.LastStep"/> = Speed*dt so
/// <see cref="EnemyProjectileDamageSystem"/> can rebuild the exact swept segment it traversed this tick
/// (cur - Direction*LastStep) WITHOUT re-reading a delta in that separate system (the DR-018 swept-tunnelling
/// discipline — a fresh delta in the damage pass is the trap). Ordered <c>[UpdateAfter(EnemyAISystem)]</c> (the
/// spawner) so a spit moves the same tick it is born. Writes LocalTransform (replicated via the stock variant);
/// structural-free. dt is the server fixed step here, exactly as <see cref="EnemyAISystem"/> reads it.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(EnemyAISystem))]
public partial struct EnemyProjectileMoveSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<EnemyProjectile>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
float dt = SystemAPI.Time.DeltaTime; // server fixed step in the plain group, same as EnemyAISystem
foreach (var (xform, proj) in SystemAPI.Query<RefRW<LocalTransform>, RefRW<EnemyProjectile>>())
{
float step = proj.ValueRO.Speed * dt;
float3 dir = new float3(proj.ValueRO.Direction.x, 0f, proj.ValueRO.Direction.y);
float3 from = xform.ValueRO.Position;
float3 pos = from + dir * step;
pos.y = from.y; // hold the movement plane
xform.ValueRW.Position = pos;
proj.ValueRW.LastStep = step;
proj.ValueRW.DistanceTravelled = proj.ValueRO.DistanceTravelled + step;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9acb4c22874b1fa489433644b90334db
@@ -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
@@ -71,58 +71,82 @@ namespace ProjectM.Server
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
int epoch = runtime.ExpeditionEpoch;
// (Re)seed this epoch's wave once — its OWN counter, never WaveState's.
// MC-2: build the 4-type weighted mix band from the director's baked weights (shared math with the base
// siege). GruntsPerWave/ChargersPerWave are the Grunt/Charger base counts.
var bands = new MixBands
{
GruntBase = dir.GruntsPerWave,
ChargerBase = dir.ChargersPerWave,
SpitterBase = dir.SpitterBase,
SwarmerSlotBase = dir.SwarmerSlotBase,
ChargerPerEpoch = dir.ChargerPerEpoch,
SpitterPerEpoch = dir.SpitterPerEpoch,
SwarmerSlotPerEpoch = dir.SwarmerSlotPerEpoch,
SwarmerPackPerEpoch = dir.SwarmerPackPerEpoch,
};
// (Re)seed this epoch's wave once — its OWN counter (in SLOTS; a swarmer slot is one pack).
if (zs.SeededEpoch != epoch)
{
zs.SeededEpoch = epoch;
zs.SpawnCounter = 0;
zs.RemainingToSpawn = ZoneEnemyMath.WaveSize(epoch, dir.GruntsPerWave, dir.ChargersPerWave);
zs.NextSpawnTick = TickUtil.NonZero(now); // first enemy this tick
zs.RemainingToSpawn = ZoneEnemyMath.WaveSlots(epoch, bands);
zs.NextSpawnTick = TickUtil.NonZero(now); // first slot this tick
}
int aliveZone = m_ZoneEnemies.CalculateEntityCount();
if (zs.RemainingToSpawn > 0)
{
// Spawn only in Calm (a base Siege pauses the expedition wave; it resumes when the base is safe), one
// per cadence, and only while under the concurrent cap.
// Spawn only in Calm (a base Siege pauses the expedition wave), one SLOT per cadence, under the cap.
bool calm = cycle.Phase == CyclePhase.Calm;
bool dueNow = zs.NextSpawnTick == 0 || !new NetworkTick(zs.NextSpawnTick).IsNewerThan(serverTick);
if (calm && dueNow && aliveZone < math.max(1, dir.MaxAlive))
if (calm && dueNow)
{
float3 baseCenter = new float3(0f, 1f, 0f);
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
baseCenter = BaseGridMath.PlotCenter(anchor);
float3 origin = RegionMath.RegionOrigin(RegionId.Expedition, baseCenter);
int slot = (int)zs.SpawnCounter;
bool charger = ZoneEnemyMath.IsChargerSlot(epoch, slot, dir.GruntsPerWave, dir.ChargersPerWave);
int prefabIdx = charger ? 1 : 0;
if (prefabIdx >= prefabs.Length) prefabIdx = prefabs.Length - 1;
var prefab = prefabs[prefabIdx].Prefab;
byte kind = ZoneEnemyMath.KindForSlot(epoch, slot, bands);
int packSize = kind == ZoneEnemyMath.KindSwarmer
? ZoneEnemyMath.PackSizeForSlot(epoch, slot, bands, dir.SwarmerPackSize) : 1;
float3 pos = EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius);
pos.y = origin.y;
// MaxAlive counts ENTITIES; spawn the whole pack only if it fits (else WAIT — don't consume the slot).
if (aliveZone + packSize <= math.max(1, dir.MaxAlive))
{
float3 baseCenter = new float3(0f, 1f, 0f);
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
baseCenter = BaseGridMath.PlotCenter(anchor);
float3 origin = RegionMath.RegionOrigin(RegionId.Expedition, baseCenter);
float3 center = EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius);
center.y = origin.y;
var ecb = new EntityCommandBuffer(Allocator.Temp);
var enemy = ecb.Instantiate(prefab);
// Preserve the prefab's baked Scale ([GhostField]) + rotation — FromPosition would reset Scale->1.
var baked = state.EntityManager.GetComponentData<LocalTransform>(prefab);
ecb.SetComponent(enemy, baked.WithPosition(pos));
ecb.AddComponent(enemy, new RegionTag { Region = RegionId.Expedition });
ecb.AddComponent<ZoneEnemyTag>(enemy);
ecb.Playback(state.EntityManager);
ecb.Dispose();
int prefabIdx = kind;
if (prefabIdx >= prefabs.Length) prefabIdx = 0; // 4-entry buffer expected; clamp defensively
var prefab = prefabs[prefabIdx].Prefab;
// Preserve the prefab's baked Scale ([GhostField]) — FromPosition would reset Scale->1.
var baked = state.EntityManager.GetComponentData<LocalTransform>(prefab);
zs.SpawnCounter += 1;
zs.RemainingToSpawn -= 1;
zs.NextSpawnTick = TickUtil.NonZero(now + (uint)math.max(1, dir.SpawnIntervalTicks));
var ecb = new EntityCommandBuffer(Allocator.Temp);
for (int k = 0; k < packSize; k++)
{
float3 pos = packSize > 1
? EnemyAIMath.ClusterOffset(center, k, packSize, dir.ClusterTightRadius) : center;
pos.y = origin.y;
var enemy = ecb.Instantiate(prefab);
ecb.SetComponent(enemy, baked.WithPosition(pos));
ecb.AddComponent(enemy, new RegionTag { Region = RegionId.Expedition });
ecb.AddComponent<ZoneEnemyTag>(enemy);
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
zs.SpawnCounter += 1; // ONE slot consumed even for a pack
zs.RemainingToSpawn -= 1;
zs.NextSpawnTick = TickUtil.NonZero(now + (uint)math.max(1, dir.SpawnIntervalTicks));
}
}
}
else if (aliveZone == 0 && runtime.ClearedThisEpoch == 0)
{
// Wave fully spawned AND every zone enemy dead -> a REAL clear (the player killed them; the empty-edge
// teardown can't reach here because we early-return when no one is out). Mark once; the gate pays the
// Wave fully spawned AND every zone enemy dead -> a REAL clear. Mark once; the gate pays the
// once-per-epoch Ore reward on the player's return to base.
runtime.ClearedThisEpoch = 1;
SystemAPI.SetComponent(cycleEntity, runtime);