Game Scene Split up

This commit is contained in:
2026-06-04 13:45:46 -07:00
parent dbc4a92a86
commit 16b01bec38
49 changed files with 11976 additions and 188 deletions
@@ -33,7 +33,6 @@ namespace ProjectM.Server
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
var spawnerEntity = SystemAPI.GetSingletonEntity<CycleDirectorSpawner>();
var spawner = SystemAPI.GetComponent<CycleDirectorSpawner>(spawnerEntity);
@@ -50,14 +49,15 @@ namespace ProjectM.Server
xform.Position = BaseGridMath.PlotCenter(anchor);
ecb.SetComponent(director, xform);
// Override the baked CycleState with the real start tick; add server-only bookkeeping.
// Boot the run-state in Calm (the persistent default) — no timer; ThreatDirector arms sieges.
ecb.SetComponent(director, new CycleState
{
Phase = CyclePhase.Expedition,
Phase = CyclePhase.Calm,
CycleNumber = 1,
PhaseEndTick = TickUtil.NonZero(now + CyclePhase.ExpeditionTicks),
PhaseEndTick = 0u,
});
ecb.AddComponent(director, new CycleRuntime { DefendStartWave = 0 });
ecb.AddComponent(director, new ThreatState());
}
// One-shot: remove the spawner so RequireForUpdate fails and the system idles.
@@ -1,17 +1,22 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
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.
/// Server-authoritative macro-loop director for the PLAYER-DRIVEN loop. The base sits in <c>Calm</c>
/// (persistent, unhurried — build/prep at your pace, no countdown) until the <see cref="ThreatState"/> arms a
/// siege, then flips to <c>Siege</c> (the base-defense wave) and back to <c>Calm</c> when the wave is cleared.
/// There is no global "Expedition" phase — being out on an expedition is per-player presence (server-only
/// <see cref="RegionTag"/>), read client-side by the HUD, so one global byte never has to represent
/// "player A out / player B home." Maintains the replicated <see cref="CycleState"/> singleton and gates
/// <see cref="WaveSystem"/> (waves spawn only during Siege). Runs in the plain server SimulationSystemGroup
/// before WaveSystem. All timing is wrap-safe NetworkTick math (<see cref="ProjectM.Simulation.TickUtil.NonZero"/>
/// + <see cref="Unity.NetCode.NetworkTick.IsNewerThan"/>), never raw uint compares. Lives on the
/// runtime-spawned CycleDirector ghost. Supersedes the forced timed Expedition→Defend→Build cycle.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
@@ -38,48 +43,64 @@ namespace ProjectM.Server
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)
if (cycle.Phase == CyclePhase.Calm)
{
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;
// Default calm: no pending siege => no countdown.
cycle.PhaseEndTick = 0;
case CyclePhase.Defend:
if (DefendCleared(ref state, runtime.DefendStartWave))
if (SystemAPI.HasComponent<ThreatState>(cycleEntity))
{
var threat = SystemAPI.GetComponent<ThreatState>(cycleEntity);
if (threat.PendingSiegeSize > 0)
{
cycle.Phase = CyclePhase.Build;
cycle.PhaseEndTick = TickUtil.NonZero(now + CyclePhase.BuildTicks);
}
break;
// Telegraph: mirror the arm tick into the replicated PhaseEndTick so the HUD can show an
// "incursion in Ns" countdown (reuses the existing HUD countdown path) while it arms.
cycle.PhaseEndTick = threat.ArmTick;
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))
bool armed = threat.ArmTick == 0
|| !new NetworkTick(threat.ArmTick).IsNewerThan(serverTick);
if (armed && SystemAPI.TryGetSingletonEntity<WaveState>(out var waveEntity))
{
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
goal.Charge += 1;
SystemAPI.SetComponent(cycleEntity, goal);
// ---- Calm -> Siege: seed WaveSystem's own Spawning entry atomically. Writing
// Phase=Spawning bypasses its Lull escalation recompute (WaveSystem only recomputes
// RemainingToSpawn while Phase==Lull), so the siege spawns EXACTLY the director-chosen
// size and WaveSystem stays the sole WaveState writer thereafter. ----
var w = SystemAPI.GetComponent<WaveState>(waveEntity);
runtime.DefendStartWave = w.WaveNumber; // capture BEFORE the bump (DefendCleared tests > this)
w.WaveNumber += 1;
w.Phase = WavePhase.Spawning;
w.RemainingToSpawn = math.max(1, threat.PendingSiegeSize);
w.NextActionTick = TickUtil.NonZero(now); // spawn the first Husk this tick
SystemAPI.SetComponent(waveEntity, w);
cycle.Phase = CyclePhase.Siege;
cycle.PhaseEndTick = 0; // Siege is wave-driven, not timed.
threat.PendingSiegeSize = 0; // consume once
threat.ArmTick = 0;
SystemAPI.SetComponent(cycleEntity, threat);
}
}
break;
}
}
else if (cycle.Phase == CyclePhase.Siege)
{
if (DefendCleared(ref state, runtime.DefendStartWave))
{
cycle.Phase = CyclePhase.Calm;
cycle.PhaseEndTick = 0;
// Long-arc goal: +1 per siege survived (single writer; was +1 per completed timed cycle).
if (SystemAPI.HasComponent<GoalProgress>(cycleEntity))
{
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
goal.Charge += 1;
SystemAPI.SetComponent(cycleEntity, goal);
}
}
}
// Surface the live wave number on the replicated CycleState for the HUD (single writer).
@@ -90,8 +111,8 @@ namespace ProjectM.Server
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.
// The Siege 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))
@@ -11,15 +11,16 @@ namespace ProjectM.Server
/// Server-only walk-in gate transit: a player who walks within a gate's radius (and whose region matches the
/// gate's <see cref="ExpeditionGate.FromRegion"/>) is transited to the gate's ToRegion at its ArrivalPos
/// (RegionTag flipped + LocalTransform teleported — GhostRelevancy re-scopes their ghosts, as in
/// <c>RegionTransitSystem</c>). Returning to the BASE during the Expedition phase expires the Expedition
/// timer so Defend starts early ("timer cap + early return"). Plain server SimulationSystemGroup
/// <c>[UpdateAfter(CyclePhaseSystem)]</c>. Arrival points are offset from the destination gate so a transited
/// player does not immediately re-trigger.
/// <c>RegionTransitSystem</c>). Returning to BASE signals the ThreatDirector (a completed expedition can draw a
/// retaliation siege) by incrementing <see cref="ProjectM.Simulation.ThreatState.PendingReturns"/>. Plain server
/// SimulationSystemGroup, ordered BEFORE CyclePhaseSystem (Gate -> ThreatDirector -> RunState) so the return is
/// consumed the same tick. Arrival points are offset from the destination gate so a transited player does not
/// immediately re-trigger.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(CyclePhaseSystem))]
[UpdateBefore(typeof(CyclePhaseSystem))]
public partial struct ExpeditionGateSystem : ISystem
{
[BurstCompile]
@@ -70,15 +71,14 @@ namespace ProjectM.Server
gatePos.Dispose();
gateArrival.Dispose();
// Early return: a player came back to base mid-Expedition -> expire the Expedition timer (-> Defend).
if (returnedToBase && SystemAPI.TryGetSingletonEntity<CycleState>(out var cycleEntity))
// A player returned to base from an expedition -> signal the ThreatDirector (it sizes/arms any
// retaliation siege). The gate teleports the returner out of its radius, so this fires once per return.
if (returnedToBase && SystemAPI.TryGetSingletonEntity<ThreatState>(out var threatEntity))
{
var cs = SystemAPI.GetComponent<CycleState>(cycleEntity);
if (cs.Phase == CyclePhase.Expedition)
{
cs.PhaseEndTick = 1; // CyclePhaseSystem sees timedPhaseDue next tick -> Defend
SystemAPI.SetComponent(cycleEntity, cs);
}
var threat = SystemAPI.GetComponent<ThreatState>(threatEntity);
threat.PendingReturns += 1;
threat.ExpeditionsCompleted += 1;
SystemAPI.SetComponent(threatEntity, threat);
}
}
}
@@ -0,0 +1,112 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server-only composite ThreatDirector — the data-driven base-attack SCHEDULER. It owns the decision of WHEN
/// and HOW BIG a siege is; <see cref="CyclePhaseSystem"/> owns the Calm↔Siege transition. The single documented
/// hand-off is <see cref="ThreatState.PendingSiegeSize"/> (this system sets it; CyclePhaseSystem consumes it).
/// This slice wires ONE source — POST-EXPEDITION retaliation: a player returning to base (counted as
/// <see cref="ThreatState.PendingReturns"/> by <see cref="ExpeditionGateSystem"/>) arms a siege of
/// <see cref="ThreatConfig.SizeBase"/> Husks after a <see cref="ThreatConfig.PostExpeditionDelayTicks"/>
/// telegraph. The Heat/Schedule sources are reserved (config baked-but-inert) so they drop in additively with
/// no re-bake. It also enforces a BOUNDED siege lifetime (<see cref="ThreatConfig.SiegeTimeoutTicks"/>): an
/// unattended siege (e.g. an empty base) auto-collapses so the loop can never soft-lock. Runs in the plain
/// server SimulationSystemGroup, ordered Gate -> ThreatDirector -> RunState(CyclePhaseSystem) -> Wave so a
/// return is consumed the same tick. All timing is wrap-safe NetworkTick math (TickUtil.NonZero +
/// NetworkTick.IsNewerThan / TicksSince), never raw uint.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(ExpeditionGateSystem))]
[UpdateBefore(typeof(CyclePhaseSystem))]
public partial struct ThreatDirectorSystem : ISystem
{
EntityQuery m_Husks;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<CycleState>();
state.RequireForUpdate<ThreatState>();
state.RequireForUpdate<ThreatConfig>();
m_Husks = 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 threat = SystemAPI.GetComponent<ThreatState>(cycleEntity);
var config = SystemAPI.GetComponent<ThreatConfig>(cycleEntity);
// ---- SOURCE: post-expedition retaliation. A returning player arms ONE siege (simultaneous returns
// collapse to a single arming — extending the de-dup the gate's one-increment-per-return starts). ----
if (config.PostExpeditionEnabled != 0 && threat.PendingReturns > 0)
{
if (cycle.Phase == CyclePhase.Calm && threat.PendingSiegeSize == 0)
{
int size = config.SizeBase + config.SizePerExpeditionResource * 0; // haul-scaling deferred (field baked)
threat.PendingSiegeSize = math.max(1, size);
threat.ArmTick = TickUtil.NonZero(now + config.PostExpeditionDelayTicks);
}
threat.PendingReturns = 0; // consume regardless so returns can't pile up
}
// ---- BOUNDED RESOLUTION: a Siege can't drag forever. Record its start; after SiegeTimeoutTicks cull
// the remaining Husks + stop spawning so CyclePhaseSystem's DefendCleared returns the base to Calm. ----
if (cycle.Phase == CyclePhase.Siege)
{
if (threat.SiegeStartTick == 0)
{
threat.SiegeStartTick = TickUtil.NonZero(now);
}
else if (config.SiegeTimeoutTicks > 0)
{
var start = new NetworkTick(threat.SiegeStartTick);
if (start.IsValid && serverTick.TicksSince(start) > (int)config.SiegeTimeoutTicks)
{
// Collapse the siege: cull every remaining Husk (a cached tag query, never RefRO on a tag).
var husks = m_Husks.ToEntityArray(Allocator.Temp);
if (husks.Length > 0)
{
var ecb = new EntityCommandBuffer(Allocator.Temp);
for (int i = 0; i < husks.Length; i++)
ecb.DestroyEntity(husks[i]);
ecb.Playback(state.EntityManager);
ecb.Dispose();
}
husks.Dispose();
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var waveEntity))
{
var w = SystemAPI.GetComponent<WaveState>(waveEntity);
w.RemainingToSpawn = 0;
SystemAPI.SetComponent(waveEntity, w);
}
threat.SiegeStartTick = 0;
}
}
}
else
{
threat.SiegeStartTick = 0; // not under siege
}
SystemAPI.SetComponent(cycleEntity, threat);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3cd1beb28c2b1f84398722a95d1ee784