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
@@ -56,12 +56,15 @@ namespace ProjectM.Authoring
// EnemyTelegraph even on a Charger (the prefab composes both authorings on one entity); reading the
// sibling ChargerAuthoring here avoids a double-AddComponent. WindupTicks = the client danger-ramp
// denominator per variant; IsCharger lets the client pick the Charger look (LungeState is server-only).
bool isCharger = GetComponent<ChargerAuthoring>() != null;
AddComponent(entity, new EnemyTelegraph
{
WindupTicks = (byte)(isCharger ? 30 : Tuning.AttackWindupTicks),
IsCharger = (byte)(isCharger ? 1 : 0),
});
// Kind byte (client telegraph look) — derived from the sibling variant authoring (EnemyBaker is the
// SOLE EnemyTelegraph writer). Grunt=0 / Charger=1 / Spitter=2 / Swarmer=3 (ZoneEnemyMath.Kind*).
byte kind = ZoneEnemyMath.KindGrunt;
byte windup = (byte)Tuning.AttackWindupTicks;
var spitter = GetComponent<SpitterAuthoring>();
if (GetComponent<ChargerAuthoring>() != null) { kind = ZoneEnemyMath.KindCharger; windup = 30; }
else if (spitter != null) { kind = ZoneEnemyMath.KindSpitter; windup = (byte)Mathf.Clamp(spitter.WindupTicks, 1, 255); }
else if (GetComponent<SwarmerAuthoring>() != null) { kind = ZoneEnemyMath.KindSwarmer; windup = 6; }
AddComponent(entity, new EnemyTelegraph { WindupTicks = windup, Kind = kind });
}
}
}
@@ -0,0 +1,44 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// MC-2 — authoring for the hostile Spitter projectile prefab (an ownerless INTERPOLATED ghost, duplicated from an
/// existing interpolated ghost so the GhostAuthoringComponent comes free). Bakes <see cref="EnemyProjectile"/> with
/// the spit's default Speed/Damage/Range; the firing Spitter OVERRIDES Direction + Speed + Damage + Region at spawn
/// and ADDS the <c>RegionTag</c> (so this prefab MUST NOT bake RegionTag — AddComponent would throw on a baked one).
/// NO Health (so it is invisible to every player hit-test) and NO <c>[GhostField]</c> beyond the stock LocalTransform.
/// </summary>
public class EnemyProjectileAuthoring : MonoBehaviour
{
[Min(0f), Tooltip("Default muzzle speed (the firing Spitter overrides this per-variant).")]
public float Speed = 11f;
[Min(0f), Tooltip("Default damage (the firing Spitter overrides this from its AttackDamage).")]
public float Damage = 8f;
[Min(0f), Tooltip("Max travel distance before the spit expires (world units).")]
public float Range = 16f;
private class EnemyProjectileBaker : Baker<EnemyProjectileAuthoring>
{
public override void Bake(EnemyProjectileAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent(entity, new EnemyProjectile
{
Speed = authoring.Speed,
Damage = authoring.Damage,
Range = authoring.Range,
Direction = new float2(0f, 1f),
DistanceTravelled = 0f,
LastStep = 0f,
Region = 0,
});
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ff79c8fbcacb8c34faad37d59836b5ac
@@ -0,0 +1,49 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// MC-2 — marks a Husk prefab as a SPITTER variant (the ranged "reposition" question). Compose WITH
/// <see cref="EnemyAuthoring"/> on the prefab root: EnemyAuthoring bakes the common Husk components + the spit's
/// damage/cooldown (EnemyStats.AttackDamage / AttackCooldownTicks), this bakes the server-only
/// <see cref="SpitterState"/> (zeroed NextShotTick = ready). Component-PRESENCE is the discriminator EnemyAISystem
/// branches on (no enum); the Grunt + Charger passes exclude it via <c>.WithNone&lt;SpitterState&gt;()</c>. The
/// actual spit projectile is a SEPARATE ghost configured by the SpitterProjectilePrefab subscene singleton.
/// </summary>
public class SpitterAuthoring : MonoBehaviour
{
[Min(0f), Tooltip("Distance the Spitter tries to hold from its target (band centre).")]
public float PreferredRange = 9f;
[Min(0f), Tooltip("Half-width dead-zone around PreferredRange where it holds and fires.")]
public float RangeTolerance = 1.5f;
[Min(0f), Tooltip("Muzzle speed of the spit (world units/second). Slow enough to be dodgeable at range.")]
public float ProjectileSpeed = 11f;
[Min(0f), Tooltip("If the target closes within this AND the Spitter can't retreat, it fires point-blank.")]
public float CorneredRange = 3f;
[Min(1), Tooltip("Telegraph wind-up before the spit fires (ticks). Keep >= ~24 (> interp delay) to stay dodgeable.")]
public int WindupTicks = 26;
private class SpitterBaker : Baker<SpitterAuthoring>
{
public override void Bake(SpitterAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent(entity, new SpitterState
{
PreferredRange = authoring.PreferredRange,
RangeTolerance = authoring.RangeTolerance,
ProjectileSpeed = authoring.ProjectileSpeed,
CorneredRange = authoring.CorneredRange,
WindupTicks = authoring.WindupTicks,
NextShotTick = 0,
});
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 55fe00810b31aa54abd577b6a07192e2
@@ -0,0 +1,25 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// MC-2 — marks a Husk prefab as a SWARMER variant (the "surround" question). Compose WITH
/// <see cref="EnemyAuthoring"/> on the prefab root, tuned fast + low-HP + fast frequent low-chip bites (via the
/// EnemyAuthoring fields). This bakes only the <see cref="SwarmerTag"/> marker: a Swarmer has NO AI branch (it
/// falls through the Grunt seek+strike pass); the tag drives the director's CLUSTER spawn (a pack per slot) + a
/// client tint. Keeps EnemyTag + RegionTag like every Husk.
/// </summary>
public class SwarmerAuthoring : MonoBehaviour
{
private class SwarmerBaker : Baker<SwarmerAuthoring>
{
public override void Bake(SwarmerAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent<SwarmerTag>(entity);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b6a84b442d0535642abc303c01546a15
@@ -22,6 +22,17 @@ namespace ProjectM.Authoring
[Min(0), Tooltip("Additional Husks per subsequent wave.")] public int CountPerWave = 2;
[Min(1), Tooltip("Ticks between individual spawns within a wave (~60/sec).")] public int SpawnIntervalTicks = 24;
[Min(1), Tooltip("Ticks of calm between waves (~60/sec).")] public int LullTicks = 240;
[Min(1), Tooltip("Max concurrent live BASE husks (mandatory fork-4a cap; bounds spits + swarmer packs at the climax).")] public int MaxAlive = 14;
[Min(0), Tooltip("Charger base count.")] public int ChargerBase = 0;
[Min(0), Tooltip("Spitter base count.")] public int SpitterBase = 0;
[Min(0), Tooltip("Swarmer SLOT base count (each slot = one pack).")] public int SwarmerSlotBase = 0;
[Min(0), Tooltip("Extra chargers per wave.")] public int ChargerPerEpoch = 1;
[Min(0), Tooltip("Extra spitters per wave (the base gains the ranged question as it escalates).")] public int SpitterPerEpoch = 1;
[Min(0), Tooltip("Extra swarmer slots per wave (0 = no base swarms in v1; bounded by MaxAlive when enabled).")] public int SwarmerSlotPerEpoch = 0;
[Min(1), Tooltip("Swarmers per swarmer-slot cluster.")] public int SwarmerPackSize = 3;
[Min(0), Tooltip("Swarmer pack-size ramp per wave (0 = fixed).")] public int SwarmerPackPerEpoch = 0;
[Min(0f), Tooltip("Tight ring radius for a swarmer pack.")] public float ClusterTightRadius = 2.5f;
private class WaveDirectorBaker : Baker<WaveDirectorAuthoring>
{
@@ -37,6 +48,16 @@ namespace ProjectM.Authoring
CountPerWave = authoring.CountPerWave,
SpawnIntervalTicks = authoring.SpawnIntervalTicks,
LullTicks = authoring.LullTicks,
MaxAlive = authoring.MaxAlive,
ChargerBase = authoring.ChargerBase,
SpitterBase = authoring.SpitterBase,
SwarmerSlotBase = authoring.SwarmerSlotBase,
ChargerPerEpoch = authoring.ChargerPerEpoch,
SpitterPerEpoch = authoring.SpitterPerEpoch,
SwarmerSlotPerEpoch = authoring.SwarmerSlotPerEpoch,
SwarmerPackSize = authoring.SwarmerPackSize,
SwarmerPackPerEpoch = authoring.SwarmerPackPerEpoch,
ClusterTightRadius = authoring.ClusterTightRadius,
});
var buffer = AddBuffer<WaveEnemyPrefab>(entity);
@@ -14,15 +14,23 @@ namespace ProjectM.Authoring
/// </summary>
public class ZoneEnemyDirectorAuthoring : MonoBehaviour
{
[Tooltip("Zone-enemy variant prefabs. Index 0 = Grunt, index 1 = Charger. Each must carry EnemyAuthoring + an interpolated GhostAuthoringComponent.")]
[Tooltip("Zone-enemy variant prefabs by Kind: [0]=Grunt, [1]=Charger, [2]=Spitter, [3]=Swarmer. Each must carry EnemyAuthoring (+ its variant authoring) + an interpolated GhostAuthoringComponent.")]
public GameObject[] EnemyPrefabs;
[Min(1), Tooltip("Max concurrent live zone enemies (the v1 ghost-relevancy budget).")] public int MaxAlive = 12;
[Min(1), Tooltip("Max concurrent live zone enemies (the ghost-relevancy budget; also caps swarmer packs).")] public int MaxAlive = 12;
[Min(0f)] public float RingRadius = 14f;
[Min(1)] public int RingSlots = 10;
[Min(1), Tooltip("Ticks between individual spawns within a wave (~60/sec).")] public int SpawnIntervalTicks = 30;
[Min(0), Tooltip("Grunts in the epoch-1 wave (held roughly constant as the epoch climbs).")] public int GruntsPerWave = 4;
[Min(0), Tooltip("Chargers in the epoch-1 wave (grows ~1 per epoch -> charger-heavy).")] public int ChargersPerWave = 1;
[Min(1), Tooltip("Ticks between individual slot spawns within a wave (~60/sec).")] public int SpawnIntervalTicks = 30;
[Min(0), Tooltip("Grunt base count (the fixed floor = remainder of the slots).")] public int GruntsPerWave = 4;
[Min(0), Tooltip("Charger base count.")] public int ChargersPerWave = 1;
[Min(0), Tooltip("Spitter base count (0 = introduced via the per-epoch ramp).")] public int SpitterBase = 0;
[Min(0), Tooltip("Swarmer SLOT base count (each slot = one pack).")] public int SwarmerSlotBase = 0;
[Min(0), Tooltip("Extra chargers per epoch.")] public int ChargerPerEpoch = 1;
[Min(0), Tooltip("Extra spitters per epoch (1 -> Spitter first appears at epoch 2).")] public int SpitterPerEpoch = 1;
[Min(0), Tooltip("Extra swarmer slots per epoch.")] public int SwarmerSlotPerEpoch = 1;
[Min(1), Tooltip("Swarmers per swarmer-slot cluster.")] public int SwarmerPackSize = 4;
[Min(0), Tooltip("Swarmer pack-size ramp per epoch (0 = fixed).")] public int SwarmerPackPerEpoch = 0;
[Min(0f), Tooltip("Tight ring radius for a swarmer pack.")] public float ClusterTightRadius = 2.5f;
[Min(0), Tooltip("Flat Ore banked to the shared ledger on a real clear, once per sortie.")] public int RewardOre = 25;
private class ZoneEnemyDirectorBaker : Baker<ZoneEnemyDirectorAuthoring>
@@ -40,6 +48,14 @@ namespace ProjectM.Authoring
GruntsPerWave = authoring.GruntsPerWave,
ChargersPerWave = authoring.ChargersPerWave,
RewardOre = authoring.RewardOre,
SpitterBase = authoring.SpitterBase,
SwarmerSlotBase = authoring.SwarmerSlotBase,
ChargerPerEpoch = authoring.ChargerPerEpoch,
SpitterPerEpoch = authoring.SpitterPerEpoch,
SwarmerSlotPerEpoch = authoring.SwarmerSlotPerEpoch,
SwarmerPackSize = authoring.SwarmerPackSize,
SwarmerPackPerEpoch = authoring.SwarmerPackPerEpoch,
ClusterTightRadius = authoring.ClusterTightRadius,
});
var buffer = AddBuffer<ZoneEnemyPrefab>(entity);