193 lines
10 KiB
C#
193 lines
10 KiB
C#
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); // first slot this tick
|
||
}
|
||
|
||
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 // 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 = ecb.Instantiate(prefab);
|
||
var xform = baked.WithPosition(pos); // preserve the baked [GhostField] Scale
|
||
if (bossRoom)
|
||
xform.Scale = baked.Scale * Tuning.BossScaleMultiplier;
|
||
ecb.SetComponent(enemy, xform);
|
||
ecb.AddComponent(enemy, new RegionTag { Region = RegionId.Expedition });
|
||
ecb.AddComponent<ZoneEnemyTag>(enemy);
|
||
ecb.AddComponent(enemy, new RoomTag { Room = room });
|
||
if (bossRoom && SystemAPI.HasComponent<Health>(prefab))
|
||
{
|
||
var hp = SystemAPI.GetComponent<Health>(prefab);
|
||
hp.Current *= Tuning.BossHealthMultiplier;
|
||
hp.Max *= Tuning.BossHealthMultiplier;
|
||
ecb.SetComponent(enemy, hp);
|
||
}
|
||
}
|
||
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);
|
||
}
|
||
}
|
||
}
|