Game Scene Split up
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user