Files
Project-M/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs
T
2026-06-04 11:35:57 -07:00

105 lines
4.4 KiB
C#

using ProjectM.Simulation;
using Unity.Burst;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server-authoritative macro-loop director for "The Aether Cycle": Expedition (timed) -> Defend
/// (wave-driven) -> Build (timed) -> next cycle. Maintains the <see cref="CycleState"/> singleton and gates
/// <see cref="WaveSystem"/> so the base-defense wave only spawns during Defend. Runs in the plain server
/// SimulationSystemGroup (NOT prediction) before <see cref="WaveSystem"/>. All timing is wrap-safe
/// NetworkTick math (<see cref="TickUtil.NonZero"/> + <see cref="Unity.NetCode.NetworkTick.IsNewerThan"/>),
/// never raw uint compares. The CycleState/CycleRuntime live on the runtime-spawned CycleDirector ghost.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateBefore(typeof(WaveSystem))]
public partial struct CyclePhaseSystem : ISystem
{
EntityQuery m_AliveHusks;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<CycleState>();
m_AliveHusks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
bool timedPhaseDue =
cycle.PhaseEndTick != 0 && !new NetworkTick(cycle.PhaseEndTick).IsNewerThan(serverTick);
switch (cycle.Phase)
{
case CyclePhase.Expedition:
if (timedPhaseDue)
{
cycle.Phase = CyclePhase.Defend;
cycle.PhaseEndTick = 0; // Defend is wave-driven, not timed.
runtime.DefendStartWave =
SystemAPI.TryGetSingleton<WaveState>(out var w) ? w.WaveNumber : 0;
}
break;
case CyclePhase.Defend:
if (DefendCleared(ref state, runtime.DefendStartWave))
{
cycle.Phase = CyclePhase.Build;
cycle.PhaseEndTick = TickUtil.NonZero(now + CyclePhase.BuildTicks);
}
break;
case CyclePhase.Build:
if (timedPhaseDue)
{
cycle.Phase = CyclePhase.Expedition;
cycle.CycleNumber += 1;
cycle.PhaseEndTick = TickUtil.NonZero(now + CyclePhase.ExpeditionTicks);
// Long-arc goal: one charge per completed cycle (single writer).
if (SystemAPI.HasComponent<GoalProgress>(cycleEntity))
{
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
goal.Charge += 1;
SystemAPI.SetComponent(cycleEntity, goal);
}
}
break;
}
// Surface the live wave number on the replicated CycleState for the HUD (single writer).
if (SystemAPI.TryGetSingleton<WaveState>(out var waveSync))
cycle.WaveNumber = waveSync.WaveNumber;
SystemAPI.SetComponent(cycleEntity, cycle);
SystemAPI.SetComponent(cycleEntity, runtime);
}
// The Defend wave has run for this phase (WaveNumber advanced past the captured start), is fully
// spawned, and no Husks remain alive.
bool DefendCleared(ref SystemState state, int defendStartWave)
{
if (!SystemAPI.TryGetSingleton<WaveState>(out var wave))
return false;
return wave.WaveNumber > defendStartWave
&& wave.RemainingToSpawn == 0
&& m_AliveHusks.CalculateEntityCount() == 0;
}
}
}