From 56cf60cce3b9363a0054d25ceaa35783b99fbe6f Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 24 Jun 2026 20:06:56 -0700 Subject: [PATCH] =?UTF-8?q?Slice=20Combat=20Depth=20(MC-2):=20enemy-variet?= =?UTF-8?q?y=20server=20spine=20=E2=80=94=20Spitter,=20Swarmer,=204-type?= =?UTF-8?q?=20mix=20(DR-041)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Authoring/Combat/EnemyAuthoring.cs | 15 +- .../Combat/EnemyProjectileAuthoring.cs | 44 ++++++ .../Combat/EnemyProjectileAuthoring.cs.meta | 2 + .../Authoring/Combat/SpitterAuthoring.cs | 49 +++++++ .../Authoring/Combat/SpitterAuthoring.cs.meta | 2 + .../Authoring/Combat/SwarmerAuthoring.cs | 25 ++++ .../Authoring/Combat/SwarmerAuthoring.cs.meta | 2 + .../Authoring/Combat/WaveDirectorAuthoring.cs | 21 +++ .../Combat/ZoneEnemyDirectorAuthoring.cs | 26 +++- .../Scripts/Server/Combat/EnemyAISystem.cs | 113 ++++++++++++++- .../Combat/EnemyProjectileDamageSystem.cs | 135 ++++++++++++++++++ .../EnemyProjectileDamageSystem.cs.meta | 2 + .../Combat/EnemyProjectileMoveSystem.cs | 49 +++++++ .../Combat/EnemyProjectileMoveSystem.cs.meta | 2 + .../Scripts/Server/Combat/WaveSystem.cs | 67 ++++++--- .../Server/Combat/ZoneEnemyDirectorSystem.cs | 86 +++++++---- .../Scripts/Simulation/Combat/AttackWindup.cs | 5 +- .../Scripts/Simulation/Combat/EnemyAIMath.cs | 37 +++++ .../Simulation/Combat/EnemyProjectile.cs | 53 +++++++ .../Simulation/Combat/EnemyProjectile.cs.meta | 2 + .../Scripts/Simulation/Combat/MixBands.cs | 30 ++++ .../Simulation/Combat/MixBands.cs.meta | 2 + .../Simulation/Combat/SpitterComponents.cs | 45 ++++++ .../Combat/SpitterComponents.cs.meta | 2 + .../Simulation/Combat/WaveComponents.cs | 11 ++ .../Simulation/Combat/ZoneEnemyComponents.cs | 10 ++ .../Simulation/Combat/ZoneEnemyMath.cs | 61 ++++++++ .../Tests/EditMode/EnemyAIMathMC2Tests.cs | 49 +++++++ .../EditMode/EnemyAIMathMC2Tests.cs.meta | 2 + .../Tests/EditMode/EnemyProjectileTests.cs | 132 +++++++++++++++++ .../EditMode/EnemyProjectileTests.cs.meta | 2 + .../Tests/EditMode/ZoneEnemyMixTests.cs | 91 ++++++++++++ .../Tests/EditMode/ZoneEnemyMixTests.cs.meta | 2 + ...Slice_Combat_Depth_Enemy_Variety_Impact.md | 92 ++++++++++++ 34 files changed, 1204 insertions(+), 64 deletions(-) create mode 100644 Assets/_Project/Scripts/Authoring/Combat/EnemyProjectileAuthoring.cs create mode 100644 Assets/_Project/Scripts/Authoring/Combat/EnemyProjectileAuthoring.cs.meta create mode 100644 Assets/_Project/Scripts/Authoring/Combat/SpitterAuthoring.cs create mode 100644 Assets/_Project/Scripts/Authoring/Combat/SpitterAuthoring.cs.meta create mode 100644 Assets/_Project/Scripts/Authoring/Combat/SwarmerAuthoring.cs create mode 100644 Assets/_Project/Scripts/Authoring/Combat/SwarmerAuthoring.cs.meta create mode 100644 Assets/_Project/Scripts/Server/Combat/EnemyProjectileDamageSystem.cs create mode 100644 Assets/_Project/Scripts/Server/Combat/EnemyProjectileDamageSystem.cs.meta create mode 100644 Assets/_Project/Scripts/Server/Combat/EnemyProjectileMoveSystem.cs create mode 100644 Assets/_Project/Scripts/Server/Combat/EnemyProjectileMoveSystem.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Combat/EnemyProjectile.cs create mode 100644 Assets/_Project/Scripts/Simulation/Combat/EnemyProjectile.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Combat/MixBands.cs create mode 100644 Assets/_Project/Scripts/Simulation/Combat/MixBands.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Combat/SpitterComponents.cs create mode 100644 Assets/_Project/Scripts/Simulation/Combat/SpitterComponents.cs.meta create mode 100644 Assets/_Project/Tests/EditMode/EnemyAIMathMC2Tests.cs create mode 100644 Assets/_Project/Tests/EditMode/EnemyAIMathMC2Tests.cs.meta create mode 100644 Assets/_Project/Tests/EditMode/EnemyProjectileTests.cs create mode 100644 Assets/_Project/Tests/EditMode/EnemyProjectileTests.cs.meta create mode 100644 Assets/_Project/Tests/EditMode/ZoneEnemyMixTests.cs create mode 100644 Assets/_Project/Tests/EditMode/ZoneEnemyMixTests.cs.meta create mode 100644 Docs/Vault/07_Sessions/_Decisions/DR-041_Slice_Combat_Depth_Enemy_Variety_Impact.md diff --git a/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs index 72a104744..e20cb22ed 100644 --- a/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Combat/EnemyAuthoring.cs @@ -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() != 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(); + if (GetComponent() != null) { kind = ZoneEnemyMath.KindCharger; windup = 30; } + else if (spitter != null) { kind = ZoneEnemyMath.KindSpitter; windup = (byte)Mathf.Clamp(spitter.WindupTicks, 1, 255); } + else if (GetComponent() != null) { kind = ZoneEnemyMath.KindSwarmer; windup = 6; } + AddComponent(entity, new EnemyTelegraph { WindupTicks = windup, Kind = kind }); } } } diff --git a/Assets/_Project/Scripts/Authoring/Combat/EnemyProjectileAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/EnemyProjectileAuthoring.cs new file mode 100644 index 000000000..60cdf5c95 --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Combat/EnemyProjectileAuthoring.cs @@ -0,0 +1,44 @@ +using ProjectM.Simulation; +using Unity.Entities; +using Unity.Mathematics; +using UnityEngine; + +namespace ProjectM.Authoring +{ + /// + /// 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 with + /// the spit's default Speed/Damage/Range; the firing Spitter OVERRIDES Direction + Speed + Damage + Region at spawn + /// and ADDS the RegionTag (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 [GhostField] beyond the stock LocalTransform. + /// + 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 + { + 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, + }); + } + } + } +} diff --git a/Assets/_Project/Scripts/Authoring/Combat/EnemyProjectileAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Combat/EnemyProjectileAuthoring.cs.meta new file mode 100644 index 000000000..af179adb4 --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Combat/EnemyProjectileAuthoring.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ff79c8fbcacb8c34faad37d59836b5ac \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Combat/SpitterAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/SpitterAuthoring.cs new file mode 100644 index 000000000..3dd46d24f --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Combat/SpitterAuthoring.cs @@ -0,0 +1,49 @@ +using ProjectM.Simulation; +using Unity.Entities; +using UnityEngine; + +namespace ProjectM.Authoring +{ + /// + /// MC-2 — marks a Husk prefab as a SPITTER variant (the ranged "reposition" question). Compose WITH + /// on the prefab root: EnemyAuthoring bakes the common Husk components + the spit's + /// damage/cooldown (EnemyStats.AttackDamage / AttackCooldownTicks), this bakes the server-only + /// (zeroed NextShotTick = ready). Component-PRESENCE is the discriminator EnemyAISystem + /// branches on (no enum); the Grunt + Charger passes exclude it via .WithNone<SpitterState>(). The + /// actual spit projectile is a SEPARATE ghost configured by the SpitterProjectilePrefab subscene singleton. + /// + 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 + { + 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, + }); + } + } + } +} diff --git a/Assets/_Project/Scripts/Authoring/Combat/SpitterAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Combat/SpitterAuthoring.cs.meta new file mode 100644 index 000000000..deb89a591 --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Combat/SpitterAuthoring.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 55fe00810b31aa54abd577b6a07192e2 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Combat/SwarmerAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/SwarmerAuthoring.cs new file mode 100644 index 000000000..28a0499bd --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Combat/SwarmerAuthoring.cs @@ -0,0 +1,25 @@ +using ProjectM.Simulation; +using Unity.Entities; +using UnityEngine; + +namespace ProjectM.Authoring +{ + /// + /// MC-2 — marks a Husk prefab as a SWARMER variant (the "surround" question). Compose WITH + /// on the prefab root, tuned fast + low-HP + fast frequent low-chip bites (via the + /// EnemyAuthoring fields). This bakes only the 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. + /// + public class SwarmerAuthoring : MonoBehaviour + { + private class SwarmerBaker : Baker + { + public override void Bake(SwarmerAuthoring authoring) + { + var entity = GetEntity(authoring, TransformUsageFlags.Dynamic); + AddComponent(entity); + } + } + } +} diff --git a/Assets/_Project/Scripts/Authoring/Combat/SwarmerAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Combat/SwarmerAuthoring.cs.meta new file mode 100644 index 000000000..6725a0f9b --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Combat/SwarmerAuthoring.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b6a84b442d0535642abc303c01546a15 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Combat/WaveDirectorAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/WaveDirectorAuthoring.cs index 3d9d00f8a..d34a02712 100644 --- a/Assets/_Project/Scripts/Authoring/Combat/WaveDirectorAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Combat/WaveDirectorAuthoring.cs @@ -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 { @@ -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(entity); diff --git a/Assets/_Project/Scripts/Authoring/Combat/ZoneEnemyDirectorAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/ZoneEnemyDirectorAuthoring.cs index 30ba17e03..f323ce11b 100644 --- a/Assets/_Project/Scripts/Authoring/Combat/ZoneEnemyDirectorAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Combat/ZoneEnemyDirectorAuthoring.cs @@ -14,15 +14,23 @@ namespace ProjectM.Authoring /// 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 @@ -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(entity); diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs index 1b04c342e..7156a56b4 100644 --- a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs @@ -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(); state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly())); + m_EnemyProjectiles = state.GetEntityQuery(ComponentType.ReadOnly()); } [BurstCompile] @@ -104,7 +107,7 @@ namespace ProjectM.Server foreach (var (xform, stats, cooldown, knockback, windup, region) in SystemAPI.Query, RefRO, RefRW, RefRW, RefRW, RefRO>() - .WithAll().WithNone()) + .WithAll().WithNone()) { 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, RefRO, RefRW, RefRW, RefRW, RefRW, RefRO>() - .WithAll()) + .WithAll().WithNone()) { 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().WithNone() (and the Grunt + // pass excludes SpitterState) so a Spitter is moved by EXACTLY this pass — the sole-Position-writer rule. + bool haveSpit = SystemAPI.TryGetSingleton(out var spitCfg) && spitCfg.Prefab != Entity.Null; + int liveSpits = m_EnemyProjectiles.CalculateEntityCount(); + LocalTransform spitBakedLt = default; + EnemyProjectile spitBakedProj = default; + if (haveSpit) + { + spitBakedLt = state.EntityManager.GetComponentData(spitCfg.Prefab); + spitBakedProj = state.EntityManager.GetComponentData(spitCfg.Prefab); + } + foreach (var (xform, stats, knockback, windup, spitter, region) in + SystemAPI.Query, RefRO, RefRW, + RefRW, RefRW, RefRO>() + .WithAll().WithNone()) + { + 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 diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyProjectileDamageSystem.cs b/Assets/_Project/Scripts/Server/Combat/EnemyProjectileDamageSystem.cs new file mode 100644 index 000000000..29f0689b3 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/EnemyProjectileDamageSystem.cs @@ -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 +{ + /// + /// 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 after (post-move position). + /// SWEPT planar hit-test (the DR-018 anti-tunnelling discipline): the travel segment is rebuilt from the STORED + /// (cur - Direction*LastStep), NEVER a fresh delta. REGION-FILTERED: a + /// target whose .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 + /// HealthApplyDamageSystem — 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. + /// + [BurstCompile] + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + [UpdateInGroup(typeof(SimulationSystemGroup))] + [UpdateAfter(typeof(EnemyProjectileMoveSystem))] + public partial struct EnemyProjectileDamageSystem : ISystem + { + /// Extra forgiveness for the spit's own size, added to a target's hit radius. + const float k_ProjectileRadius = 0.2f; + + /// Hit radius used for structures, which (by design) bake no HitRadius (so player shots never hit them). + const float k_StructureRadius = 1.0f; + + [BurstCompile] + public void OnCreate(ref SystemState state) + { + state.RequireForUpdate(); + state.RequireForUpdate(); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + uint now = SystemAPI.GetSingleton().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(Allocator.Temp); + var targetPositions = new NativeList(Allocator.Temp); + var targetRadii = new NativeList(Allocator.Temp); + var targetRegions = new NativeList(Allocator.Temp); + + foreach (var (xform, hitRadius, health, region, e) in + SystemAPI.Query, RefRO, RefRO, RefRO>() + .WithAll().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, RefRO>() + .WithAll().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(16, Allocator.Temp); + foreach (var (xform, proj, projEntity) in + SystemAPI.Query, RefRO>().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(); + } + } +} diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyProjectileDamageSystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/EnemyProjectileDamageSystem.cs.meta new file mode 100644 index 000000000..4a4f65cbf --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/EnemyProjectileDamageSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4f6dbd4ab9a2b154e8d7cb1796904ab6 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyProjectileMoveSystem.cs b/Assets/_Project/Scripts/Server/Combat/EnemyProjectileMoveSystem.cs new file mode 100644 index 000000000..22c0a6e1d --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/EnemyProjectileMoveSystem.cs @@ -0,0 +1,49 @@ +using ProjectM.Simulation; +using Unity.Burst; +using Unity.Entities; +using Unity.Mathematics; +using Unity.Transforms; + +namespace ProjectM.Server +{ + /// + /// MC-2 — integrates hostile Spitter projectiles () server-only in the plain + /// (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 = Speed*dt so + /// 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 [UpdateAfter(EnemyAISystem)] (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 reads it. + /// + [BurstCompile] + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + [UpdateInGroup(typeof(SimulationSystemGroup))] + [UpdateAfter(typeof(EnemyAISystem))] + public partial struct EnemyProjectileMoveSystem : ISystem + { + [BurstCompile] + public void OnCreate(ref SystemState state) + { + state.RequireForUpdate(); + } + + [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>()) + { + 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; + } + } + } +} diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyProjectileMoveSystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/EnemyProjectileMoveSystem.cs.meta new file mode 100644 index 000000000..35e070909 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/EnemyProjectileMoveSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9acb4c22874b1fa489433644b90334db \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/WaveSystem.cs b/Assets/_Project/Scripts/Server/Combat/WaveSystem.cs index ec136cb5d..5bec3d71b 100644 --- a/Assets/_Project/Scripts/Server/Combat/WaveSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/WaveSystem.cs @@ -51,6 +51,21 @@ namespace ProjectM.Server var wave = SystemAPI.GetComponent(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(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(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>().WithAll()) + 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(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 diff --git a/Assets/_Project/Scripts/Server/Combat/ZoneEnemyDirectorSystem.cs b/Assets/_Project/Scripts/Server/Combat/ZoneEnemyDirectorSystem.cs index 2ea3e261b..37e4500d6 100644 --- a/Assets/_Project/Scripts/Server/Combat/ZoneEnemyDirectorSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/ZoneEnemyDirectorSystem.cs @@ -71,58 +71,82 @@ namespace ProjectM.Server var runtime = SystemAPI.GetComponent(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(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(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(prefab); - ecb.SetComponent(enemy, baked.WithPosition(pos)); - ecb.AddComponent(enemy, new RegionTag { Region = RegionId.Expedition }); - ecb.AddComponent(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(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(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); diff --git a/Assets/_Project/Scripts/Simulation/Combat/AttackWindup.cs b/Assets/_Project/Scripts/Simulation/Combat/AttackWindup.cs index 400364f9c..36195f260 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/AttackWindup.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/AttackWindup.cs @@ -29,7 +29,8 @@ namespace ProjectM.Simulation /// Per-variant wind-up DURATION in ticks (the client danger-ramp denominator). public byte WindupTicks; - /// 0 = Grunt-style; 1 = Charger (committed-lunge tell). - public byte IsCharger; + /// Enemy kind for the client telegraph look: 0=Grunt, 1=Charger, 2=Spitter, 3=Swarmer (the + /// ZoneEnemyMath.Kind* bytes). Baked per variant by EnemyBaker (the SOLE writer); never a [GhostField]. + public byte Kind; } } diff --git a/Assets/_Project/Scripts/Simulation/Combat/EnemyAIMath.cs b/Assets/_Project/Scripts/Simulation/Combat/EnemyAIMath.cs index 3956d1c80..7674368c4 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/EnemyAIMath.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/EnemyAIMath.cs @@ -126,5 +126,42 @@ namespace ProjectM.Simulation if (sq < bestSq) { bestSq = sq; index = i; isStructure = true; } } } + + /// + /// MC-2 Spitter range-band velocity (planar XZ): ADVANCE toward at + /// when farther than + , + /// RETREAT directly away when closer than - , and + /// HOLD (zero) inside the dead-zone band. Y forced to 0. Returns zero when the points coincide. Pure / + /// Burst-safe / EditMode-testable; keeps the Spitter at its firing distance instead of closing to melee. + /// + public static float3 BandVelocity(float3 from, float3 to, float speed, float preferred, float tolerance) + { + float3 d = to - from; + d.y = 0f; + float distSq = math.lengthsq(d); + if (distSq < 1e-8f) + return float3.zero; + float dist = math.sqrt(distSq); + float3 dir = d / dist; + float tol = math.max(0f, tolerance); + if (dist > preferred + tol) + return dir * speed; // too far -> close in + if (dist < preferred - tol) + return -dir * speed; // too close -> back off + return float3.zero; // in band -> hold and fire + } + + /// + /// Deterministic tight-cluster offset for swarmer of a pack of + /// around at (reuses the + /// even-angle math at a small radius). A single swarmer (packSize<=1) spawns at + /// the centre. Stable per index so a replayed pack lands identically. Pure. + /// + public static float3 ClusterOffset(float3 center, int index, int packSize, float tightRadius) + { + if (packSize <= 1) + return center; + return RingPosition(center, index, packSize, tightRadius); + } } } diff --git a/Assets/_Project/Scripts/Simulation/Combat/EnemyProjectile.cs b/Assets/_Project/Scripts/Simulation/Combat/EnemyProjectile.cs new file mode 100644 index 000000000..b90cf2f62 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/EnemyProjectile.cs @@ -0,0 +1,53 @@ +using Unity.Entities; +using Unity.Mathematics; + +namespace ProjectM.Simulation +{ + /// + /// MC-2 — a hostile Spitter projectile: a server-spawned, OWNERLESS INTERPOLATED ghost moved server-only in the + /// plain SimulationSystemGroup (NOT predicted — like the Husks that fire it). It replicates ONLY the stock + /// LocalTransform (no hand-written [GhostField]); this component is server-only state. It deliberately carries NO + /// Health, so it is invisible to every WithAll<Health> target loop (player melee/projectile hit-tests can + /// never see it — fork 2a: spits are pure dodge/dash checks, NOT shootable). Integrated by + /// EnemyProjectileMoveSystem and swept-hit-tested against players + structures by EnemyProjectileDamageSystem. + /// + public struct EnemyProjectile : IComponentData + { + /// Planar heading (world XZ -> float2 x,y), unit length, locked at spawn. + public float2 Direction; + + /// Travel speed (world units/second). + public float Speed; + + /// Damage applied to the first valid same-region target hit. + public float Damage; + + /// Max travel distance before it expires (world units). + public float Range; + + /// Accumulated travelled distance (server-only; drives range-expiry). + public float DistanceTravelled; + + /// Distance moved on the LAST tick (= Speed * the server fixed step). The damage system rebuilds the + /// swept segment as cur - Direction*LastStep — NEVER a fresh SystemAPI.Time.DeltaTime (this system runs in the + /// PLAIN group where that dt is the wall-frame delta, not the fixed step). Prevents high-speed tunnelling. + public float LastStep; + + /// Region byte (RegionId.Base/Expedition), copied from the firing Spitter. The damage system skips any + /// target whose RegionTag.Region != this — relevancy hides cross-region ghosts from CLIENTS, but the SERVER + /// world holds base + expedition players 1000u apart, so server damage needs its OWN region guard. + public byte Region; + } + + /// + /// Baked subscene singleton: the Spitter projectile ghost prefab + the concurrent soft-cap. The server reads it + /// via GetSingleton (the prefab Entity lives HERE, never per-Spitter — mirrors AbilityDatabase / WaveEnemyPrefab). + /// MaxLiveProjectiles bounds the RegionRelevancySystem O(ghosts x conn)/tick loop: a Spitter at/over the cap + /// soft-fails its shot (no cooldown burn — the EB-2 turret soft-fail pattern). + /// + public struct SpitterProjectilePrefab : IComponentData + { + public Entity Prefab; + public int MaxLiveProjectiles; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/EnemyProjectile.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/EnemyProjectile.cs.meta new file mode 100644 index 000000000..d7e486332 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/EnemyProjectile.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8a31a7b0c834ae24db480005ffdb6a15 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/MixBands.cs b/Assets/_Project/Scripts/Simulation/Combat/MixBands.cs new file mode 100644 index 000000000..7fc39c760 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/MixBands.cs @@ -0,0 +1,30 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// MC-2 — baked weighted-composition table shared by BOTH enemy directors (the expedition + /// ZoneEnemyDirectorSystem and the base-siege WaveSystem). Pure integer weights consumed by the deterministic + /// .{WaveSlots, KindForSlot, PackSizeForSlot} functions (no enum, no RNG -> + /// replay/save-stable). Per kind: a base count + a per-epoch ramp; the Grunt count is the REMAINDER (slots minus + /// the others) so it stays a fixed floor while chargers / spitters / swarmer-slots grow as the epoch (expedition) + /// or wave (base siege) climbs. A "swarmer slot" expands to a PackSize cluster at spawn (PackSizeForSlot), so one + /// slot = one pack. The LEGACY band {GruntBase=g, ChargerBase=c, ChargerPerEpoch=1, rest 0} reproduces the old + /// 2-type / exactly (a parity test + /// pins this, so the base-siege size curve is provably unchanged where it must be). + /// + public struct MixBands : IComponentData + { + public int GruntBase; + public int ChargerBase; + public int SpitterBase; + public int SwarmerSlotBase; + public int ChargerPerEpoch; + public int SpitterPerEpoch; + public int SwarmerSlotPerEpoch; + + /// Exposed-but-default-0 epoch ramp for the swarmer PACK size (PackSizeForSlot adds + /// SwarmerPackPerEpoch*(epoch-1) to the director's base pack size). v1 keeps it 0 = fixed pack size. + public int SwarmerPackPerEpoch; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/MixBands.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/MixBands.cs.meta new file mode 100644 index 000000000..bad4a8a30 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/MixBands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 850f904d96b1c7d41959dddbdbf0b4b5 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/SpitterComponents.cs b/Assets/_Project/Scripts/Simulation/Combat/SpitterComponents.cs new file mode 100644 index 000000000..0c56bda2f --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/SpitterComponents.cs @@ -0,0 +1,45 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// MC-2 — server-only Spitter "reposition" brain state. Component PRESENCE is the Spitter discriminator (no + /// enum / brain byte — honours the Burst cross-assembly-enum rule; EnemyAISystem is Bursted): a Husk variant + /// baked with SpitterState is driven by the ranged range-band branch, mutually exclusive with the Charger + /// branch (the AI partitions Spitter = .WithAll<EnemyTag,SpitterState>().WithNone<LungeState>() so no + /// enemy is ever double-moved). The Spitter holds a PREFERRED RANGE band from its target — retreating if too + /// close, advancing if too far — and fires a TELEGRAPHED, dodgeable projectile on its OWN fire gate. If + /// cornered (no retreat room) within CorneredRange it falls back to the Grunt seek+strike. NOT a [GhostField] + /// (only server systems read it). All ticks via TickUtil.NonZero; compared with NetworkTick only. + /// + public struct SpitterState : IComponentData + { + /// Band centre: the distance the Spitter tries to hold from its target (world units). + public float PreferredRange; + + /// Half-width dead-zone around PreferredRange; inside [pref-tol, pref+tol] the Spitter holds. + public float RangeTolerance; + + /// Muzzle speed baked onto the spit projectile (world units/second). + public float ProjectileSpeed; + + /// If the target closes within this distance AND the Spitter can't retreat, it melee-falls-back. + public float CorneredRange; + + /// Telegraph wind-up lead in ticks before the spit fires (the dodge window). Baked (v1 not + /// live-tunable); keep >= ~24 (> interp delay) so a player reacting to the aim-line can clear the shot. + public int WindupTicks; + + /// Server-only fire gate: raw tick of the earliest tick it may spit again (NonZero; 0 = ready). Its + /// OWN gate, never EnemyAttackCooldown. Compared via NetworkTick.IsNewerThan. + public uint NextShotTick; + } + + /// + /// MC-2 — pure marker for a Swarmer "surround" enemy: mechanically a Grunt (NO AI branch — it falls through the + /// Grunt seek+strike pass) with swarm-tuned baked EnemyStats (fast, low-HP, fast frequent low-chip bites). The + /// tag drives only (a) the director's CLUSTER spawn (PackSize swarmers in one tick) and (b) a client tint. Keeps + /// EnemyTag + RegionTag like every Husk, so readability / health-bars / damage / region-AI all work unchanged. + /// + public struct SwarmerTag : IComponentData { } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/SpitterComponents.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/SpitterComponents.cs.meta new file mode 100644 index 000000000..2329aa000 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/SpitterComponents.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: be9404154fd4f964099918079d2da6b8 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/WaveComponents.cs b/Assets/_Project/Scripts/Simulation/Combat/WaveComponents.cs index 0b125f6dc..0cac04171 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/WaveComponents.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/WaveComponents.cs @@ -16,6 +16,17 @@ namespace ProjectM.Simulation public int CountPerWave; public int SpawnIntervalTicks; public int LullTicks; + public int MaxAlive; // MC-2 fork-4a: mandatory cap for the 4-type base siege (uncapped packs/spits spike relevancy). + // MC-2 base-siege mix bands (the director builds a ZoneEnemyMath.MixBands): BaseCount above = the Grunt base count. + public int ChargerBase; + public int SpitterBase; + public int SwarmerSlotBase; + public int ChargerPerEpoch; + public int SpitterPerEpoch; + public int SwarmerSlotPerEpoch; + public int SwarmerPackSize; + public int SwarmerPackPerEpoch; + public float ClusterTightRadius; } /// Baked pool of Husk prefab variants the director draws from round-robin (Grunt / Swarmer / Brute / ...). diff --git a/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyComponents.cs b/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyComponents.cs index 406137da0..dc8fe3ddc 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyComponents.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyComponents.cs @@ -28,6 +28,16 @@ namespace ProjectM.Simulation public int SpawnIntervalTicks; public int GruntsPerWave; public int ChargersPerWave; + // MC-2 mix bands (the director builds a ZoneEnemyMath.MixBands from these): GruntsPerWave/ChargersPerWave + // above are the Grunt/Charger BASE counts; these add the Spitter/Swarmer bases + per-epoch ramps + the pack. + public int SpitterBase; + public int SwarmerSlotBase; + public int ChargerPerEpoch; + public int SpitterPerEpoch; + public int SwarmerSlotPerEpoch; + public int SwarmerPackSize; // swarmers per swarmer-slot cluster (>=1) + public int SwarmerPackPerEpoch; // exposed pack ramp (v1 default 0 = fixed) + public float ClusterTightRadius; // tight ring radius for a swarmer pack public int RewardOre; } diff --git a/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyMath.cs b/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyMath.cs index 3102b0e13..d1098a9fb 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyMath.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyMath.cs @@ -38,5 +38,66 @@ namespace ProjectM.Simulation int s = ((slot % size) + size) % size; return s >= size - chargers; } + + // ---- MC-2: 4-type weighted composition (Grunt/Charger/Spitter/Swarmer), shared by both directors ---- + // Kind bytes (NO C# enum — directors index a per-Kind prefab buffer by these; EnemyAISystem is Bursted). + public const byte KindGrunt = 0; + public const byte KindCharger = 1; + public const byte KindSpitter = 2; + public const byte KindSwarmer = 3; + + /// + /// Total SLOTS in this epoch/wave under : GruntBase + the per-kind ramped counts + /// (charger/spitter/swarmer-slot = base + perEpoch*(epoch-1)). Lower-bounded at 1 so there is always a fight. + /// A swarmer SLOT expands to a pack at spawn (), so this counts packs, not + /// individual swarmers. For the LEGACY band it equals (parity-tested). Pure integer. + /// + public static int WaveSlots(int epoch, in MixBands bands) + { + int e = math.max(1, epoch); + int grunts = math.max(0, bands.GruntBase); + int chargers = math.max(0, bands.ChargerBase + bands.ChargerPerEpoch * (e - 1)); + int spitters = math.max(0, bands.SpitterBase + bands.SpitterPerEpoch * (e - 1)); + int swarmers = math.max(0, bands.SwarmerSlotBase + bands.SwarmerSlotPerEpoch * (e - 1)); + return math.max(1, grunts + chargers + spitters + swarmers); + } + + /// + /// Deterministic Kind byte for spawn of this epoch/wave. Slots are partitioned in a + /// FIXED order — Grunts, then Spitters, then Chargers, then Swarmer-slots last — so the wave skews threat-heavy + /// as the ramped counts climb (Grunts are the remainder = a fixed floor). Any leftover slot (when the kinds + /// under-fill the max(1,..) floor) defaults to Grunt. Stable per (epoch, slot). For the LEGACY band this + /// returns KindCharger on exactly the slots the old did (parity-tested). Pure. + /// + public static byte KindForSlot(int epoch, int slot, in MixBands bands) + { + int e = math.max(1, epoch); + int size = WaveSlots(epoch, bands); + int chargers = math.max(0, bands.ChargerBase + bands.ChargerPerEpoch * (e - 1)); + int spitters = math.max(0, bands.SpitterBase + bands.SpitterPerEpoch * (e - 1)); + int swarmers = math.max(0, bands.SwarmerSlotBase + bands.SwarmerSlotPerEpoch * (e - 1)); + int grunts = math.max(0, size - chargers - spitters - swarmers); // remainder = fixed grunt floor + + int s = ((slot % size) + size) % size; + if (s < grunts) return KindGrunt; + s -= grunts; + if (s < spitters) return KindSpitter; + s -= spitters; + if (s < chargers) return KindCharger; + s -= chargers; + if (s < swarmers) return KindSwarmer; + return KindGrunt; // defensive: unreachable while counts sum to size + } + + /// + /// Swarmer cluster size for a swarmer slot: plus the (default-0) + /// ramp. Lower-bounded at 1. v1 bakes the ramp 0 -> a fixed pack; + /// the field is exposed for later tuning. + /// + public static int PackSizeForSlot(int epoch, int slot, in MixBands bands, int basePackSize) + { + int e = math.max(1, epoch); + return math.max(1, basePackSize + math.max(0, bands.SwarmerPackPerEpoch) * (e - 1)); + } } } diff --git a/Assets/_Project/Tests/EditMode/EnemyAIMathMC2Tests.cs b/Assets/_Project/Tests/EditMode/EnemyAIMathMC2Tests.cs new file mode 100644 index 000000000..514b6ac6f --- /dev/null +++ b/Assets/_Project/Tests/EditMode/EnemyAIMathMC2Tests.cs @@ -0,0 +1,49 @@ +using NUnit.Framework; +using ProjectM.Simulation; +using Unity.Mathematics; + +namespace ProjectM.Tests +{ + /// + /// MC-2 pure-math tests for the new EnemyAIMath helpers: BandVelocity (Spitter range-band keep-distance) and + /// ClusterOffset (swarmer pack placement). No ECS world. + /// + public class EnemyAIMathMC2Tests + { + [Test] + public void BandVelocity_AdvancesWhenTooFar() + { + var v = EnemyAIMath.BandVelocity(new float3(0, 1, 0), new float3(20, 1, 0), 5f, 9f, 1.5f); + Assert.Greater(v.x, 0.1f, "too far -> moves toward the target"); + Assert.AreEqual(0f, v.y, 1e-5f, "planar"); + } + + [Test] + public void BandVelocity_RetreatsWhenTooClose() + { + var v = EnemyAIMath.BandVelocity(new float3(0, 1, 0), new float3(3, 1, 0), 5f, 9f, 1.5f); + Assert.Less(v.x, -0.1f, "too close -> backs away from the target"); + } + + [Test] + public void BandVelocity_HoldsInBand() + { + var v = EnemyAIMath.BandVelocity(new float3(0, 1, 0), new float3(9, 1, 0), 5f, 9f, 1.5f); + Assert.AreEqual(0f, math.length(v), 1e-4f, "inside the dead-zone band -> hold and fire"); + } + + [Test] + public void ClusterOffset_SingleAtCentre_PackSpread() + { + var c = new float3(100, 1, 5); + var single = EnemyAIMath.ClusterOffset(c, 0, 1, 2.5f); + Assert.AreEqual(c.x, single.x, 1e-5f, "a lone swarmer spawns at the pack centre"); + Assert.AreEqual(c.z, single.z, 1e-5f); + var a = EnemyAIMath.ClusterOffset(c, 0, 4, 2.5f); + var b = EnemyAIMath.ClusterOffset(c, 1, 4, 2.5f); + Assert.Greater(math.distance(a, b), 0.01f, "pack members get distinct offsets"); + var again = EnemyAIMath.ClusterOffset(c, 2, 4, 2.5f); + Assert.AreEqual(0f, math.distance(again, EnemyAIMath.ClusterOffset(c, 2, 4, 2.5f)), 1e-5f, "deterministic"); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/EnemyAIMathMC2Tests.cs.meta b/Assets/_Project/Tests/EditMode/EnemyAIMathMC2Tests.cs.meta new file mode 100644 index 000000000..b149f53f1 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/EnemyAIMathMC2Tests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9242a20ea43527243a4e93c343f0cb49 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/EnemyProjectileTests.cs b/Assets/_Project/Tests/EditMode/EnemyProjectileTests.cs new file mode 100644 index 000000000..70be1d772 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/EnemyProjectileTests.cs @@ -0,0 +1,132 @@ +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 +{ + /// + /// MC-2 tests for the hostile Spitter projectile systems (server-only, plain SimulationSystemGroup): + /// EnemyProjectileMoveSystem integrates + writes LastStep; EnemyProjectileDamageSystem swept-hit-tests players + + /// structures, REGION-FILTERED, appending a DamageEvent + destroying the spit at-most-once. Covers the two + /// review-mandated regressions: swept anti-TUNNELLING (a per-tick step bigger than the target radius still + /// registers) and the cross-region damage guard (an Expedition spit must not damage a Base target on its path). + /// + public class EnemyProjectileTests + { + 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) MoveWorld() + { + var w = new World("EnemyProjMove"); + var g = w.GetOrCreateSystemManaged(); + g.AddSystemToUpdateList(w.GetOrCreateSystem()); + g.SortSystems(); + w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 0.1f)); + return (w, g); + } + + static (World, SimulationSystemGroup) DamageWorld() + { + var w = new World("EnemyProjDmg"); + var g = w.GetOrCreateSystemManaged(); + g.AddSystemToUpdateList(w.GetOrCreateSystem()); + g.SortSystems(); + w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 0.1f)); + SetTick(w, 200); + return (w, g); + } + + static Entity MakeSpit(EntityManager em, float3 pos, float2 dir, float speed, float range, byte region, float lastStep = 0f, float damage = 10f) + { + var e = em.CreateEntity(); + em.AddComponentData(e, LocalTransform.FromPosition(pos)); + em.AddComponentData(e, new EnemyProjectile { Direction = dir, Speed = speed, Damage = damage, Range = range, DistanceTravelled = 0f, LastStep = lastStep, Region = region }); + return e; + } + + static Entity MakePlayerTarget(EntityManager em, float3 pos, byte region, float radius = 0.6f) + { + var e = em.CreateEntity(); + em.AddComponentData(e, LocalTransform.FromPosition(pos)); + em.AddComponentData(e, new Health { Current = 100f, Max = 100f }); + em.AddComponentData(e, new HitRadius { Value = radius }); + em.AddComponentData(e, new RegionTag { Region = region }); + em.AddBuffer(e); + em.AddComponent(e); + return e; + } + + [Test] + public void Move_IntegratesAndStoresLastStep() + { + var (w, g) = MoveWorld(); + using (w) + { + var em = w.EntityManager; + var spit = MakeSpit(em, new float3(0, 1, 0), new float2(1, 0), 10f, 5f, RegionId.Base); + g.Update(); // dt 0.1 * speed 10 = step 1 + var p = em.GetComponentData(spit); + Assert.AreEqual(1f, p.LastStep, 1e-4f, "LastStep = Speed*dt (for the swept segment)"); + Assert.AreEqual(1f, p.DistanceTravelled, 1e-4f); + Assert.AreEqual(1f, em.GetComponentData(spit).Position.x, 1e-4f, "moved along +X"); + } + } + + [Test] + public void Damage_HitsSameRegionPlayer_DestroysAtMostOnce() + { + var (w, g) = DamageWorld(); + using (w) + { + var em = w.EntityManager; + var player = MakePlayerTarget(em, new float3(5, 1, 0), RegionId.Base); + var spit = MakeSpit(em, new float3(5, 1, 0), new float2(1, 0), 10f, 20f, RegionId.Base, lastStep: 1f); + g.Update(); + Assert.AreEqual(1, em.GetBuffer(player).Length, "same-region player takes the hit"); + Assert.IsFalse(em.Exists(spit), "the spit is consumed on hit"); + } + } + + [Test] + public void Damage_RegionFilter_ExpeditionSpitSparesBasePlayer() + { + var (w, g) = DamageWorld(); + using (w) + { + var em = w.EntityManager; + var basePlayer = MakePlayerTarget(em, new float3(5, 1, 0), RegionId.Base); + var spit = MakeSpit(em, new float3(5, 1, 0), new float2(1, 0), 10f, 20f, RegionId.Expedition, lastStep: 1f); + g.Update(); + Assert.AreEqual(0, em.GetBuffer(basePlayer).Length, "cross-region spit must NOT damage an off-region player"); + Assert.IsTrue(em.Exists(spit), "and it is not consumed by an off-region target"); + } + } + + [Test] + public void Damage_SweptSegment_NoTunnelThroughSmallTarget() + { + var (w, g) = DamageWorld(); + using (w) + { + var em = w.EntityManager; + // target radius 0.5 at x=5; spit now at x=10 but stepped 8 this tick (start x=2) -> segment [2..10] crosses x=5. + var player = MakePlayerTarget(em, new float3(5, 1, 0), RegionId.Base, radius: 0.5f); + var spit = MakeSpit(em, new float3(10, 1, 0), new float2(1, 0), 80f, 50f, RegionId.Base, lastStep: 8f); + g.Update(); + Assert.AreEqual(1, em.GetBuffer(player).Length, + "swept segment hits even when the per-tick step exceeds the target radius (no tunnelling)"); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/EnemyProjectileTests.cs.meta b/Assets/_Project/Tests/EditMode/EnemyProjectileTests.cs.meta new file mode 100644 index 000000000..0917da40b --- /dev/null +++ b/Assets/_Project/Tests/EditMode/EnemyProjectileTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e63d6c6d98027f248be3fc163961ca95 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/ZoneEnemyMixTests.cs b/Assets/_Project/Tests/EditMode/ZoneEnemyMixTests.cs new file mode 100644 index 000000000..5c1136e93 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ZoneEnemyMixTests.cs @@ -0,0 +1,91 @@ +using NUnit.Framework; +using ProjectM.Simulation; + +namespace ProjectM.Tests +{ + /// + /// MC-2 pure-math tests for the 4-type weighted composition (ZoneEnemyMath.WaveSlots / KindForSlot / + /// PackSizeForSlot) shared by both enemy directors. Deterministic integer math (no ECS world). The PARITY test + /// pins that the legacy band reproduces the old 2-type WaveSize/IsChargerSlot EXACTLY, so the base-siege size + + /// composition is provably controlled where it must be (the fork-4a safety net). + /// + public class ZoneEnemyMixTests + { + static MixBands Bands(int g, int c, int sp, int sw, int cPer, int spPer, int swPer, int packPer = 0) => new MixBands + { + GruntBase = g, ChargerBase = c, SpitterBase = sp, SwarmerSlotBase = sw, + ChargerPerEpoch = cPer, SpitterPerEpoch = spPer, SwarmerSlotPerEpoch = swPer, SwarmerPackPerEpoch = packPer, + }; + + [Test] + public void WaveSlots_LowerBoundedAtOne_AndSumsTheBands() + { + Assert.AreEqual(1, ZoneEnemyMath.WaveSlots(1, Bands(0, 0, 0, 0, 0, 0, 0)), "empty band still yields a fight"); + Assert.AreEqual(5, ZoneEnemyMath.WaveSlots(1, Bands(4, 1, 0, 0, 1, 0, 0)), "4 grunts + 1 charger at epoch 1"); + Assert.AreEqual(7, ZoneEnemyMath.WaveSlots(3, Bands(4, 1, 0, 0, 1, 0, 0)), "epoch 3: +1 charger/epoch -> 4+(1+2)"); + Assert.AreEqual(4 + 2 + 1 + 1, ZoneEnemyMath.WaveSlots(2, Bands(4, 1, 0, 0, 1, 1, 1)), "epoch 2: 4 grunts + 2 chargers + 1 spitter + 1 swarmer-slot"); + } + + [Test] + public void KindForSlot_Deterministic() + { + var b = Bands(4, 1, 1, 1, 1, 1, 1); + for (int slot = 0; slot < 30; slot++) + Assert.AreEqual(ZoneEnemyMath.KindForSlot(5, slot, b), ZoneEnemyMath.KindForSlot(5, slot, b), "stable per (epoch,slot)"); + } + + [Test] + public void KindForSlot_GruntFloorFixed_ThreatsGrowWithEpoch() + { + var b = Bands(4, 1, 0, 0, 1, 0, 0); // grunts fixed at 4, chargers grow + CountKinds(b, 1, out int g1, out int c1, out int _, out int _); + Assert.AreEqual(4, g1); Assert.AreEqual(1, c1); + CountKinds(b, 5, out int g5, out int c5, out int _, out int _); + Assert.AreEqual(4, g5, "grunt count is a fixed floor"); Assert.AreEqual(5, c5, "chargers = base + (epoch-1)"); + } + + [Test] + public void KindForSlot_ParityWithLegacyIsChargerSlot() + { + for (int g = 0; g <= 6; g++) + for (int c = 0; c <= 4; c++) + for (int e = 1; e <= 6; e++) + { + var b = Bands(g, c, 0, 0, 1, 0, 0); // legacy band: charger ramps +1/epoch, no spitter/swarmer + int size = ZoneEnemyMath.WaveSlots(e, b); + Assert.AreEqual(ZoneEnemyMath.WaveSize(e, g, c), size, $"WaveSlots vs WaveSize g{g} c{c} e{e}"); + for (int slot = 0; slot < size + 3; slot++) + { + bool legacy = ZoneEnemyMath.IsChargerSlot(e, slot, g, c); + bool now = ZoneEnemyMath.KindForSlot(e, slot, b) == ZoneEnemyMath.KindCharger; + Assert.AreEqual(legacy, now, $"parity g{g} c{c} e{e} slot{slot}"); + } + } + } + + [Test] + public void PackSizeForSlot_FixedByDefault_RampsWhenSet() + { + var fixedBand = Bands(0, 0, 0, 1, 0, 0, 1); + Assert.AreEqual(4, ZoneEnemyMath.PackSizeForSlot(1, 0, fixedBand, 4), "base pack"); + Assert.AreEqual(4, ZoneEnemyMath.PackSizeForSlot(5, 0, fixedBand, 4), "no ramp -> fixed across epochs"); + var rampBand = Bands(0, 0, 0, 1, 0, 0, 1, packPer: 2); + Assert.AreEqual(4 + 2 * 2, ZoneEnemyMath.PackSizeForSlot(3, 0, rampBand, 4), "epoch 3 ramp +2*(3-1)"); + Assert.GreaterOrEqual(ZoneEnemyMath.PackSizeForSlot(1, 0, fixedBand, 0), 1, "lower-bounded at 1"); + } + + static void CountKinds(MixBands b, int epoch, out int g, out int c, out int sp, out int sw) + { + g = c = sp = sw = 0; + int size = ZoneEnemyMath.WaveSlots(epoch, b); + for (int slot = 0; slot < size; slot++) + { + byte k = ZoneEnemyMath.KindForSlot(epoch, slot, b); + if (k == ZoneEnemyMath.KindGrunt) g++; + else if (k == ZoneEnemyMath.KindCharger) c++; + else if (k == ZoneEnemyMath.KindSpitter) sp++; + else sw++; + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/ZoneEnemyMixTests.cs.meta b/Assets/_Project/Tests/EditMode/ZoneEnemyMixTests.cs.meta new file mode 100644 index 000000000..f70319c1f --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ZoneEnemyMixTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6853067864d6bc342bac525cdee324f8 \ No newline at end of file diff --git a/Docs/Vault/07_Sessions/_Decisions/DR-041_Slice_Combat_Depth_Enemy_Variety_Impact.md b/Docs/Vault/07_Sessions/_Decisions/DR-041_Slice_Combat_Depth_Enemy_Variety_Impact.md new file mode 100644 index 000000000..a002b9d47 --- /dev/null +++ b/Docs/Vault/07_Sessions/_Decisions/DR-041_Slice_Combat_Depth_Enemy_Variety_Impact.md @@ -0,0 +1,92 @@ +--- +id: DR-041 +title: Combat Depth Slice — Enemy Variety (MC-2) + Impact (MC-3) (reviewed + locked) +status: accepted +date: 2026-06-22 +tags: +- decision +- design +- combat +- enemies +- netcode +- juice +- slice +permalink: gamevault/07-sessions/decisions/dr-041-combat-depth-enemy-variety-impact +--- + +# DR-041 — Combat Depth: Enemy Variety (MC-2) + Impact (MC-3) + +> The combat-depth slice the operator chose after Slice 3 ("the combat needs a lot more work"). Today there are only **two enemy brains** (Grunt = walk-up melee, Charger = committed lunge) → effectively ONE question, so fights feel samey. This slice adds two NEW readable questions + makes hits FEEL like hits. Preceded by the mandatory adversarial pre-coding design review (1 ground + 3 lenses — netcode/relevancy/determinism · combat feel & readability · reuse/scope → synth; run `wf_eb115556-8cc`). All three lenses **GO_WITH_CHANGES**; the review **corrected four mis-groundings** (folded in below). Implements MC-2 + MC-3 from [[Path_to_Fun]] PATH B. Reuses the Charger pattern, region split, `ZoneEnemyMath`, `CombatFeedbackSystem`, `PrototypeCameraRig`. + +## The hole this closes +Pillar #2 ("depth via dialogue"): enemies ask DISTINCT readable questions; the player answers with skilled, committed tools (the dash + melee combo already exist and are good). With only melee threats there's one answer. This slice adds the **reposition question (Spitter)** and the **surround question (Swarmer)**, a weighted mix that ramps them in, and the **impact feel (MC-3)** that makes each exchange land. + +## Operator forks (locked) +- **Base siege gets the FULL 4-type mix too** (fork 4a, chosen over expedition-only). `WaveSystem` adopts the shared composition function **+ a MANDATORY new `WaveDirector.MaxAlive` cap** (none today → uncapped spitter projectiles + swarmer packs would spike the `O(ghosts×conn)` relevancy loop during the END-1/END-2 climax — the review made the cap a hard requirement of this choice). A PARITY test proves `WaveSlots` reproduces the legacy `BaseCount+(wave-1)*CountPerWave` size curve for the legacy band, so the base *pacing* is provably controlled even as contents gain variety. **Directly attacks the original "all combat in the home base feels stale" complaint.** +- **No shoot-down of spits** (fork 2a). `EnemyProjectile` stays OUT of the player-projectile hit loops; the dash-through-spit interaction (i-frame negation) is the committed-tool counter. Cheaper, simpler; trivially addable later. +- **Defaults taken** (operator "defaults then adjust" autonomy; all live-tunable): Spitter **holds position** in-band (strafe is a tuning revisit); hit-stop ships the **camera-punch baseline**, true freeze-frame gated behind `HitStopFreezeEnabled=false`; new enemies **staggered** in (Spitter @ epoch ≥2, Swarmer packs @ epoch ≥3); Swarmer **pack size fixed** for v1 (ramp field exposed, unwired). + +## Four review corrections (why the review earned its keep) +1. **dt-trap.** The enemy-projectile systems are NOT mirrors of `ProjectileMove/DamageSystem` — those run in `PredictedSimulationSystemGroup` where `SystemAPI.Time.DeltaTime` IS the fixed step; the enemy ones run in the **plain** server group where that dt is wall-frame. Store `LastStep = Speed*dt` at move time; the damage system rebuilds the swept segment ONLY from `cur - dir*LastStep`, never a fresh `Time.DeltaTime`. +2. **Missing damage-region filter (blocker).** `ProjectileDamageSystem` has NO region check (player projectiles are region-irrelevant so it's fine). A hostile spit hit-tests every `Health` entity in the shared world where base/expedition players coexist 1000u apart → `EnemyProjectile` carries `byte Region`; `EnemyProjectileDamageSystem` snapshots each target's `RegionTag.Region` and skips mismatches (same byte guard as `PickWeightedNearest`). +3. **Enemy hit-flash is NOT free.** Enemies render via Rukhanka GPU deformation (no classic `MeshRenderer`/MPB); `CombatFeedbackSystem` sees only the ghost `Entity`, not child render entities. A real material flash needs a ShaderGraph `_Flash*` + a `[MaterialProperty]` IComponentData on the render entity + a ghost→render-entity mapping → **DEFERRED to its own ShaderGraph slice**. MC-3 v1 ships camera-punch + magnitude scaling + a 2-frame emphasis on existing pooled GameObjects. +4. **Spitter telegraph + IsCharger migration mislabeled.** The existing danger cone is a melee wedge at the enemy's feet (useless for a ranged threat) → net-new **Kind-keyed aim-line** out to projectile range during wind-up (in scope). And `EnemyTelegraph.IsCharger` has ZERO runtime readers → `IsCharger→byte Kind` is a pure baker-side change in `EnemyAuthoring`, NOT a read-site migration. + +## The build (LOCKED) + +### Enemy discriminator — presence-tags, NO enum in the Bursted AI +`EnemyTelegraph.IsCharger → byte Kind` (0=Grunt,1=Charger,2=Spitter,3=Swarmer; `EnemyTelegraph` is not a `[GhostField]` → no re-bake). Brain discrimination stays presence-tag, with a **mandatory query-partition guard** so no entity is double-moved (sole-Position-writer invariant): +- Spitter pass: `.WithAll().WithNone()` +- Charger pass: iterate `LungeState` + `.WithAll().WithNone()` +- Grunt pass: `.WithAll().WithNone()` +- Baker-time assert: a prefab carries at most one of {`LungeState`,`SpitterState`}. Swarmer = a Grunt + `SwarmerTag` marker (swarm-tuned `EnemyStats`; tag drives clustering + client tint only). + +### Spitter (reposition) — new components, all server-only, none `[GhostField]` +- `SpitterState { float PreferredRange(9), RangeTolerance(1.5), ProjectileSpeed, CorneredRange; uint NextShotTick }` (own fire gate via `TickUtil.NonZero`, NOT `EnemyAttackCooldown`). +- `EnemyProjectile { float2 Direction; float Speed,Damage,Range,DistanceTravelled,LastStep; byte Region }`. +- `SpitterProjectilePrefab { Entity Prefab; int MaxLiveProjectiles(24) }` (subscene singleton, server `GetSingleton` — mirrors `AbilityDatabase`/`WaveEnemyPrefab`). +- **AI pass (3rd foreach in `EnemyAISystem`, same `ecb`):** (1) knockback branch verbatim; (2) region-scoped `PickWeightedNearest`; (3) range-band move via new pure `EnemyAIMath.BandVelocity(from,to,speed,pref,tol)` (advance if too far, retreat if too close, zero in-band, Y flat, `SweptMove`); (4) **cornered fallback** — if `SweptMove` collapsed the retreat (existing wall-stop heuristic) AND target within `CorneredRange`, fall into the Grunt seek+wind-up→strike block verbatim; (5) **telegraphed shot** — in-band & `NextShotTick` ready → commit `AttackWindup` (dodge window); on elapse spawn via `GetSingleton()` + **`baked.WithPosition`** + `EnemyProjectile` data + `Region`/`RegionTag` = spitter's region, then `NextShotTick = TickUtil.NonZero(now+cd)`. **Soft-cap:** live `EnemyProjectile` ≥ MaxLiveProjectiles → skip firing, NO cooldown burn (EB-2 soft-fail). +- **The spit ghost:** NEW interpolated ownerless ghost, replicates ONLY stock `LocalTransform` (no `[GhostField]`; no `Health` → invisible to all `WithAll` loops). New-ghost recipe: duplicate a Husk/UpgradePickup, swap to `EnemyProjectileAuthoring`, no `GhostOwner`, `SourceNetworkId=-1`. +- **`EnemyProjectileMoveSystem`** (server, plain `SimulationSystemGroup`, `[UpdateAfter(EnemyAISystem)]`): integrate position, write `LastStep=Speed*dt`. +- **`EnemyProjectileDamageSystem`** (server, plain group, `[UpdateAfter(EnemyProjectileMoveSystem)]`): swept hit-test (segment = `cur - dir*LastStep`); targets players+structures; **region filter** (skip region mismatch); on hit `AppendToBuffer(DamageEvent{SourceNetworkId=-1, SourceTick=TickUtil.NonZero(now)})` + `DestroyEntity` at-most-once. DamageEvent drains the FOLLOWING tick in predicted `HealthApplyDamageSystem` (~16ms, REQUIRED — predicted append would double-apply on rollback); `SourceTick` makes dash-through-spit i-frame negation correct for free. + +### Swarmer (surround) +`SwarmerTag` marker, no AI branch (Grunt pass). Identity = baked `EnemyStats`: high MoveSpeed (~6.5), low MaxHealth (~8), short `AttackCooldownTicks`, low `AttackDamage`, LOW `EnemyTelegraph.WindupTicks` (fast frequent bites, not 6 mini-Grunts queuing a melee telegraph). **Cluster spawn:** when composition says a swarmer slot, the director's single `ecb` instantiates `PackSize` swarmers offset via new pure `EnemyAIMath.ClusterOffset(center,k,packSize,tightRadius)`. **Slot vs entity accounting:** `RemainingToSpawn`/`SpawnCounter` decrement by 1 SLOT per pack; `MaxAlive` counts ENTITIES (gate `aliveZone+packSize<=MaxAlive` else WAIT). + +### Mix bands — one shared pure function (extend `ZoneEnemyMath`) +Const bytes `KindGrunt=0,KindCharger=1,KindSpitter=2,KindSwarmer=3` (NO C# enum, no RNG, all integer → replay/save-stable). `MixBands { int GruntBase,ChargerBase,SpitterBase,SwarmerSlotBase, ChargerPerEpoch,SpitterPerEpoch,SwarmerSlotPerEpoch }` (baked weights). `WaveSlots(epoch,bands)` (≥1), `KindForSlot(epoch,slot,bands)` (deterministic), `PackSizeForSlot(epoch,slot,bands,basePack)`. Assign in fixed order (Grunts→Spitters→Chargers→Swarmer-slots last). **`IsChargerSlot` kept as a wrapper** `return KindForSlot(...)==KindCharger` (4 legacy tests stay green) + new `KindForSlot` assertions. Both directors index a **4-entry** per-Kind prefab buffer (bake/Play guard: assert exactly 4 entries, clamp+log). + +### WaveSystem (base siege) — fork 4a +Adopt `WaveSlots`/`KindForSlot` + a 4-entry prefab buffer; **add `WaveDirector.MaxAlive` (REQUIRED)** + `MixBands` authoring on `WaveDirector`. Parity test: `WaveSlots` reproduces `BaseCount+(wave-1)*CountPerWave` for the legacy band. + +### System ordering (linear, no cycle) +`EnemyAISystem` (unchanged `[UpdateAfter(PredictedSimulationSystemGroup)]`) → `EnemyProjectileMoveSystem` `[UpdateAfter(EnemyAISystem)]` → `EnemyProjectileDamageSystem` `[UpdateAfter(EnemyProjectileMoveSystem)]`. Strictly linear forward chain; nothing references the two new leaf types → no back-edge. **Play world-creation boot mandatory** (the only place a `ComponentSystemSorter` cycle throws; EditMode can't catch it). + +### MC-3 — hit-stop (no `Time.timeScale`) + impact +- **Player-dealt-hit camera punch is NET-NEW** (verified `CombatFeedbackSystem.cs:219` gates `PunchFov` behind `isLocalPlayer` → no punch when YOU hit). Add a magnitude-scaled `PunchFov` + `AddShake` on the enemy-`Health`-decrease edge (line ~213). Magnitude `m=saturate(delta/HitStopRefDamage)`; `kick=lerp(Min,Max,m)`. +- **NEW `FeelConfig` fields** (verified absent; only `HitStopFovKick`/`DurationMs` exist), all stamped in `ResetDefaults` (play-enter-reset): `HitStopFovKickMin/Max`, `HitStopMaxFrames`, `HitStopRefDamage`, `HitFlashColor`, `HitFlashDurationMs`, `HitStopFreezeEnabled(false)`. +- **True freeze deferred behind `HitStopFreezeEnabled=false`** (latch camera's published target + pause local anim-param advance 2–4 frames — NOT scaling the follow-lerp `k`, which the review proved causes camera rubber-band). +- **Spitter aim-line telegraph:** extend `UpdateEnemyDanger` with a `Kind==2` branch drawing a thin aim line/lane along the spitter facing out to PROJECTILE range during wind-up (spitter face-locks target during wind-up). **Dodgeability budget:** wind-up ≥ ~24 ticks (~400ms > interp delay), `ProjectileSpeed` slow enough that flight time at PreferredRange ≥ ~300ms — live-tunable. + +## Reuse ledger +**Reused unchanged:** `EnemyTag/Stats/AttackCooldown`, `KnockbackState`, `AttackWindup`, `Health`, `HitRadius`, `DamageEvent`, `RegionTag`, `IsLunging`; `EnemyAIMath.{SeekVelocity,InAttackRange,SlideVelocity,RingPosition,PickWeightedNearest×2}`; `EnemyAISystem.SweptMove`+knockback+Grunt-strike+IsLunging blocks; `HealthApplyDamageSystem` (drains spit DamageEvents + dash negation free); `RegionRelevancySystem`; `CombatFeedbackSystem` numbers/sparks/bars/death; `PrototypeCameraRig.PunchFov/AddShake`; `ZoneEnemyMath` (extended). Player `Projectile*` used as a swept-hit TEMPLATE only. +**New:** components `SpitterState,SwarmerTag,EnemyProjectile,SpitterProjectilePrefab,MixBands`; systems `EnemyProjectileMoveSystem,EnemyProjectileDamageSystem` + Spitter pass; math `BandVelocity,ClusterOffset,WaveSlots/KindForSlot/PackSizeForSlot`; authoring `SpitterAuthoring,SwarmerAuthoring,EnemyProjectileAuthoring` + `WaveDirector` MixBands/MaxAlive + `EnemyTelegraph.IsCharger→Kind`; client MC-3 bundle + Spitter aim-line + 7 `FeelConfig` fields. + +## Test plan +Pure-math: `WaveSlots`≥1, per-epoch ramps, `KindForSlot` determinism + composition counts, swarmer bucketing, `PackSizeForSlot`≥1, `IsChargerSlot` wrapper keeps 4 legacy assertions; **parity** test (`WaveSlots` reproduces legacy curve); `BandVelocity` (retreat/advance/in-band/Y-flat); `ClusterOffset` determinism. System: `EnemyProjectileMove` integrate+LastStep+range-expiry; `EnemyProjectileDamage` append+at-most-once + **tunnelling regression** (LastStep>radius still hits) + **region-filter** (Expedition spit doesn't damage Base target); Spitter brain (advance/retreat/in-band-commit-then-spawn-with-Region, soft-cap no-burn); cornered fallback; dash-through-spit negation; cluster spawn (PackSize spawned, 1 slot consumed, pack-over-MaxAlive defers); discriminator routing (no double-visit); 4-entry buffer guard. **Play-validation:** no sort-cycle at world-creation; no Burst ICE; Spitter end-to-end (holds range, aim-line telegraph, dodgeable+dash-negatable spit); region correctness (no cross-region see/damage); swarmer reads as a swarm + respects MaxAlive; mix ramp visibly shifts; MC-3 magnitude-scaled punch + predicted tick-rate UNAFFECTED (no timeScale); perf (live spits ≤24, stable frame time under base siege + expedition swarm). + +## Consequences +- **Deferred to a later slice:** DOTS `[MaterialProperty]` enemy hit-flash (ShaderGraph `_Flash*` + render-entity mapping); true freeze-frame hit-stop (gated off); Spitter in-band strafe (1b); player-shoots-spit (2b); Swarmer pack-size epoch ramp (field exposed, unwired). +- **Open (operator):** the combat fun-gate is a hands-on co-op playtest after build ("play with a friend and not want to stop"); the Slice 3 fun-gate still pending too. +- **Status:** reviewed + locked; build IN FLIGHT (see below). Full review (verdicts/blockers/forks) in run transcript `wf_eb115556-8cc`. + +## Build progress (in flight — 2026-06-22) + +**Done + compiling clean (368/368 EditMode still green, backward-compatible at epoch 1):** +- Leaf components: `SpitterState`(+baked `WindupTicks`), `SwarmerTag`, `EnemyProjectile`, `SpitterProjectilePrefab`, `MixBands` (`Simulation/Combat/`). +- Math: `EnemyAIMath.{BandVelocity, ClusterOffset}`; `ZoneEnemyMath` Kind consts + `WaveSlots/KindForSlot/PackSizeForSlot` (legacy `WaveSize`/`IsChargerSlot` kept intact for parity). +- Systems: `EnemyProjectileMoveSystem` + `EnemyProjectileDamageSystem` (plain server group, LastStep swept, region filter); `EnemyAISystem` Spitter pass + partition guards (`WithNone` Grunt / `WithNone` Charger) + `m_EnemyProjectiles` cache. +- Discriminator: `EnemyTelegraph.IsCharger→byte Kind`; `EnemyAuthoring` bakes Kind from sibling authoring. +- Authoring: `SpitterAuthoring`, `SwarmerAuthoring`, `EnemyProjectileAuthoring`; both director components (`ZoneEnemyDirector`, `WaveDirector`) + their authoring gained the mix/cluster fields + (Wave) mandatory `MaxAlive`. Base siege adopts `WaveSlots`/`KindForSlot`/cluster (fork 4a); defaults keep the size curve (≈+1 charger +1 spitter/wave) so the END-game stays bounded. + +**Remaining:** MC-3 client juice (FeelConfig fields + `CombatFeedbackSystem` player-hit camera punch + Spitter `Kind==2` aim-line); the additive EditMode tests (per the test plan above); prefab + subscene wiring (Spitter/Swarmer/EnemyProjectile ghosts via the new-ghost recipe, 4-entry director rosters, `SpitterProjectilePrefab` singleton, MixBands/MaxAlive on both directors); then the verify ladder + Play smoke + post-impl review + doc bookend + commit. **Resume from here if compacted.**