e1ed08a803
Consolidate the divorced combat + economy halves into one base-local loop. BaseFieldSpawnSystem tops up RegionTag{Base} Ore nodes around the plot; harvest routes Base->shared ledger and Expedition/untagged->personal inventory for BOTH the projectile (ResourceHarvestSystem) and melee (MeleeComboSystem server-only block, writes Remaining back for VFX). Activate the reserved Schedule source in ThreatDirectorSystem so base sieges arm WITHOUT an expedition trip (the loop-closer: previously zero waves ever attacked a base-only player). Region-filter the ExpeditionFieldSystem teardown so it no longer wipes the permanent base field. HudSystem shows phase-aware loop copy. See DR-031.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
102 lines
5.1 KiB
C#
102 lines
5.1 KiB
C#
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 home-base mining-field manager. Keeps the live RegionTag{Base} ResourceNode count topped up to
|
|
/// <see cref="BaseFieldSpawner.TargetCount"/> so the gather -> build -> survive loop lives AT the base (no
|
|
/// expedition trip). Unlike <see cref="ExpeditionFieldSystem"/> (edge-triggered on player presence) this is a
|
|
/// TICK-CADENCED top-up: every <see cref="BaseFieldSpawner.RespawnIntervalTicks"/> it counts live base nodes
|
|
/// and instantiates (TargetCount - liveCount) more, scattered UNIFORMLY-IN-RADIUS (rad = inner + r*(outer-inner),
|
|
/// NOT the area-weighted sqrt that piles nodes on the outer wall) in the [Inner,Outer] annulus around
|
|
/// BaseGridMath.PlotCenter, each overridden via SetComponent (NOT Add — the prefab already bakes
|
|
/// RegionTag{Expedition}) to RegionTag{Base} + ResourceId.Ore. The FIRST pass fires immediately
|
|
/// (NextSpawnTick seeded 0) so the field seeds without waiting. Deterministic: the scatter RNG is seeded from
|
|
/// a monotonic Epoch (never the tick); the cadence gate is wrap-safe NetworkTick math (TickUtil.NonZero +
|
|
/// IsNewerThan), never raw uint. Runtime-spawned ghosts dodge the prespawn handshake. Plain server
|
|
/// SimulationSystemGroup; server-only, never predicted.
|
|
/// </summary>
|
|
[BurstCompile]
|
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
|
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
|
public partial struct BaseFieldSpawnSystem : ISystem
|
|
{
|
|
[BurstCompile]
|
|
public void OnCreate(ref SystemState state)
|
|
{
|
|
state.RequireForUpdate<NetworkTime>();
|
|
state.RequireForUpdate<BaseFieldSpawner>();
|
|
state.RequireForUpdate<BaseAnchor>();
|
|
}
|
|
|
|
[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<BaseFieldSpawner>();
|
|
var spawner = SystemAPI.GetComponent<BaseFieldSpawner>(spawnerEntity);
|
|
var runtime = SystemAPI.GetComponent<BaseFieldRuntime>(spawnerEntity);
|
|
if (spawner.Prefab == Entity.Null)
|
|
return;
|
|
|
|
// Cadence gate: first pass (NextSpawnTick == 0) fires immediately; thereafter every RespawnIntervalTicks.
|
|
if (runtime.NextSpawnTick != 0u && new NetworkTick(runtime.NextSpawnTick).IsNewerThan(serverTick))
|
|
return;
|
|
|
|
// Count LIVE base-region nodes only (expedition nodes share the ResourceNode type; exclude by region).
|
|
int liveBase = 0;
|
|
foreach (var region in SystemAPI.Query<RefRO<RegionTag>>().WithAll<ResourceNode>())
|
|
if (region.ValueRO.Region == RegionId.Base)
|
|
liveBase++;
|
|
|
|
int deficit = spawner.TargetCount - liveBase;
|
|
if (deficit > 0)
|
|
{
|
|
var anchor = SystemAPI.GetSingleton<BaseAnchor>();
|
|
float3 center = BaseGridMath.PlotCenter(anchor);
|
|
var baseXform = SystemAPI.GetComponent<LocalTransform>(spawner.Prefab);
|
|
var prefabNode = SystemAPI.GetComponent<ResourceNode>(spawner.Prefab);
|
|
|
|
runtime.Epoch += 1;
|
|
var rng = new Random(((uint)runtime.Epoch * 747796405u) | 1u); // epoch-seeded (never the tick), nonzero
|
|
float inner = math.max(0f, spawner.InnerRadius);
|
|
float outer = math.max(inner + 0.01f, spawner.OuterRadius);
|
|
|
|
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
|
for (int i = 0; i < deficit; i++)
|
|
{
|
|
var node = ecb.Instantiate(spawner.Prefab);
|
|
|
|
float ang = rng.NextFloat(0f, math.PI * 2f);
|
|
float rad = inner + rng.NextFloat(0f, 1f) * (outer - inner); // UNIFORM in radius
|
|
var xform = baseXform;
|
|
xform.Position = center + new float3(math.cos(ang) * rad, 0f, math.sin(ang) * rad);
|
|
ecb.SetComponent(node, xform);
|
|
|
|
// Override the baked RegionTag{Expedition} -> Base (else RegionRelevancy hides it from base
|
|
// players) and force the resource to Ore (the build currency; base field stays Ore-only).
|
|
ecb.SetComponent(node, new RegionTag { Region = RegionId.Base });
|
|
var rn = prefabNode;
|
|
rn.ResourceId = ResourceId.Ore;
|
|
ecb.SetComponent(node, rn);
|
|
}
|
|
ecb.Playback(state.EntityManager);
|
|
ecb.Dispose();
|
|
}
|
|
|
|
runtime.NextSpawnTick = TickUtil.NonZero(now + (uint)math.max(1, spawner.RespawnIntervalTicks));
|
|
SystemAPI.SetComponent(spawnerEntity, runtime);
|
|
}
|
|
}
|
|
}
|