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