Game Scene Split up
This commit is contained in:
@@ -40,6 +40,13 @@ namespace ProjectM.Server
|
||||
{
|
||||
if (dmg.Length == 0)
|
||||
continue;
|
||||
// Dev god-mode: while enabled, this entity ignores ALL damage (server-authoritative, once per tick).
|
||||
if (SystemAPI.HasComponent<DebugGodMode>(entity) && SystemAPI.IsComponentEnabled<DebugGodMode>(entity))
|
||||
{
|
||||
dmg.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Respawn invulnerability: a freshly-recovered player ignores damage for a window.
|
||||
if (haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent<RespawnInvuln>(entity))
|
||||
|
||||
@@ -41,8 +41,8 @@ namespace ProjectM.Server
|
||||
if (!serverTick.IsValid)
|
||||
return;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
// M6 Aether Cycle: the base-defense wave only runs during the Defend phase.
|
||||
if (SystemAPI.TryGetSingleton<CycleState>(out var cycle) && cycle.Phase != CyclePhase.Defend)
|
||||
// Player-driven loop: the base-defense wave only spawns during a Siege.
|
||||
if (SystemAPI.TryGetSingleton<CycleState>(out var cycle) && cycle.Phase != CyclePhase.Siege)
|
||||
return;
|
||||
|
||||
var director = SystemAPI.GetSingleton<WaveDirector>();
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
#if UNITY_EDITOR
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// EDITOR-ONLY server receiver for <see cref="DebugCommandRequest"/> dev-tool RPCs (from the DebugOverlay or
|
||||
/// execute_code). Applies authoritative effects so the dev buttons exercise the REAL server paths and work
|
||||
/// over a live connection too: force/end sieges, grant resources/upgrades, teleport, god-mode, heal/kill,
|
||||
/// advance the goal. Sender-targeted ops resolve the player via SourceConnection -> NetworkId -> GhostOwner
|
||||
/// (the RegionTransitSystem pattern). Plain server SimulationSystemGroup (NOT the predicted loop). Reuses
|
||||
/// StorageMath / StatModifier / RegionMath + the wave/cycle singletons. The whole system is #if UNITY_EDITOR
|
||||
/// (stripped from builds); the wire TYPE (<see cref="DebugCommandRequest"/>) is unconditional so the RPC
|
||||
/// collection hash matches across peers. Non-Burst (managed-simple, editor-only) — perf is irrelevant.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
public partial struct DebugCommandReceiveSystem : ISystem
|
||||
{
|
||||
EntityQuery m_Husks;
|
||||
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
m_Husks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
|
||||
var builder = new EntityQueryBuilder(Allocator.Temp)
|
||||
.WithAll<DebugCommandRequest, ReceiveRpcCommandRequest>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(builder));
|
||||
}
|
||||
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
// Connection NetworkId -> player entity (for sender-targeted ops).
|
||||
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||
foreach (var (owner, e) in SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag>().WithEntityAccess())
|
||||
playerByConn[owner.ValueRO.NetworkId] = e;
|
||||
|
||||
bool haveCycle = SystemAPI.TryGetSingletonEntity<CycleState>(out var cycleEntity);
|
||||
|
||||
foreach (var (request, receive, reqEntity) in
|
||||
SystemAPI.Query<RefRO<DebugCommandRequest>, RefRO<ReceiveRpcCommandRequest>>().WithEntityAccess())
|
||||
{
|
||||
var cmd = request.ValueRO;
|
||||
|
||||
Entity sender = Entity.Null;
|
||||
var connEntity = receive.ValueRO.SourceConnection;
|
||||
if (SystemAPI.HasComponent<NetworkId>(connEntity))
|
||||
playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(connEntity).Value, out sender);
|
||||
|
||||
switch (cmd.Op)
|
||||
{
|
||||
case DebugOp.SpawnWave:
|
||||
if (haveCycle && SystemAPI.HasComponent<ThreatState>(cycleEntity))
|
||||
{
|
||||
var ts = SystemAPI.GetComponent<ThreatState>(cycleEntity);
|
||||
ts.PendingSiegeSize = math.max(1, cmd.ArgA);
|
||||
ts.ArmTick = 0; // fire as soon as CyclePhaseSystem sees it
|
||||
SystemAPI.SetComponent(cycleEntity, ts);
|
||||
}
|
||||
break;
|
||||
|
||||
case DebugOp.EndSiege:
|
||||
case DebugOp.SetCalm:
|
||||
CullHusks(ref ecb);
|
||||
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var we))
|
||||
{
|
||||
var w = SystemAPI.GetComponent<WaveState>(we);
|
||||
w.Phase = WavePhase.Lull;
|
||||
w.RemainingToSpawn = 0;
|
||||
SystemAPI.SetComponent(we, w);
|
||||
}
|
||||
if (haveCycle && SystemAPI.HasComponent<ThreatState>(cycleEntity))
|
||||
{
|
||||
var ts = SystemAPI.GetComponent<ThreatState>(cycleEntity);
|
||||
ts.PendingSiegeSize = 0;
|
||||
ts.ArmTick = 0;
|
||||
ts.SiegeStartTick = 0;
|
||||
SystemAPI.SetComponent(cycleEntity, ts);
|
||||
}
|
||||
if (cmd.Op == DebugOp.SetCalm && haveCycle)
|
||||
{
|
||||
var cs = SystemAPI.GetComponent<CycleState>(cycleEntity);
|
||||
cs.Phase = CyclePhase.Calm;
|
||||
cs.PhaseEndTick = 0;
|
||||
SystemAPI.SetComponent(cycleEntity, cs);
|
||||
}
|
||||
break;
|
||||
|
||||
case DebugOp.ClearEnemies:
|
||||
CullHusks(ref ecb);
|
||||
break;
|
||||
|
||||
case DebugOp.GrantResource:
|
||||
if (SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var ledgerE))
|
||||
{
|
||||
var ledger = SystemAPI.GetBuffer<StorageEntry>(ledgerE);
|
||||
StorageMath.Deposit(ledger, (ushort)cmd.ArgA, cmd.ArgB);
|
||||
}
|
||||
break;
|
||||
|
||||
case DebugOp.GrantUpgrade:
|
||||
if (sender != Entity.Null && SystemAPI.HasBuffer<StatModifier>(sender))
|
||||
GrowDamageModifier(SystemAPI.GetBuffer<StatModifier>(sender));
|
||||
break;
|
||||
|
||||
case DebugOp.Teleport:
|
||||
if (sender != Entity.Null && SystemAPI.HasComponent<RegionTag>(sender)
|
||||
&& SystemAPI.HasComponent<LocalTransform>(sender))
|
||||
{
|
||||
byte region = (byte)cmd.ArgA;
|
||||
SystemAPI.GetComponentRW<RegionTag>(sender).ValueRW.Region = region;
|
||||
float3 baseCenter = new float3(0f, 1f, 0f);
|
||||
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
||||
baseCenter = BaseGridMath.PlotCenter(anchor);
|
||||
SystemAPI.GetComponentRW<LocalTransform>(sender).ValueRW.Position =
|
||||
RegionMath.RegionOrigin(region, baseCenter);
|
||||
}
|
||||
break;
|
||||
|
||||
case DebugOp.ToggleGod:
|
||||
if (sender != Entity.Null && SystemAPI.HasComponent<DebugGodMode>(sender))
|
||||
SystemAPI.SetComponentEnabled<DebugGodMode>(sender, !SystemAPI.IsComponentEnabled<DebugGodMode>(sender));
|
||||
break;
|
||||
|
||||
case DebugOp.Heal:
|
||||
if (sender != Entity.Null && SystemAPI.HasComponent<Health>(sender))
|
||||
{
|
||||
var h = SystemAPI.GetComponent<Health>(sender);
|
||||
h.Current = SystemAPI.HasComponent<EffectiveCharacterStats>(sender)
|
||||
? SystemAPI.GetComponent<EffectiveCharacterStats>(sender).MaxHealth
|
||||
: h.Max;
|
||||
SystemAPI.SetComponent(sender, h);
|
||||
}
|
||||
break;
|
||||
|
||||
case DebugOp.KillPlayer:
|
||||
if (sender != Entity.Null && SystemAPI.HasComponent<Health>(sender))
|
||||
{
|
||||
var h = SystemAPI.GetComponent<Health>(sender);
|
||||
h.Current = 0f;
|
||||
SystemAPI.SetComponent(sender, h);
|
||||
}
|
||||
break;
|
||||
|
||||
case DebugOp.AdvanceGoal:
|
||||
if (haveCycle && SystemAPI.HasComponent<GoalProgress>(cycleEntity))
|
||||
{
|
||||
var g = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
|
||||
g.Charge += math.max(1, cmd.ArgA);
|
||||
SystemAPI.SetComponent(cycleEntity, g);
|
||||
}
|
||||
break;
|
||||
|
||||
case DebugOp.SetHeat:
|
||||
if (haveCycle && SystemAPI.HasComponent<ThreatState>(cycleEntity))
|
||||
{
|
||||
var ts = SystemAPI.GetComponent<ThreatState>(cycleEntity);
|
||||
ts.Heat = cmd.ArgA;
|
||||
SystemAPI.SetComponent(cycleEntity, ts);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
ecb.DestroyEntity(reqEntity);
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
playerByConn.Dispose();
|
||||
}
|
||||
|
||||
void CullHusks(ref EntityCommandBuffer ecb)
|
||||
{
|
||||
var husks = m_Husks.ToEntityArray(Allocator.Temp);
|
||||
for (int i = 0; i < husks.Length; i++)
|
||||
ecb.DestroyEntity(husks[i]);
|
||||
husks.Dispose();
|
||||
}
|
||||
|
||||
static void GrowDamageModifier(DynamicBuffer<StatModifier> mods)
|
||||
{
|
||||
const uint debugSourceId = 0x00DEB061u; // distinct debug sentinel (replace-by-SourceId keeps it bounded)
|
||||
for (int i = 0; i < mods.Length; i++)
|
||||
{
|
||||
if (mods[i].SourceId == debugSourceId && mods[i].Target == (byte)StatTarget.Damage)
|
||||
{
|
||||
var m = mods[i];
|
||||
m.Value += 0.25f;
|
||||
mods[i] = m;
|
||||
return;
|
||||
}
|
||||
}
|
||||
mods.Add(new StatModifier
|
||||
{
|
||||
Target = (byte)StatTarget.Damage,
|
||||
Op = (byte)ModOp.PercentAdd,
|
||||
Value = 0.25f,
|
||||
SourceId = debugSourceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8c34745ae117284439fa4487f586ad0e
|
||||
@@ -8,14 +8,14 @@ using Unity.Transforms;
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-only procedural expedition-field manager. Edge-triggered off the cycle phase (via the server-only
|
||||
/// <see cref="CycleRuntime.PrevPhase"/>): on ENTERING Expedition for a not-yet-seeded cycle it scatters
|
||||
/// <see cref="ResourceFieldSpawner.Count"/> resource-node ghosts (seeded by CycleNumber via
|
||||
/// <see cref="Unity.Mathematics.Random"/>) around the expedition region origin, each
|
||||
/// <see cref="RegionTag"/>{Expedition}; on LEAVING Expedition it destroys every node. Runs in the plain
|
||||
/// server SimulationSystemGroup <c>[UpdateAfter(CyclePhaseSystem)]</c> so the phase edge is observed the
|
||||
/// same tick. Server-authoritative; clients despawn nodes via GhostDespawnSystem. Per-cycle reproducible
|
||||
/// (the seed is the monotonic int CycleNumber, compared by equality — never tick math; never seed 0).
|
||||
/// Server-only procedural expedition-field manager. Re-keyed off PER-PLAYER PRESENCE (no global phase): it
|
||||
/// counts players whose server-only <see cref="RegionTag"/> is the Expedition region, and on the
|
||||
/// empty->occupied edge (a new sortie) bumps <see cref="CycleRuntime.ExpeditionEpoch"/> and scatters
|
||||
/// <see cref="ResourceFieldSpawner.Count"/> resource-node ghosts (seeded by the epoch) around the expedition
|
||||
/// origin, each RegionTag{Expedition}; on the occupied->empty edge (the LAST player left) it destroys every
|
||||
/// node. So the field lives as long as anyone is out there, not on a global timer. Plain server
|
||||
/// SimulationSystemGroup. Server-authoritative; clients despawn nodes via GhostDespawnSystem. Per-epoch
|
||||
/// reproducible (the seed is the monotonic int epoch, compared by equality — never tick math, never 0).
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
@@ -34,10 +34,21 @@ namespace ProjectM.Server
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
|
||||
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
|
||||
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
|
||||
var spawner = SystemAPI.GetSingleton<ResourceFieldSpawner>();
|
||||
|
||||
// Per-player presence: is anyone currently out in the expedition region?
|
||||
int expeditionPlayers = 0;
|
||||
foreach (var region in SystemAPI.Query<RefRO<RegionTag>>().WithAll<PlayerTag>())
|
||||
if (region.ValueRO.Region == RegionId.Expedition)
|
||||
expeditionPlayers++;
|
||||
bool occupied = expeditionPlayers > 0;
|
||||
bool wasOccupied = runtime.PrevExpeditionOccupied != 0;
|
||||
|
||||
// empty -> occupied: a new sortie begins; bump the epoch so the field reseeds fresh.
|
||||
if (occupied && !wasOccupied)
|
||||
runtime.ExpeditionEpoch += 1;
|
||||
|
||||
float3 baseCenter = new float3(0f, 1f, 0f);
|
||||
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
||||
baseCenter = BaseGridMath.PlotCenter(anchor);
|
||||
@@ -45,14 +56,14 @@ namespace ProjectM.Server
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
// SPAWN edge: entered Expedition for a cycle we have not seeded yet.
|
||||
if (cycle.Phase == CyclePhase.Expedition
|
||||
&& runtime.LastSpawnedCycle != cycle.CycleNumber
|
||||
// SPAWN: a player is out and this epoch has not been seeded yet.
|
||||
if (occupied
|
||||
&& runtime.LastSpawnedEpoch != runtime.ExpeditionEpoch
|
||||
&& spawner.Prefab != Entity.Null)
|
||||
{
|
||||
var baseXform = SystemAPI.GetComponent<LocalTransform>(spawner.Prefab);
|
||||
var prefabNode = SystemAPI.GetComponent<ResourceNode>(spawner.Prefab);
|
||||
var rng = new Random((uint)math.max(1, cycle.CycleNumber));
|
||||
var rng = new Random((uint)math.max(1, runtime.ExpeditionEpoch));
|
||||
int count = math.max(1, spawner.Count);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
@@ -69,17 +80,17 @@ namespace ProjectM.Server
|
||||
rn.ResourceId = (byte)(ResourceId.Aether + (byte)(i % 3));
|
||||
ecb.SetComponent(node, rn);
|
||||
}
|
||||
runtime.LastSpawnedCycle = cycle.CycleNumber;
|
||||
runtime.LastSpawnedEpoch = runtime.ExpeditionEpoch;
|
||||
}
|
||||
|
||||
// DESTROY edge: left Expedition — clear the whole field.
|
||||
if (runtime.PrevPhase == CyclePhase.Expedition && cycle.Phase != CyclePhase.Expedition)
|
||||
// DESTROY: the last player left the expedition — clear the whole field.
|
||||
if (wasOccupied && !occupied)
|
||||
{
|
||||
foreach (var (rn, e) in SystemAPI.Query<RefRO<ResourceNode>>().WithEntityAccess())
|
||||
ecb.DestroyEntity(e);
|
||||
}
|
||||
|
||||
runtime.PrevPhase = cycle.Phase;
|
||||
runtime.PrevExpeditionOccupied = (byte)(occupied ? 1 : 0);
|
||||
SystemAPI.SetComponent(cycleEntity, runtime);
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
|
||||
@@ -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