Core Game Loop Additions

This commit is contained in:
2026-06-03 22:41:27 -07:00
parent 79ff06a7df
commit 8e9b4412ce
70 changed files with 3084 additions and 2 deletions
@@ -41,6 +41,9 @@ 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)
return;
var director = SystemAPI.GetSingleton<WaveDirector>();
var directorEntity = SystemAPI.GetSingletonEntity<WaveDirector>();
@@ -84,6 +87,8 @@ namespace ProjectM.Server
var ecb = new EntityCommandBuffer(Allocator.Temp);
var husk = ecb.Instantiate(prefabs[prefabIdx].Prefab);
ecb.SetComponent(husk, LocalTransform.FromPosition(pos));
// Husks belong to the base region (hidden from expedition players by relevancy).
ecb.AddComponent(husk, new RegionTag { Region = RegionId.Base });
ecb.Playback(state.EntityManager);
ecb.Dispose();
@@ -53,6 +53,8 @@ namespace ProjectM.Server
var player = ecb.Instantiate(spawner.PlayerPrefab);
ecb.SetComponent(player, LocalTransform.FromPosition(center + PlayerSpawnMath.SpawnOffset(networkId.Value, spawner.SpawnRingRadius, spawner.RingSlots)));
ecb.SetComponent(player, new GhostOwner { NetworkId = networkId.Value });
// Tag the player into the base region (M6 region/relevancy split).
ecb.AddComponent(player, new RegionTag { Region = RegionId.Base });
// Auto-despawn the player when its owning connection is removed.
ecb.AppendToBuffer(connection, new LinkedEntityGroup { Value = player });
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c169eff521b1ff748b598a1b1a895196
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,88 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
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).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(CyclePhaseSystem))]
public partial struct ExpeditionFieldSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<ResourceFieldSpawner>();
state.RequireForUpdate<CycleState>();
}
[BurstCompile]
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>();
float3 baseCenter = new float3(0f, 1f, 0f);
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
baseCenter = BaseGridMath.PlotCenter(anchor);
float3 origin = RegionMath.RegionOrigin(RegionId.Expedition, baseCenter);
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
&& 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));
int count = math.max(1, spawner.Count);
for (int i = 0; i < count; i++)
{
var node = ecb.Instantiate(spawner.Prefab);
float ang = rng.NextFloat(0f, math.PI * 2f);
float rad = spawner.Radius * math.sqrt(rng.NextFloat(0f, 1f));
var xform = baseXform;
xform.Position = origin + new float3(math.cos(ang) * rad, 0f, math.sin(ang) * rad);
ecb.SetComponent(node, xform);
// Round-robin the resource type (Aether / Ore / Biomass) over the prefab's baked node.
var rn = prefabNode;
rn.ResourceId = (byte)(ResourceId.Aether + (byte)(i % 3));
ecb.SetComponent(node, rn);
}
runtime.LastSpawnedCycle = cycle.CycleNumber;
}
// DESTROY edge: left Expedition — clear the whole field.
if (runtime.PrevPhase == CyclePhase.Expedition && cycle.Phase != CyclePhase.Expedition)
{
foreach (var (rn, e) in SystemAPI.Query<RefRO<ResourceNode>>().WithEntityAccess())
ecb.DestroyEntity(e);
}
runtime.PrevPhase = cycle.Phase;
SystemAPI.SetComponent(cycleEntity, runtime);
ecb.Playback(state.EntityManager);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9267d7809e68ea54caa55378f33e67f6
@@ -0,0 +1,134 @@
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 resource harvest: sweeps each surviving projectile's this-tick travel segment against
/// resource-node ghosts and deposits <see cref="ResourceNode.HarvestPerHit"/> of the node's
/// <see cref="ResourceNode.ResourceId"/> into the GLOBAL resource ledger (the CycleDirector's
/// <see cref="StorageEntry"/> buffer, resolved via <see cref="ResourceLedger"/> — NEVER
/// GetSingleton&lt;StorageEntry&gt;, which would collide with the base storage container). Runs in the plain
/// server SimulationSystemGroup <c>[UpdateAfter(PredictedSimulationSystemGroup)]</c> — after
/// ProjectileDamageSystem has already consumed Health-target hits and range-expired projectiles, so this
/// only sees true survivors. The swept segment is reconstructed from <see cref="Projectile.LastStep"/>
/// (written by ProjectileMoveSystem in the fixed-step group), so it is tunnelling-safe WITHOUT depending on
/// this plain group's variable-frame DeltaTime. A node hit by two projectiles in one tick deposits twice
/// but is destroyed exactly once. Relies on the asserted ~1000-unit base/expedition coordinate gap so a
/// base projectile can never geometrically reach an expedition node.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(PredictedSimulationSystemGroup))]
public partial struct ResourceHarvestSystem : ISystem
{
const float k_ProjectileRadius = 0.2f;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<Projectile>();
state.RequireForUpdate<ResourceNode>();
state.RequireForUpdate<ResourceLedger>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var ledgerEntity = SystemAPI.GetSingletonEntity<ResourceLedger>();
var ledger = SystemAPI.GetBuffer<StorageEntry>(ledgerEntity);
// Snapshot all nodes once this tick.
var nodeEntities = new NativeList<Entity>(Allocator.Temp);
var nodePos = new NativeList<float2>(Allocator.Temp);
var nodeRadius = new NativeList<float>(Allocator.Temp);
var nodeRemaining = new NativeList<int>(Allocator.Temp);
var nodeResource = new NativeList<byte>(Allocator.Temp);
var nodePerHit = new NativeList<float>(Allocator.Temp);
foreach (var (xform, hr, node, e) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>, RefRO<ResourceNode>>().WithEntityAccess())
{
nodeEntities.Add(e);
nodePos.Add(xform.ValueRO.Position.xz);
nodeRadius.Add(hr.ValueRO.Value);
nodeRemaining.Add(node.ValueRO.Remaining);
nodeResource.Add(node.ValueRO.ResourceId);
nodePerHit.Add(node.ValueRO.HarvestPerHit);
}
var destroyed = new NativeArray<bool>(nodeEntities.Length, Allocator.Temp);
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (xform, proj, projEntity) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Projectile>>().WithEntityAccess())
{
float3 cur = xform.ValueRO.Position;
float2 segEnd = cur.xz;
float2 segStart = segEnd - proj.ValueRO.Direction * proj.ValueRO.LastStep;
float2 seg = segEnd - segStart;
float segLenSq = math.lengthsq(seg);
int bestIdx = -1;
float bestT = float.MaxValue;
for (int i = 0; i < nodeEntities.Length; i++)
{
if (destroyed[i]) continue;
float2 tp = nodePos[i];
float t = segLenSq > 1e-8f ? math.saturate(math.dot(tp - segStart, seg) / segLenSq) : 0f;
float2 closest = segStart + t * seg;
float hitDist = nodeRadius[i] + k_ProjectileRadius;
if (math.distancesq(tp, closest) <= hitDist * hitDist && t < bestT)
{
bestT = t;
bestIdx = i;
}
}
if (bestIdx < 0)
continue;
int amount = (int)nodePerHit[bestIdx];
StorageMath.Deposit(ledger, nodeResource[bestIdx], amount);
int rem = nodeRemaining[bestIdx] - amount;
nodeRemaining[bestIdx] = rem;
ecb.DestroyEntity(projEntity);
if (rem <= 0)
{
if (!destroyed[bestIdx])
{
destroyed[bestIdx] = true;
ecb.DestroyEntity(nodeEntities[bestIdx]);
}
}
else
{
// Persist the decremented Remaining (replicated GhostField) so depletion carries across ticks.
SystemAPI.SetComponent(nodeEntities[bestIdx], new ResourceNode
{
ResourceId = nodeResource[bestIdx],
Remaining = rem,
HarvestPerHit = nodePerHit[bestIdx],
});
}
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
destroyed.Dispose();
nodeEntities.Dispose();
nodePos.Dispose();
nodeRadius.Dispose();
nodeRemaining.Dispose();
nodeResource.Dispose();
nodePerHit.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1e1ce72e524297a48a08085c68ff908e
@@ -43,6 +43,8 @@ namespace ProjectM.Server
var xform = SystemAPI.GetComponent<LocalTransform>(spawner.Prefab);
xform.Position = position;
ecb.SetComponent(container, xform);
// M6: scope the shared storage to the base region for ghost relevancy.
ecb.AddComponent(container, new RegionTag { Region = RegionId.Base });
}
// One-shot: remove the spawner so RequireForUpdate fails and the system idles.
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ee7b17ccce78a094abf6f8008ecbef36
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,69 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// Server-only, one-shot spawner for the GLOBAL cycle-director ghost (mirrors SharedStorageSpawnSystem,
/// but MINUS the RegionTag — the director must stay global so GhostRelevancy keeps it relevant to every
/// region). On its first update it reads the baked <see cref="CycleDirectorSpawner"/> + NetworkTime,
/// instantiates the ghost, initializes <see cref="CycleState"/> (Expedition, cycle 1, PhaseEndTick =
/// now + <see cref="CyclePhase.ExpeditionTicks"/>), adds the server-only <see cref="CycleRuntime"/>, and
/// places it at the base center (preserving the prefab's baked LocalTransform scale — FromPosition would
/// reset the replicated Scale GhostField), then destroys the spawner so it idles.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
public partial struct CycleDirectorSpawnSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<CycleDirectorSpawner>();
state.RequireForUpdate<NetworkTime>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
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);
var ecb = new EntityCommandBuffer(Allocator.Temp);
if (spawner.Prefab != Entity.Null)
{
var director = ecb.Instantiate(spawner.Prefab);
// Place at the base center, preserving the prefab's baked scale/rotation.
var xform = SystemAPI.GetComponent<LocalTransform>(spawner.Prefab);
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
xform.Position = BaseGridMath.PlotCenter(anchor);
ecb.SetComponent(director, xform);
// Override the baked CycleState with the real start tick; add server-only bookkeeping.
ecb.SetComponent(director, new CycleState
{
Phase = CyclePhase.Expedition,
CycleNumber = 1,
PhaseEndTick = TickUtil.NonZero(now + CyclePhase.ExpeditionTicks),
});
ecb.AddComponent(director, new CycleRuntime { DefendStartWave = 0 });
}
// One-shot: remove the spawner so RequireForUpdate fails and the system idles.
ecb.DestroyEntity(spawnerEntity);
ecb.Playback(state.EntityManager);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bdfd3e27e8a3e924c93d6af962e0df05
@@ -0,0 +1,93 @@
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);
}
break;
}
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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c325c252dce9fba4a938d5c8db903042
@@ -0,0 +1,85 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// 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.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(CyclePhaseSystem))]
public partial struct ExpeditionGateSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<ExpeditionGate>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
// Snapshot gates once.
var gateFrom = new NativeList<byte>(Allocator.Temp);
var gateTo = new NativeList<byte>(Allocator.Temp);
var gateRadiusSq = new NativeList<float>(Allocator.Temp);
var gatePos = new NativeList<float2>(Allocator.Temp);
var gateArrival = new NativeList<float3>(Allocator.Temp);
foreach (var (gate, xform) in SystemAPI.Query<RefRO<ExpeditionGate>, RefRO<LocalTransform>>())
{
gateFrom.Add(gate.ValueRO.FromRegion);
gateTo.Add(gate.ValueRO.ToRegion);
gateRadiusSq.Add(gate.ValueRO.Radius * gate.ValueRO.Radius);
gatePos.Add(xform.ValueRO.Position.xz);
gateArrival.Add(gate.ValueRO.ArrivalPos);
}
bool returnedToBase = false;
foreach (var (region, xform) in
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>().WithAll<PlayerTag>())
{
byte r = region.ValueRO.Region;
float2 pp = xform.ValueRO.Position.xz;
for (int i = 0; i < gateFrom.Length; i++)
{
if (gateFrom[i] != r) continue;
if (math.distancesq(pp, gatePos[i]) > gateRadiusSq[i]) continue;
region.ValueRW.Region = gateTo[i];
xform.ValueRW.Position = gateArrival[i];
if (gateTo[i] == RegionId.Base)
returnedToBase = true;
break;
}
}
gateFrom.Dispose();
gateTo.Dispose();
gateRadiusSq.Dispose();
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))
{
var cs = SystemAPI.GetComponent<CycleState>(cycleEntity);
if (cs.Phase == CyclePhase.Expedition)
{
cs.PhaseEndTick = 1; // CyclePhaseSystem sees timedPhaseDue next tick -> Defend
SystemAPI.SetComponent(cycleEntity, cs);
}
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4292536f663eb5c4d92688f6c5bb0368
@@ -0,0 +1,69 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server-authoritative per-connection ghost relevancy for the base/expedition region split. Each tick:
/// build a connection -&gt; region map from the region-tagged player ghosts, then mark every region-tagged
/// ghost IRRELEVANT to each connection whose player is in a DIFFERENT region. Uses
/// <see cref="GhostRelevancyMode.SetIsIrrelevant"/> so untagged/global ghosts (e.g. the cycle director)
/// stay relevant to everyone for free — only cross-region ghosts are hidden. Runs in the
/// <see cref="GhostSimulationSystemGroup"/> (before GhostSendSystem reads the set). The set holds
/// (connection, ghost) pairs for the CURRENT simulated tick only, so it is cleared and repopulated every
/// update. A connection with no spawned player yet is absent from the map and simply sees everything.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(GhostSimulationSystemGroup))]
public partial struct RegionRelevancySystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<GhostRelevancy>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
// Map each in-game connection (by NetworkId) to its player's region.
var connRegion = new NativeHashMap<int, byte>(8, Allocator.Temp);
foreach (var (owner, region) in
SystemAPI.Query<RefRO<GhostOwner>, RefRO<RegionTag>>().WithAll<PlayerTag>())
{
connRegion[owner.ValueRO.NetworkId] = region.ValueRO.Region;
}
ref var relevancy = ref SystemAPI.GetSingletonRW<GhostRelevancy>().ValueRW;
relevancy.GhostRelevancyMode = GhostRelevancyMode.SetIsIrrelevant;
var set = relevancy.GhostRelevancySet;
set.Clear();
if (!connRegion.IsEmpty)
{
var conns = connRegion.GetKeyValueArrays(Allocator.Temp);
foreach (var (ghost, region) in
SystemAPI.Query<RefRO<GhostInstance>, RefRO<RegionTag>>())
{
int ghostId = ghost.ValueRO.ghostId;
if (ghostId == 0)
continue; // ghost id not assigned yet this tick
byte ghostRegion = region.ValueRO.Region;
for (int i = 0; i < conns.Keys.Length; i++)
{
if (conns.Values[i] != ghostRegion)
set.Add(new RelevantGhostForConnection { Connection = conns.Keys[i], Ghost = ghostId }, 1);
}
}
conns.Dispose();
}
connRegion.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: db2f33e6ce7ce9346b3dfbcd5e562ed6
@@ -0,0 +1,71 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// Server-authoritative handler for <see cref="RegionTransitRequest"/> RPCs. Resolves the sender's player
/// (via the source connection's <see cref="NetworkId"/> -&gt; <see cref="GhostOwner"/>), flips its
/// <see cref="RegionTag"/> to the requested region, and teleports it to that region's origin
/// (<see cref="RegionMath.RegionOrigin"/>, centered on the base via <see cref="BaseGridMath.PlotCenter"/>).
/// Runs in the default server SimulationSystemGroup (NOT the prediction loop) so the transit applies once;
/// the next snapshot reconciles the owner-predicted client and <see cref="RegionRelevancySystem"/> re-scopes
/// which region's ghosts the connection receives. Mirrors the <see cref="StorageOpReceiveSystem"/> RPC shape.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
public partial struct RegionTransitSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<BaseAnchor>();
var builder = new EntityQueryBuilder(Allocator.Temp)
.WithAll<RegionTransitRequest, ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(builder));
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var baseCenter = BaseGridMath.PlotCenter(SystemAPI.GetSingleton<BaseAnchor>());
// Map connection NetworkId -> player entity.
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
foreach (var (owner, entity) in
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, RegionTag>().WithEntityAccess())
{
playerByConn[owner.ValueRO.NetworkId] = entity;
}
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (request, receive, requestEntity) in
SystemAPI.Query<RefRO<RegionTransitRequest>, RefRO<ReceiveRpcCommandRequest>>().WithEntityAccess())
{
var connEntity = receive.ValueRO.SourceConnection;
if (SystemAPI.HasComponent<NetworkId>(connEntity))
{
int connId = SystemAPI.GetComponent<NetworkId>(connEntity).Value;
if (playerByConn.TryGetValue(connId, out var player))
{
byte target = request.ValueRO.TargetRegion;
SystemAPI.GetComponentRW<RegionTag>(player).ValueRW.Region = target;
SystemAPI.GetComponentRW<LocalTransform>(player).ValueRW.Position =
RegionMath.RegionOrigin(target, baseCenter);
}
}
ecb.DestroyEntity(requestEntity);
}
ecb.Playback(state.EntityManager);
playerByConn.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 44d6ce89f189d984c83cd213d86d4b02