Files
Project-M/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs
T
kronic 3836e9c842 Phase 1 B3: enemies die with a corpse window instead of popping out of existence
Server: HealthApplyDamageSystem marks EnemyTag Dying{UntilTick} (TickUtil.
NonZero, ~54 ticks) on the lethal 0-crossing instead of instant destroy,
zeroes every live cue (replicated AttackWindup, LungeState + IsLunging bit,
KnockbackState), destroys on expiry; plain-world tests keep instant destroy
(no NetworkTime) so the suite's assertions stay meaningful.

Every consumer now ignores corpses (all confirmed entity-count/unfiltered by
the design review): EnemyAISystem all 4 passes, BossAISystem brain + summon
cap, RoomEnemyDirector room-clear + MaxAlive fit, WaveSystem cap + cleared,
CyclePhaseSystem breach wipe + DefendCleared, ThreatDirector timeout cull,
TurretFire targeting, CoreDamage drain, ProjectileDamage snapshot (corpses
are not shields), AbilityFire auto-aim candidates, debug cull + telemetry.
Wipe passes skip Dying to avoid cross-ECB double-destroys.

Client: EnemyAnimationDriveSystem finally drives the controller's IsDead
param (Health.Current<=0 is the replicated death read; corpse locomotion
zeroed); CombatFeedbackSystem moves the kill CRUNCH to the 0-crossing (the
prune-edge timing would land it ~1 s late) - the prune keeps a small
dissolve puff for corpses and the legacy full read for alive-vanish culls.

456/456 EditMode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:34:12 -07:00

208 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// Server-only per-ROOM enemy director — the Step-6 successor of the presence-keyed <c>ZoneEnemyDirectorSystem</c>.
/// While the run FSM has a room active (<see cref="RunInfo.Lifecycle"/> == InRoom) it seeds ONE wave per
/// <see cref="RunRuntime.RoomEpoch"/> (int-equality reseed) sized by <see cref="ZoneEnemyMath.WaveSlots"/> indexed
/// on the room's <see cref="RoomPlan.DifficultyEpoch"/> (deeper rooms + Elite/Boss types skew heavier — the
/// grounded MC-2 mix bands are reused verbatim), drip-spawned one SLOT per cadence at the deterministic ring
/// around <see cref="RegionMath.ExpeditionRoomOrigin"/>(base, ActiveSubSlot), under the same
/// <see cref="ZoneEnemyDirector.MaxAlive"/> "spawn-the-pack-only-if-it-fits-else-wait" relevancy guard. A
/// <see cref="RoomTypeId.Boss"/> room spawns ONE beefed boss instead (health × <see cref="Tuning.BossHealthMultiplier"/>,
/// scale × <see cref="Tuning.BossScaleMultiplier"/> — v1's boss is a scaled Charger). Every spawn keeps the full
/// stack — EnemyTag + RegionTag{Expedition} + <see cref="ZoneEnemyTag"/> — PLUS <see cref="RoomTag"/>{room} (the
/// teardown contract). Scale preserved via <c>baked.WithPosition</c>.
///
/// The room CLEAR edge surfaces ONLY through the replicated <see cref="ExpeditionObjective"/>.State == Cleared
/// (wave fully spawned AND zero alive, latched per seeded epoch) — written FIRST, ABOVE every early-return
/// (snapshot-above-early-return) so the HUD never freezes; RunDirectorSystem consumes it one-tick-late (Step 7).
/// The old CycleRuntime.ClearedThisEpoch write is gone (the C4 collapse), and the old base-siege Calm gate is
/// deliberately DROPPED — a home retaliation siege no longer freezes a live sortie (the DR-042 latent gap).
///
/// Ordering: <c>[UpdateAfter(RunDirectorSystem)]</c> ONLY — reads the freshly-advanced room state same-tick.
/// NO CyclePhase edge may ever return to the room chain (Play-only sort-cycle, invisible to EditMode).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(RunDirectorSystem))]
public partial struct RoomEnemyDirectorSystem : ISystem
{
EntityQuery m_ZoneEnemies;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<RunInfo>();
state.RequireForUpdate<RunRuntime>();
state.RequireForUpdate<ZoneEnemyDirector>();
m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly<ZoneEnemyTag>(), ComponentType.Exclude<Dying>()); // room clear + MaxAlive fit count LIVING only (B3) - corpses neither hold the room open nor crowd out spawns
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
var runEntity = SystemAPI.GetSingletonEntity<RunInfo>();
var info = SystemAPI.GetComponent<RunInfo>(runEntity);
var run = SystemAPI.GetComponent<RunRuntime>(runEntity);
bool roomActive = info.Lifecycle == RunLifecycle.InRoom;
var directorEntity = SystemAPI.GetSingletonEntity<ZoneEnemyDirector>();
var dir = SystemAPI.GetComponent<ZoneEnemyDirector>(directorEntity);
var zs = SystemAPI.GetComponent<ZoneEnemyState>(directorEntity);
int aliveZone = m_ZoneEnemies.CalculateEntityCount();
// REPLICATED objective summary FIRST, above every early-return (snapshot-above-early-return): the HUD
// readout must never freeze stale. Cleared latches only for a wave seeded FOR THIS RoomEpoch.
if (SystemAPI.HasComponent<ExpeditionObjective>(runEntity))
{
byte objState;
short objRemaining;
if (roomActive && (aliveZone > 0 || zs.RemainingToSpawn > 0))
{
objState = ExpeditionObjectiveState.Active;
objRemaining = (short)math.min(aliveZone + zs.RemainingToSpawn, short.MaxValue);
}
else if (roomActive && zs.SeededEpoch == run.RoomEpoch && zs.RemainingToSpawn == 0 && aliveZone == 0)
{
objState = ExpeditionObjectiveState.Cleared; // fully spawned + fully dead -> advance-ready
objRemaining = 0;
}
else
{
objState = ExpeditionObjectiveState.Idle;
objRemaining = 0;
}
SystemAPI.SetComponent(runEntity, new ExpeditionObjective { State = objState, Remaining = objRemaining });
}
if (!roomActive)
return;
var prefabs = SystemAPI.GetBuffer<ZoneEnemyPrefab>(directorEntity);
if (prefabs.Length == 0)
return;
// Single plan authority: the node RunDirector published — never re-derived here.
var map = RunMapMath.Generate(run.RunSeed);
var node = map.NodeAt(run.CurrentNodeId);
var plan = RoomLayoutMath.Plan(node, info.CurrentRoom, info.RoomCount);
byte room = (byte)(info.CurrentRoom & 0xFF);
bool bossRoom = plan.RoomType == RoomTypeId.Boss;
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 once per ROOM (its OWN counter, in SLOTS; a swarmer slot is one pack; a boss room is 1 slot).
if (zs.SeededEpoch != run.RoomEpoch)
{
zs.SeededEpoch = run.RoomEpoch;
zs.SpawnCounter = 0;
zs.RemainingToSpawn = bossRoom ? 1 : ZoneEnemyMath.WaveSlots(plan.DifficultyEpoch, bands);
zs.NextSpawnTick = TickUtil.NonZero(now + Tuning.RoomEntryGraceTicks); // landing grace — let the party orient
}
if (zs.RemainingToSpawn > 0)
{
bool dueNow = zs.NextSpawnTick == 0 || !new NetworkTick(zs.NextSpawnTick).IsNewerThan(serverTick);
if (dueNow)
{
int slot = (int)zs.SpawnCounter;
byte kind = bossRoom ? ZoneEnemyMath.KindCharger
: ZoneEnemyMath.KindForSlot(plan.DifficultyEpoch, slot, bands);
int packSize = !bossRoom && kind == ZoneEnemyMath.KindSwarmer
? ZoneEnemyMath.PackSizeForSlot(plan.DifficultyEpoch, slot, bands, dir.SwarmerPackSize) : 1;
// MaxAlive counts ENTITIES; spawn the whole pack only if it fits (else WAIT — keep the slot).
if (aliveZone + packSize <= math.max(1, dir.MaxAlive))
{
float3 baseCenter = new float3(0f, 1f, 0f);
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
baseCenter = BaseGridMath.PlotCenter(anchor);
float3 origin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot);
float3 center = bossRoom
? origin + new float3(0f, 0f, 12f) // the boss anchors the room center
: EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius);
center.y = origin.y;
int prefabIdx = kind;
if (prefabIdx >= prefabs.Length) prefabIdx = 0; // 4-entry buffer expected; clamp defensively
var prefab = prefabs[prefabIdx].Prefab;
var baked = state.EntityManager.GetComponentData<LocalTransform>(prefab);
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 = ZoneEnemySpawnUtil.Spawn(ecb, prefab, in baked, pos, RegionId.Expedition, room);
if (bossRoom)
{
// Boss = a scaled Charger given a real kit by BossAISystem. Scale the visual AND the
// hitbox/reach (so hits register on the big model + its reach matches), multiply Health,
// and tag BossState (server-only discriminator) so BossAISystem alone drives it.
var bxform = baked.WithPosition(pos);
bxform.Scale = baked.Scale * Tuning.BossScaleMultiplier;
ecb.SetComponent(enemy, bxform);
if (SystemAPI.HasComponent<Health>(prefab))
{
var hp = SystemAPI.GetComponent<Health>(prefab);
hp.Current *= Tuning.BossHealthMultiplier;
hp.Max *= Tuning.BossHealthMultiplier;
ecb.SetComponent(enemy, hp);
}
if (SystemAPI.HasComponent<HitRadius>(prefab))
{
var hr = SystemAPI.GetComponent<HitRadius>(prefab);
hr.Value *= Tuning.BossScaleMultiplier;
ecb.SetComponent(enemy, hr);
}
if (SystemAPI.HasComponent<EnemyStats>(prefab))
{
var es = SystemAPI.GetComponent<EnemyStats>(prefab);
es.AttackRange *= Tuning.BossScaleMultiplier;
ecb.SetComponent(enemy, es);
}
ecb.AddComponent(enemy, new BossState { Phase = 1 });
}
}
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));
}
}
}
SystemAPI.SetComponent(directorEntity, zs);
}
}
}