Files
Project-M/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs
T
kronic 3995af736c Boss becomes a real fight + readable-but-fair enemy threat
BossState (server-only) + BossAISystem: sole mover, seek -> telegraphed
radial slam (AttackWindup) -> phase-2 speed + swarmer summon. Excluded from
the EnemyAISystem Charger pass; knockback-immune at all 3 stamp sites;
arena-anchored spawn; scaled Health/HitRadius/AttackRange. Grunt windup now
commits in its last ~30% (whiffs out-of-range); Charger stagger roots; grunt
speed 4.2->5.2; swarmer telegraph synced. SweptMove + zone-enemy spawn-stamp
extracted to shared helpers. Zero new [GhostField].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:21:18 -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>());
}
[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);
}
}
}