Core Game Loop Additions
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c81ee1ba775463a4d940bb397a01f4e6
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,39 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Authoring for the baked <see cref="ResourceFieldSpawner"/> singleton (mirrors StorageSpawnerAuthoring).
|
||||
/// Place once in the gameplay subscene and assign the resource-node ghost prefab; ExpeditionFieldSystem
|
||||
/// scatters the field each Expedition. Carries no transform.
|
||||
/// </summary>
|
||||
public class ResourceFieldSpawnerAuthoring : MonoBehaviour
|
||||
{
|
||||
[Tooltip("Resource-node ghost prefab. Must carry ResourceNodeAuthoring + a GhostAuthoringComponent (ownerless, interpolated).")]
|
||||
public GameObject NodePrefab;
|
||||
|
||||
[Tooltip("Number of nodes per expedition.")]
|
||||
[Min(1)] public int Count = 8;
|
||||
|
||||
[Tooltip("Scatter radius (world units) around the expedition origin.")]
|
||||
[Min(1f)] public float Radius = 12f;
|
||||
|
||||
private class ResourceFieldSpawnerBaker : Baker<ResourceFieldSpawnerAuthoring>
|
||||
{
|
||||
public override void Bake(ResourceFieldSpawnerAuthoring authoring)
|
||||
{
|
||||
var entity = GetEntity(authoring, TransformUsageFlags.None);
|
||||
AddComponent(entity, new ResourceFieldSpawner
|
||||
{
|
||||
Prefab = authoring.NodePrefab != null
|
||||
? GetEntity(authoring.NodePrefab, TransformUsageFlags.Dynamic)
|
||||
: Entity.Null,
|
||||
Count = authoring.Count,
|
||||
Radius = authoring.Radius,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e9e840f8b95266140b0ac5bd4e81391b
|
||||
@@ -0,0 +1,45 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Authoring for a resource-node ghost prefab (ownerless interpolated — duplicate from UpgradePickup.prefab
|
||||
/// so the GhostAuthoringComponent comes free). Bakes <see cref="ResourceNode"/> + <see cref="HitRadius"/>
|
||||
/// (reused for the harvest hit test) + <see cref="RegionTag"/>{Expedition} so GhostRelevancy scopes the node
|
||||
/// to expedition players. The field spawner overrides ResourceId (round-robin) and Position per instance.
|
||||
/// </summary>
|
||||
public class ResourceNodeAuthoring : MonoBehaviour
|
||||
{
|
||||
public enum ResourceKind : byte { Aether = 1, Ore = 2, Biomass = 3 }
|
||||
|
||||
[Tooltip("Default resource type (the spawner round-robins this per node).")]
|
||||
public ResourceKind Kind = ResourceKind.Aether;
|
||||
|
||||
[Tooltip("Total resource units in the node before it depletes.")]
|
||||
[Min(1)] public int Amount = 30;
|
||||
|
||||
[Tooltip("Units harvested per projectile hit.")]
|
||||
[Min(1f)] public float HarvestPerHit = 5f;
|
||||
|
||||
[Tooltip("Hit radius (world units) for the harvest sweep.")]
|
||||
[Min(0f)] public float HitRadius = 1.2f;
|
||||
|
||||
private class ResourceNodeBaker : Baker<ResourceNodeAuthoring>
|
||||
{
|
||||
public override void Bake(ResourceNodeAuthoring authoring)
|
||||
{
|
||||
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
|
||||
AddComponent(entity, new ResourceNode
|
||||
{
|
||||
ResourceId = (byte)authoring.Kind,
|
||||
Remaining = authoring.Amount,
|
||||
HarvestPerHit = authoring.HarvestPerHit,
|
||||
});
|
||||
AddComponent(entity, new HitRadius { Value = authoring.HitRadius });
|
||||
AddComponent(entity, new RegionTag { Region = RegionId.Expedition });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 174563c586d9a0f4bb84cca191f4a1f0
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4b2e77367c16a9a41943145e582954d1
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,34 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Authoring for the GLOBAL cycle-director ghost prefab: an ownerless INTERPOLATED ghost (the
|
||||
/// GhostAuthoringComponent is inherited when this prefab is duplicated from UpgradePickup.prefab) that
|
||||
/// carries the replicated macro-loop state (<see cref="CycleState"/>) and the shared resource ledger
|
||||
/// (a <see cref="StorageEntry"/> buffer marked by <see cref="ResourceLedger"/>). It is GLOBAL — it must
|
||||
/// carry NO <see cref="RegionTag"/> so GhostRelevancy keeps it relevant to every connection regardless of
|
||||
/// region. The server CycleDirectorSpawnSystem overrides the baked CycleState at spawn (real PhaseEndTick)
|
||||
/// and adds the server-only CycleRuntime.
|
||||
/// </summary>
|
||||
public class CycleDirectorAuthoring : MonoBehaviour
|
||||
{
|
||||
private class CycleDirectorBaker : Baker<CycleDirectorAuthoring>
|
||||
{
|
||||
public override void Bake(CycleDirectorAuthoring authoring)
|
||||
{
|
||||
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
|
||||
AddComponent(entity, new CycleState
|
||||
{
|
||||
Phase = CyclePhase.Expedition,
|
||||
CycleNumber = 1,
|
||||
PhaseEndTick = 0u,
|
||||
});
|
||||
AddComponent<ResourceLedger>(entity);
|
||||
AddBuffer<StorageEntry>(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 843fd0d567f48ad46840dcce0ce84bbc
|
||||
@@ -0,0 +1,31 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Authoring for the baked <see cref="CycleDirectorSpawner"/> singleton (mirrors StorageSpawnerAuthoring).
|
||||
/// Place once in the gameplay subscene; the server-only CycleDirectorSpawnSystem reads it, instantiates the
|
||||
/// global cycle-director ghost, then destroys the singleton so it fires exactly once. Carries no transform.
|
||||
/// </summary>
|
||||
public class CycleDirectorSpawnerAuthoring : MonoBehaviour
|
||||
{
|
||||
[Tooltip("Cycle-director ghost prefab. Must carry CycleDirectorAuthoring + a GhostAuthoringComponent (ownerless, interpolated).")]
|
||||
public GameObject DirectorPrefab;
|
||||
|
||||
private class CycleDirectorSpawnerBaker : Baker<CycleDirectorSpawnerAuthoring>
|
||||
{
|
||||
public override void Bake(CycleDirectorSpawnerAuthoring authoring)
|
||||
{
|
||||
var entity = GetEntity(authoring, TransformUsageFlags.None);
|
||||
AddComponent(entity, new CycleDirectorSpawner
|
||||
{
|
||||
Prefab = authoring.DirectorPrefab != null
|
||||
? GetEntity(authoring.DirectorPrefab, TransformUsageFlags.Dynamic)
|
||||
: Entity.Null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1f4d2cb1e17d6a1429525674969dd3f0
|
||||
@@ -0,0 +1,45 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Authoring for a walk-in <see cref="ExpeditionGate"/>. Place on a visible gate object in the gameplay
|
||||
/// subscene; baked into both worlds at the gate's position (the server reads its LocalTransform for the
|
||||
/// overlap test, the client renders the mesh). Set From/To regions + the arrival point in the destination
|
||||
/// region (offset from that region's gate so the player doesn't immediately re-trigger).
|
||||
/// </summary>
|
||||
public class ExpeditionGateAuthoring : MonoBehaviour
|
||||
{
|
||||
public enum Region : byte { Base = 0, Expedition = 1 }
|
||||
|
||||
[Tooltip("Region a player must be in for this gate to act on them.")]
|
||||
public Region From = Region.Base;
|
||||
|
||||
[Tooltip("Region the player is transited to.")]
|
||||
public Region To = Region.Expedition;
|
||||
|
||||
[Min(0.5f)] public float Radius = 2.5f;
|
||||
|
||||
[Tooltip("Where the player arrives in the destination region (offset from that region's gate).")]
|
||||
public Vector3 ArrivalPos = new Vector3(1000f, 1f, 0f);
|
||||
|
||||
private class ExpeditionGateBaker : Baker<ExpeditionGateAuthoring>
|
||||
{
|
||||
public override void Bake(ExpeditionGateAuthoring authoring)
|
||||
{
|
||||
// Dynamic so the baked entity carries a LocalTransform the server can read for the overlap test.
|
||||
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
|
||||
AddComponent(entity, new ExpeditionGate
|
||||
{
|
||||
FromRegion = (byte)authoring.From,
|
||||
ToRegion = (byte)authoring.To,
|
||||
Radius = authoring.Radius,
|
||||
ArrivalPos = new float3(authoring.ArrivalPos.x, authoring.ArrivalPos.y, authoring.ArrivalPos.z),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 22f744b59ad23834abe28fc09b661005
|
||||
@@ -24,6 +24,9 @@ namespace ProjectM.Client
|
||||
RectTransform _cooldownFill;
|
||||
Text _healthText;
|
||||
Text _threatText;
|
||||
Text _phaseText;
|
||||
Text _resourceText;
|
||||
Text _locationText;
|
||||
GameObject _respawnOverlay;
|
||||
EntityQuery _huskQuery;
|
||||
|
||||
@@ -48,6 +51,55 @@ namespace ProjectM.Client
|
||||
|
||||
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt);
|
||||
|
||||
// Macro-loop HUD (phase + cycle + countdown + location), read before the per-player early-out so it persists pre-spawn.
|
||||
bool haveCycle = SystemAPI.TryGetSingleton<CycleState>(out var cyc);
|
||||
if (_phaseText != null && haveCycle)
|
||||
{
|
||||
var endTick = new NetworkTick(cyc.PhaseEndTick);
|
||||
string detail;
|
||||
if (cyc.Phase == CyclePhase.Defend)
|
||||
detail = _huskQuery.CalculateEntityCount() + " HUSKS";
|
||||
else if (haveTick && cyc.PhaseEndTick != 0 && endTick.IsValid && endTick.IsNewerThan(nt.ServerTick))
|
||||
detail = (endTick.TicksSince(nt.ServerTick) / 60) + "s";
|
||||
else
|
||||
detail = "";
|
||||
_phaseText.text = PhaseLabel(cyc.Phase) + (detail.Length > 0 ? " - " + detail : "") + " CYCLE " + cyc.CycleNumber;
|
||||
_phaseText.color = PhaseColor(cyc.Phase);
|
||||
}
|
||||
else if (_phaseText != null)
|
||||
{
|
||||
_phaseText.text = "";
|
||||
}
|
||||
|
||||
if (_locationText != null)
|
||||
{
|
||||
var cam = Camera.main;
|
||||
bool onExpedition = cam != null && cam.transform.position.x > 500f;
|
||||
_locationText.text = onExpedition
|
||||
? "ON EXPEDITION - return through the gate"
|
||||
: "AT BASE" + (haveCycle && cyc.Phase == CyclePhase.Expedition ? " - step into the gate to deploy" : "");
|
||||
_locationText.color = onExpedition ? new Color(1f, 0.8f, 0.4f) : new Color(0.6f, 0.85f, 1f);
|
||||
}
|
||||
|
||||
if (_resourceText != null)
|
||||
{
|
||||
string res = "";
|
||||
if (SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var ledgerE))
|
||||
{
|
||||
var buf = SystemAPI.GetBuffer<StorageEntry>(ledgerE);
|
||||
int aether = 0, ore = 0, bio = 0;
|
||||
for (int i = 0; i < buf.Length; i++)
|
||||
{
|
||||
var en = buf[i];
|
||||
if (en.ItemId == ResourceId.Aether) aether = en.Count;
|
||||
else if (en.ItemId == ResourceId.Ore) ore = en.Count;
|
||||
else if (en.ItemId == ResourceId.Biomass) bio = en.Count;
|
||||
}
|
||||
res = "AETHER " + aether + " ORE " + ore + " BIO " + bio;
|
||||
}
|
||||
_resourceText.text = res;
|
||||
}
|
||||
|
||||
bool found = false;
|
||||
float hp = 0f, maxHp = 1f, cdFrac = 1f;
|
||||
bool dead = false, shielded = false;
|
||||
@@ -77,7 +129,7 @@ namespace ProjectM.Client
|
||||
break;
|
||||
}
|
||||
|
||||
_canvas.enabled = found;
|
||||
_canvas.enabled = found || haveCycle;
|
||||
if (!found) return;
|
||||
|
||||
float frac = maxHp > 0f ? Mathf.Clamp01(hp / maxHp) : 0f;
|
||||
@@ -139,6 +191,27 @@ namespace ProjectM.Client
|
||||
trt.anchorMin = new Vector2(1, 1); trt.anchorMax = new Vector2(1, 1); trt.pivot = new Vector2(1, 1);
|
||||
trt.anchoredPosition = new Vector2(-40, -30); trt.sizeDelta = new Vector2(380, 50);
|
||||
|
||||
// Cycle phase + number (top-center).
|
||||
_phaseText = MakeText("PhaseText", _canvas.transform, "EXPEDITION CYCLE 1", 34, TextAnchor.UpperCenter,
|
||||
new Color(0.55f, 0.9f, 1f), font);
|
||||
var prt = _phaseText.rectTransform;
|
||||
prt.anchorMin = new Vector2(0.5f, 1f); prt.anchorMax = new Vector2(0.5f, 1f); prt.pivot = new Vector2(0.5f, 1f);
|
||||
prt.anchoredPosition = new Vector2(0, -24); prt.sizeDelta = new Vector2(600, 50);
|
||||
|
||||
// Resource ledger counts (top-center, below phase).
|
||||
_resourceText = MakeText("ResourceText", _canvas.transform, "", 24, TextAnchor.UpperCenter,
|
||||
new Color(0.7f, 0.95f, 0.8f), font);
|
||||
var rrt = _resourceText.rectTransform;
|
||||
rrt.anchorMin = new Vector2(0.5f, 1f); rrt.anchorMax = new Vector2(0.5f, 1f); rrt.pivot = new Vector2(0.5f, 1f);
|
||||
rrt.anchoredPosition = new Vector2(0, -64); rrt.sizeDelta = new Vector2(600, 40);
|
||||
|
||||
// Location + gate hint (top-center, below resources).
|
||||
_locationText = MakeText("LocationText", _canvas.transform, "", 22, TextAnchor.UpperCenter,
|
||||
new Color(0.6f, 0.85f, 1f), font);
|
||||
var lrt = _locationText.rectTransform;
|
||||
lrt.anchorMin = new Vector2(0.5f, 1f); lrt.anchorMax = new Vector2(0.5f, 1f); lrt.pivot = new Vector2(0.5f, 1f);
|
||||
lrt.anchoredPosition = new Vector2(0, -96); lrt.sizeDelta = new Vector2(760, 36);
|
||||
|
||||
// Downed / respawning overlay (full screen, toggled by Dead).
|
||||
_respawnOverlay = new GameObject("RespawnOverlay", typeof(RectTransform));
|
||||
_respawnOverlay.transform.SetParent(_canvas.transform, false);
|
||||
@@ -211,5 +284,27 @@ namespace ProjectM.Client
|
||||
if (f == null) f = Font.CreateDynamicFontFromOSFont(new[] { "Arial", "Liberation Sans", "DejaVu Sans" }, 28);
|
||||
return f;
|
||||
}
|
||||
|
||||
static Color PhaseColor(byte phase)
|
||||
{
|
||||
switch (phase)
|
||||
{
|
||||
case CyclePhase.Expedition: return new Color(0.45f, 0.85f, 1f);
|
||||
case CyclePhase.Defend: return new Color(1f, 0.5f, 0.3f);
|
||||
case CyclePhase.Build: return new Color(0.45f, 0.95f, 0.6f);
|
||||
default: return Color.white;
|
||||
}
|
||||
}
|
||||
|
||||
static string PhaseLabel(byte phase)
|
||||
{
|
||||
switch (phase)
|
||||
{
|
||||
case CyclePhase.Expedition: return "EXPEDITION";
|
||||
case CyclePhase.Defend: return "DEFEND";
|
||||
case CyclePhase.Build: return "BUILD";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<StorageEntry>, 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 -> 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"/> -> <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
|
||||
@@ -35,5 +35,9 @@ namespace ProjectM.Simulation
|
||||
|
||||
/// <summary>Integrated distance travelled (predicted on client + authoritative on server). Not replicated.</summary>
|
||||
public float DistanceTravelled;
|
||||
|
||||
/// <summary>This tick's travel step (Speed*dt), written by ProjectileMoveSystem so a plain-group harvest
|
||||
/// sweep is tunnelling-safe without depending on its own variable-frame clock. Server-local; not replicated.</summary>
|
||||
public float LastStep;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ namespace ProjectM.Simulation
|
||||
.WithAll<Simulate>())
|
||||
{
|
||||
float step = projectile.ValueRO.Speed * dt;
|
||||
projectile.ValueRW.LastStep = step;
|
||||
float3 dir = new float3(projectile.ValueRO.Direction.x, 0f, projectile.ValueRO.Direction.y);
|
||||
|
||||
transform.ValueRW.Position += dir * step;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 655041f384f27064e82ddc6dec87ce86
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,17 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Tag marking the single GLOBAL shared-resource ledger — the entity whose [GhostField]
|
||||
/// <see cref="StorageEntry"/> buffer holds harvested resources (Aether/ore/biomass) replicated to ALL
|
||||
/// connections. It lives on the ownerless interpolated CycleDirector ghost, which carries NO
|
||||
/// <see cref="RegionTag"/>, so GhostRelevancy (SetIsIrrelevant) keeps it relevant to players in EVERY
|
||||
/// region — base AND expedition — unlike the region-tagged base storage container (which relevancy hides
|
||||
/// from expedition players). Server systems resolve the ledger via
|
||||
/// <c>GetSingletonEntity<ResourceLedger>()</c> then <c>GetBuffer<StorageEntry>()</c> — NEVER
|
||||
/// <c>GetSingleton<StorageEntry></c> (the base container owns a second StorageEntry buffer, so a
|
||||
/// buffer-typed singleton query would throw "multiple instances").
|
||||
/// </summary>
|
||||
public struct ResourceLedger : IComponentData { }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 78ea3121ee0b2db4992b1a2c5dd8d34b
|
||||
@@ -0,0 +1,21 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Baked singleton holding the resource-node ghost prefab + field shape. ExpeditionFieldSystem reads it to
|
||||
/// scatter <see cref="Count"/> nodes within <see cref="Radius"/> of the expedition region origin on each
|
||||
/// Expedition phase entry (seeded by the cycle number). Mirrors <see cref="StorageSpawner"/>.
|
||||
/// </summary>
|
||||
public struct ResourceFieldSpawner : IComponentData
|
||||
{
|
||||
/// <summary>Baked resource-node ghost prefab to instantiate.</summary>
|
||||
public Entity Prefab;
|
||||
|
||||
/// <summary>Number of nodes to scatter per expedition.</summary>
|
||||
public int Count;
|
||||
|
||||
/// <summary>Scatter radius (world units) around the expedition region origin.</summary>
|
||||
public float Radius;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 120cf21540ce86640b32921fa224b974
|
||||
@@ -0,0 +1,41 @@
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>Resource-type ids for harvested materials (a byte, not an enum, per the cross-assembly enum-in-Burst hazard).</summary>
|
||||
public static class ResourceId
|
||||
{
|
||||
/// <summary>Unused / empty sentinel (aligns with StorageMath's 0-itemId no-op).</summary>
|
||||
public const byte None = 0;
|
||||
|
||||
/// <summary>Magic energy — powers abilities / charging.</summary>
|
||||
public const byte Aether = 1;
|
||||
|
||||
/// <summary>Raw ore — structures / building.</summary>
|
||||
public const byte Ore = 2;
|
||||
|
||||
/// <summary>Biomass — misc / crafting.</summary>
|
||||
public const byte Biomass = 3;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A harvestable resource node in the procedural expedition field — an ownerless INTERPOLATED ghost
|
||||
/// (region-tagged Expedition) that clients see and shoot. The server-only ResourceHarvestSystem sweeps
|
||||
/// projectiles against it; each hit deposits <see cref="HarvestPerHit"/> of <see cref="ResourceId"/> into
|
||||
/// the GLOBAL resource ledger and decrements <see cref="Remaining"/>; the node despawns at <= 0.
|
||||
/// ResourceId/Remaining are [GhostField] so clients can tint by type and (later) show depletion;
|
||||
/// HarvestPerHit is baked, server-only.
|
||||
/// </summary>
|
||||
public struct ResourceNode : IComponentData
|
||||
{
|
||||
/// <summary>Which resource this node yields (see <see cref="ResourceId"/>).</summary>
|
||||
[GhostField] public byte ResourceId;
|
||||
|
||||
/// <summary>Remaining resource units; the node despawns when this reaches 0.</summary>
|
||||
[GhostField] public int Remaining;
|
||||
|
||||
/// <summary>Units yielded per projectile hit (baked; server-only).</summary>
|
||||
public float HarvestPerHit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d8504e2af2e9a694d9a7dc30c61cc69a
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ee67f9c921637c84b92bfae0edb3d3e9
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,61 @@
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Macro-loop state for "The Aether Cycle": which phase the run is in, the cycle number, and the server
|
||||
/// tick the current (timed) phase ends. Server-authoritative, maintained by CyclePhaseSystem. Currently a
|
||||
/// server-side singleton; the [GhostField]s below are inert until it is moved onto the runtime-spawned
|
||||
/// CycleDirector ghost (when the client HUD is wired), at which point the same struct replicates unchanged.
|
||||
/// The Defend phase is NOT timed — it ends when the base-defense wave is cleared — so PhaseEndTick is only
|
||||
/// meaningful in Expedition/Build (0 during Defend).
|
||||
/// </summary>
|
||||
public struct CycleState : IComponentData
|
||||
{
|
||||
/// <summary>Current phase (see <see cref="CyclePhase"/>).</summary>
|
||||
[GhostField] public byte Phase;
|
||||
|
||||
/// <summary>1-based cycle counter (increments when a new Expedition begins).</summary>
|
||||
[GhostField] public int CycleNumber;
|
||||
|
||||
/// <summary>Server tick the current timed phase ends (Expedition/Build only; 0 in Defend).</summary>
|
||||
[GhostField] public uint PhaseEndTick;
|
||||
}
|
||||
|
||||
/// <summary>Phase constants for <see cref="CycleState.Phase"/> (a byte, not an enum, for trivial Burst/serialization).</summary>
|
||||
public static class CyclePhase
|
||||
{
|
||||
/// <summary>Out in the procedural field gathering resources (timed).</summary>
|
||||
public const byte Expedition = 0;
|
||||
|
||||
/// <summary>The base is under assault by a Husk wave (ends when the wave is cleared).</summary>
|
||||
public const byte Defend = 1;
|
||||
|
||||
/// <summary>Calm at base: spend resources to build/upgrade (timed).</summary>
|
||||
public const byte Build = 2;
|
||||
|
||||
/// <summary>Expedition phase duration in server ticks (SimulationTickRate = 60). Tunable; short for the M6 slice.</summary>
|
||||
public const uint ExpeditionTicks = 3600; // ~60s cap (early return via the gate ends it sooner)
|
||||
|
||||
/// <summary>Build phase duration in server ticks.</summary>
|
||||
public const uint BuildTicks = 1200; // ~20s
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-only bookkeeping for the cycle state machine that must NOT replicate (kept separate from the
|
||||
/// replicated <see cref="CycleState"/>). Records the wave number captured when the Defend phase began so
|
||||
/// the director can detect "this Defend's wave has now been spawned and cleared".
|
||||
/// </summary>
|
||||
public struct CycleRuntime : IComponentData
|
||||
{
|
||||
/// <summary>WaveState.WaveNumber captured at the moment the current Defend phase started.</summary>
|
||||
public int DefendStartWave;
|
||||
|
||||
/// <summary>Cycle phase from the previous tick — lets ExpeditionFieldSystem edge-detect entering/leaving Expedition.</summary>
|
||||
public byte PrevPhase;
|
||||
|
||||
/// <summary>CycleNumber the expedition field was last seeded for (compared by int equality, never tick math).</summary>
|
||||
public int LastSpawnedCycle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ca714d222c4d2ed48aaaad7bbe6ec8fc
|
||||
@@ -0,0 +1,16 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton baked into the gameplay subscene holding the cycle-director ghost prefab. A one-shot server
|
||||
/// system (<c>CycleDirectorSpawnSystem</c>) instantiates the prefab — the GLOBAL <see cref="CycleState"/>
|
||||
/// + shared resource-ledger ghost — exactly once, then destroys this singleton. Mirrors
|
||||
/// <see cref="StorageSpawner"/>. Carries no transform; only the prefab needs one.
|
||||
/// </summary>
|
||||
public struct CycleDirectorSpawner : IComponentData
|
||||
{
|
||||
/// <summary>Baked cycle-director ghost prefab to instantiate.</summary>
|
||||
public Entity Prefab;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e0fcb1bcbea82cc419d4f134c9589619
|
||||
@@ -0,0 +1,28 @@
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// A walk-in travel gate between world regions. A baked entity (visible mesh + this component) at a fixed
|
||||
/// position; the server <c>ExpeditionGateSystem</c> transits a player who walks within <see cref="Radius"/>
|
||||
/// and whose region matches <see cref="FromRegion"/> to <see cref="ToRegion"/>, placing them at
|
||||
/// <see cref="ArrivalPos"/> (offset from the destination gate so they do not immediately re-trigger).
|
||||
/// Returning to the base during the Expedition phase also starts Defend early (the "timer cap + early
|
||||
/// return" pacing).
|
||||
/// </summary>
|
||||
public struct ExpeditionGate : IComponentData
|
||||
{
|
||||
/// <summary>Region a player must currently be in for this gate to act on them (see <see cref="RegionId"/>).</summary>
|
||||
public byte FromRegion;
|
||||
|
||||
/// <summary>Region the player is transited to.</summary>
|
||||
public byte ToRegion;
|
||||
|
||||
/// <summary>Planar (XZ) trigger radius in world units.</summary>
|
||||
public float Radius;
|
||||
|
||||
/// <summary>World position the player arrives at in the destination region.</summary>
|
||||
public float3 ArrivalPos;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ed28d6b4a4f0b0844b851cecaadeb93f
|
||||
@@ -0,0 +1,49 @@
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Identifies which world REGION an entity belongs to. M6 splits the single server world into two
|
||||
/// spatial regions at a large coordinate offset — the persistent home <see cref="RegionId.Base"/> and
|
||||
/// the procedurally-arranged <see cref="RegionId.Expedition"/> — and uses per-connection GhostRelevancy
|
||||
/// to replicate each region only to the connections whose player is currently in it. Server-side only
|
||||
/// (NOT a [GhostField]; the server makes all relevancy decisions). Added to players on spawn and to
|
||||
/// every region-scoped ghost the server spawns. Untagged ghosts are global (relevant to everyone).
|
||||
/// </summary>
|
||||
public struct RegionTag : IComponentData
|
||||
{
|
||||
/// <summary>Region id (see <see cref="RegionId"/>): 0 = base, 1 = expedition.</summary>
|
||||
public byte Region;
|
||||
}
|
||||
|
||||
/// <summary>Region ids for <see cref="RegionTag.Region"/> (a byte, not an enum, to keep server/Burst code trivial).</summary>
|
||||
public static class RegionId
|
||||
{
|
||||
/// <summary>The persistent, shared home base.</summary>
|
||||
public const byte Base = 0;
|
||||
|
||||
/// <summary>The procedural expedition field (offset far from the base on +X).</summary>
|
||||
public const byte Expedition = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic mapping of a region id to its world-space origin. The base region keeps the existing
|
||||
/// home-base coordinates; the expedition region lives at a large +X offset so the two never overlap in
|
||||
/// the single shared PhysicsWorld. Pure (no RNG/wall-clock) — server-authoritative teleports and field
|
||||
/// spawners resolve region positions through here.
|
||||
/// </summary>
|
||||
public static class RegionMath
|
||||
{
|
||||
/// <summary>World-space X offset of the expedition region from the base region.</summary>
|
||||
public const float ExpeditionOffsetX = 1000f;
|
||||
|
||||
/// <summary>World-space origin of <paramref name="region"/>, given the base center (BaseGridMath.PlotCenter).</summary>
|
||||
public static float3 RegionOrigin(byte region, float3 baseCenter)
|
||||
{
|
||||
return region == RegionId.Expedition
|
||||
? baseCenter + new float3(ExpeditionOffsetX, 0f, 0f)
|
||||
: baseCenter;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 09694e58455f41b439cdf791c3c421d8
|
||||
@@ -0,0 +1,17 @@
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Client -> server request to move the sender's player between world regions (base <-> expedition).
|
||||
/// A one-off action, so an RPC (not a per-tick predicted input), mirroring <see cref="StorageOpRequest"/>.
|
||||
/// TargetRegion is a byte (see <see cref="RegionId"/>) to keep the generated serializer trivial. The
|
||||
/// server teleports the sender's player to the region origin and flips its <see cref="RegionTag"/>, which
|
||||
/// re-scopes GhostRelevancy so the client gains the target region's ghosts and drops the old region's.
|
||||
/// </summary>
|
||||
public struct RegionTransitRequest : IRpcCommand
|
||||
{
|
||||
/// <summary>Destination region id (see <see cref="RegionId"/>).</summary>
|
||||
public byte TargetRegion;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 32c76b46284ed1845a412ca030de2499
|
||||
Reference in New Issue
Block a user