Economy: base-local mining loop (mine at base, any attack harvests, scheduled sieges)
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>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Authoring for the home-base mining field (<see cref="BaseFieldSpawner"/>). Place ONE in the gameplay
|
||||
/// subscene. <see cref="NodePrefab"/> = the SAME ResourceNode ghost prefab the expedition uses; the server
|
||||
/// system overrides each instance to RegionTag{Base} + ResourceId.Ore and scatters them in the
|
||||
/// [<see cref="InnerRadius"/>, <see cref="OuterRadius"/>] annulus around the base plot center. Defaults are
|
||||
/// sized to the baked 32x32 plot (square corner reach ~22.6) inside the ~28.7 boundary ring, so nodes form a
|
||||
/// reachable perimeter ring that never sits on a build cell.
|
||||
/// </summary>
|
||||
public class BaseFieldSpawnerAuthoring : MonoBehaviour
|
||||
{
|
||||
[Tooltip("Resource-node ghost prefab (ResourceNodeAuthoring + GhostAuthoring). Reuse the expedition node prefab.")]
|
||||
public GameObject NodePrefab;
|
||||
|
||||
[Tooltip("Live base-node target; the field refills toward this each respawn pass.")]
|
||||
[Min(1)] public int TargetCount = 10;
|
||||
|
||||
[Tooltip("Inner scatter radius — clears the build plot corner reach (~22.6) + spawn ring.")]
|
||||
[Min(0f)] public float InnerRadius = 23.5f;
|
||||
|
||||
[Tooltip("Outer scatter radius — stays inside the walkable boundary ring (~28.7).")]
|
||||
[Min(0f)] public float OuterRadius = 27f;
|
||||
|
||||
[Tooltip("Server ticks (@60) between top-up passes.")]
|
||||
[Min(1)] public int RespawnIntervalTicks = 600;
|
||||
|
||||
private class BaseFieldSpawnerBaker : Baker<BaseFieldSpawnerAuthoring>
|
||||
{
|
||||
public override void Bake(BaseFieldSpawnerAuthoring authoring)
|
||||
{
|
||||
var entity = GetEntity(authoring, TransformUsageFlags.None);
|
||||
AddComponent(entity, new BaseFieldSpawner
|
||||
{
|
||||
Prefab = authoring.NodePrefab != null
|
||||
? GetEntity(authoring.NodePrefab, TransformUsageFlags.Dynamic)
|
||||
: Entity.Null,
|
||||
TargetCount = authoring.TargetCount,
|
||||
InnerRadius = authoring.InnerRadius,
|
||||
OuterRadius = authoring.OuterRadius,
|
||||
RespawnIntervalTicks = authoring.RespawnIntervalTicks,
|
||||
});
|
||||
AddComponent(entity, new BaseFieldRuntime { Epoch = 0, NextSpawnTick = 0u });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c4055a6a779d06949ae23b16334b810e
|
||||
@@ -30,6 +30,16 @@ namespace ProjectM.Authoring
|
||||
|
||||
[Tooltip("Max server ticks a siege may run before it auto-collapses (no soft-lock). 0 = no cap.")]
|
||||
public uint SiegeTimeoutTicks = 3600;
|
||||
[Header("Threat — scheduled base sieges")]
|
||||
[Tooltip("A timed cadence arms a base siege even without an expedition trip (keeps the base loop stakeful).")]
|
||||
public bool ScheduleEnabled = true;
|
||||
|
||||
[Tooltip("Server ticks (@60) between scheduled base sieges. First fire is one interval out (mine/build grace).")]
|
||||
public uint ScheduleIntervalTicks = 2700;
|
||||
|
||||
[Tooltip("Extra Husks per surviving wave (siege size = SiegeSizeBase + this * WaveNumber). 0 = flat.")]
|
||||
public int ScheduleSizePerWave = 1;
|
||||
|
||||
|
||||
private class CycleDirectorBaker : Baker<CycleDirectorAuthoring>
|
||||
{
|
||||
@@ -53,6 +63,9 @@ namespace ProjectM.Authoring
|
||||
SizePerExpeditionResource = authoring.SiegeSizePerResource,
|
||||
StartCondition = ThreatStartCondition.Immediate,
|
||||
SiegeTimeoutTicks = authoring.SiegeTimeoutTicks,
|
||||
ScheduleEnabled = (byte)(authoring.ScheduleEnabled ? 1 : 0),
|
||||
ScheduleIntervalTicks = authoring.ScheduleIntervalTicks,
|
||||
ScheduleSizePerWave = authoring.ScheduleSizePerWave,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,9 +152,12 @@ namespace ProjectM.Client
|
||||
var cam = Camera.main;
|
||||
bool onExpedition = cam != null && cam.transform.position.x > ExpeditionRegionXMin;
|
||||
_locationText.text = onExpedition
|
||||
? "ON EXPEDITION - return through the gate"
|
||||
: "AT BASE - deploy through the gate when you're ready";
|
||||
_locationText.style.color = onExpedition ? new Color(1f, 0.8f, 0.4f) : new Color(0.6f, 0.85f, 1f);
|
||||
? "ON EXPEDITION - carve the frontier, then return"
|
||||
: siege
|
||||
? "DEFEND THE BASE - hold the line"
|
||||
: "MINE THE CRYSTALS - any attack harvests Ore, then BUILD";
|
||||
_locationText.style.color = onExpedition ? new Color(1f, 0.8f, 0.4f)
|
||||
: siege ? new Color(1f, 0.55f, 0.4f) : new Color(0.6f, 0.95f, 0.7f);
|
||||
|
||||
// ---- Goal (hex-pip meter, or a continuous bar for large targets) ----
|
||||
if (SystemAPI.TryGetSingleton<GoalProgress>(out var goal))
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 346e9c0fb92e7b94fa3761222fc2ff1e
|
||||
@@ -115,8 +115,11 @@ namespace ProjectM.Server
|
||||
// DESTROY: the last player left the expedition — clear the whole field (nodes + clutter).
|
||||
if (wasOccupied && !occupied)
|
||||
{
|
||||
foreach (var (rn, e) in SystemAPI.Query<RefRO<ResourceNode>>().WithEntityAccess())
|
||||
ecb.DestroyEntity(e);
|
||||
// Only EXPEDITION nodes — the base field is permanent RegionTag{Base} and must NOT be torn down here.
|
||||
foreach (var (rn, region, e) in
|
||||
SystemAPI.Query<RefRO<ResourceNode>, RefRO<RegionTag>>().WithEntityAccess())
|
||||
if (region.ValueRO.Region == RegionId.Expedition)
|
||||
ecb.DestroyEntity(e);
|
||||
foreach (var (bc, e) in SystemAPI.Query<RefRO<BlightClutter>>().WithEntityAccess())
|
||||
ecb.DestroyEntity(e);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ namespace ProjectM.Server
|
||||
|
||||
ComponentLookup<GhostOwner> m_GhostOwnerLookup;
|
||||
BufferLookup<InventorySlot> m_InvLookup;
|
||||
ComponentLookup<RegionTag> m_RegionLookup;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
@@ -43,6 +44,7 @@ namespace ProjectM.Server
|
||||
state.RequireForUpdate<ResourceLedger>();
|
||||
m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(true);
|
||||
m_InvLookup = state.GetBufferLookup<InventorySlot>(false);
|
||||
m_RegionLookup = state.GetComponentLookup<RegionTag>(true);
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
@@ -56,6 +58,7 @@ namespace ProjectM.Server
|
||||
// hoisted out of the per-hit sweep (invariant for the tick).
|
||||
m_GhostOwnerLookup.Update(ref state);
|
||||
m_InvLookup.Update(ref state);
|
||||
m_RegionLookup.Update(ref state);
|
||||
bool haveDb = SystemAPI.TryGetSingleton<ItemDatabase>(out var itemDb);
|
||||
|
||||
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||
@@ -72,6 +75,7 @@ namespace ProjectM.Server
|
||||
var tgtYieldPerHit = new NativeList<float>(Allocator.Temp);
|
||||
var tgtVariant = new NativeList<byte>(Allocator.Temp);
|
||||
var tgtIsClutter = new NativeList<bool>(Allocator.Temp);
|
||||
var tgtToLedger = new NativeList<bool>(Allocator.Temp);
|
||||
|
||||
foreach (var (xform, hr, node, e) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>, RefRO<ResourceNode>>().WithEntityAccess())
|
||||
@@ -84,6 +88,7 @@ namespace ProjectM.Server
|
||||
tgtYieldPerHit.Add(node.ValueRO.HarvestPerHit);
|
||||
tgtVariant.Add(0);
|
||||
tgtIsClutter.Add(false);
|
||||
tgtToLedger.Add(m_RegionLookup.HasComponent(e) && m_RegionLookup[e].Region == RegionId.Base);
|
||||
}
|
||||
|
||||
foreach (var (xform, hr, clutter, e) in
|
||||
@@ -97,6 +102,7 @@ namespace ProjectM.Server
|
||||
tgtYieldPerHit.Add(clutter.ValueRO.ScrapPerHit);
|
||||
tgtVariant.Add(clutter.ValueRO.Variant);
|
||||
tgtIsClutter.Add(true);
|
||||
tgtToLedger.Add(m_RegionLookup.HasComponent(e) && m_RegionLookup[e].Region == RegionId.Base);
|
||||
}
|
||||
|
||||
var destroyed = new NativeArray<bool>(tgtEntity.Length, Allocator.Temp);
|
||||
@@ -144,7 +150,10 @@ namespace ProjectM.Server
|
||||
// firing player's GhostOwner (AbilityFireSystem); the owner is read OPTIONALLY (cached lookup) so
|
||||
// an un-owned projectile (or a test projectile with no GhostOwner) falls through to the ledger.
|
||||
int remainder = amount;
|
||||
if (m_GhostOwnerLookup.HasComponent(projEntity)
|
||||
// Base-region nodes credit the SHARED ledger DIRECTLY (the build currency pool); expedition / un-tagged
|
||||
// nodes keep the personal-inventory reroute (spill-to-ledger). Untagged -> not Base -> inventory path.
|
||||
if (!tgtToLedger[bestIdx]
|
||||
&& m_GhostOwnerLookup.HasComponent(projEntity)
|
||||
&& playerByConn.TryGetValue(m_GhostOwnerLookup[projEntity].NetworkId, out var player)
|
||||
&& m_InvLookup.HasBuffer(player))
|
||||
{
|
||||
@@ -208,6 +217,7 @@ namespace ProjectM.Server
|
||||
tgtYieldPerHit.Dispose();
|
||||
tgtVariant.Dispose();
|
||||
tgtIsClutter.Dispose();
|
||||
tgtToLedger.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,27 @@ namespace ProjectM.Server
|
||||
threat.PendingReturns = 0; // consume regardless so returns can't pile up
|
||||
}
|
||||
|
||||
// ---- SOURCE: scheduled base sieges. A timed cadence arms a siege even with NO expedition trip, so
|
||||
// the base-defense loop has stakes on its own. The first fire is one full interval out (a mine/build
|
||||
// grace window); size escalates by the live wave number. All ticks wrap-safe (TickUtil.NonZero). ----
|
||||
if (config.ScheduleEnabled != 0 && config.ScheduleIntervalTicks > 0)
|
||||
{
|
||||
if (threat.NextScheduledTick == 0 || cycle.Phase != CyclePhase.Calm)
|
||||
{
|
||||
// Seed, and DEFER while a siege runs, so the next scheduled siege is always one full interval
|
||||
// AFTER the current one resolves -> a guaranteed calm/build window even if a siege runs long.
|
||||
threat.NextScheduledTick = TickUtil.NonZero(now + config.ScheduleIntervalTicks);
|
||||
}
|
||||
else if (cycle.Phase == CyclePhase.Calm && threat.PendingSiegeSize == 0
|
||||
&& !new NetworkTick(threat.NextScheduledTick).IsNewerThan(serverTick))
|
||||
{
|
||||
int wave = SystemAPI.TryGetSingleton<WaveState>(out var ws) ? ws.WaveNumber : 0;
|
||||
threat.PendingSiegeSize = math.max(1, config.SizeBase + config.ScheduleSizePerWave * wave);
|
||||
threat.ArmTick = TickUtil.NonZero(now + config.PostExpeditionDelayTicks);
|
||||
threat.NextScheduledTick = TickUtil.NonZero(now + config.ScheduleIntervalTicks);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Baked singleton describing the HOME-BASE mining field: a ring of harvestable resource nodes scattered
|
||||
/// around the base so the gather -> build -> survive loop lives on ONE screen (no expedition trip required).
|
||||
/// DISTINCT from <see cref="ResourceFieldSpawner"/> (the expedition field) so the two never collide on a
|
||||
/// GetSingleton. <see cref="ProjectM.Server.BaseFieldSpawnSystem"/> keeps the live RegionTag{Base} node count
|
||||
/// topped up to <see cref="TargetCount"/> on a tick cadence; nodes scatter UNIFORMLY-IN-RADIUS in the annulus
|
||||
/// [<see cref="InnerRadius"/>, <see cref="OuterRadius"/>] around BaseGridMath.PlotCenter (inner clears the
|
||||
/// square build plot + spawn ring, outer stays inside the walkable boundary ring) and are overridden to
|
||||
/// RegionTag{Base} + ResourceId.Ore (the sole build currency, kept legible — never the expedition's
|
||||
/// Aether/Ore/Biomass round-robin). Place ONE authoring in the gameplay subscene.
|
||||
/// </summary>
|
||||
public struct BaseFieldSpawner : IComponentData
|
||||
{
|
||||
/// <summary>Baked resource-node ghost prefab to instantiate (reuses the expedition node prefab).</summary>
|
||||
public Entity Prefab;
|
||||
|
||||
/// <summary>Desired live base-node count; the system refills toward this each respawn pass.</summary>
|
||||
public int TargetCount;
|
||||
|
||||
/// <summary>Inner scatter radius (world units) from the plot center — must clear the build plot + spawn ring.</summary>
|
||||
public float InnerRadius;
|
||||
|
||||
/// <summary>Outer scatter radius (world units) — must stay inside the boundary ring so nodes are reachable.</summary>
|
||||
public float OuterRadius;
|
||||
|
||||
/// <summary>Server ticks between top-up passes (a depleted field refills toward TargetCount on this cadence).</summary>
|
||||
public int RespawnIntervalTicks;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-only runtime state for <see cref="ProjectM.Server.BaseFieldSpawnSystem"/>, baked beside
|
||||
/// <see cref="BaseFieldSpawner"/>. NOT replicated. <see cref="Epoch"/> seeds the per-node scatter RNG
|
||||
/// (monotonic, so a top-up never repeats a layout); <see cref="NextSpawnTick"/> gates the cadence (wrap-safe
|
||||
/// via TickUtil.NonZero + NetworkTick.IsNewerThan, never raw uint). NextSpawnTick == 0 means "fire now" so the
|
||||
/// first pass seeds the field without waiting for a tick that never comes.
|
||||
/// </summary>
|
||||
public struct BaseFieldRuntime : IComponentData
|
||||
{
|
||||
public int Epoch;
|
||||
public uint NextSpawnTick;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7a18d4457f559ef49bcd3122dcdb6d82
|
||||
@@ -49,11 +49,15 @@ namespace ProjectM.Simulation
|
||||
public partial struct MeleeComboSystem : ISystem
|
||||
{
|
||||
ComponentLookup<KnockbackState> m_KnockbackLookup;
|
||||
ComponentLookup<RegionTag> m_RegionLookup;
|
||||
BufferLookup<InventorySlot> m_InvLookup;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
|
||||
m_RegionLookup = state.GetComponentLookup<RegionTag>(isReadOnly: true);
|
||||
m_InvLookup = state.GetBufferLookup<InventorySlot>(isReadOnly: false);
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
}
|
||||
|
||||
@@ -166,6 +170,50 @@ namespace ProjectM.Simulation
|
||||
enemyEntities.Add(enemyEntity);
|
||||
enemyPositions.Add(xform.ValueRO.Position);
|
||||
}
|
||||
// Gather harvest targets (resource nodes + Blight clutter) ONCE so "any attack harvests": a swing
|
||||
// depletes every node/clutter in its cone, crediting the shared ResourceLedger (the build currency
|
||||
// pool) just like a base projectile hit. SERVER-ONLY (this whole block) — interpolated node ghosts
|
||||
// are never rolled back, so the deposit + destroy fire exactly once per swing.
|
||||
bool haveLedger = SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var ledgerEntity);
|
||||
m_RegionLookup.Update(ref state);
|
||||
m_InvLookup.Update(ref state);
|
||||
var meleePlayerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||
foreach (var (po, pe) in SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, InventorySlot>().WithEntityAccess())
|
||||
meleePlayerByConn[po.ValueRO.NetworkId] = pe;
|
||||
var harvEntity = new NativeList<Entity>(Allocator.Temp);
|
||||
var harvPos = new NativeList<float3>(Allocator.Temp);
|
||||
var harvRemaining = new NativeList<int>(Allocator.Temp);
|
||||
var harvYieldId = new NativeList<byte>(Allocator.Temp);
|
||||
var harvPerHit = new NativeList<float>(Allocator.Temp);
|
||||
var harvIsClutter = new NativeList<bool>(Allocator.Temp);
|
||||
var harvVariant = new NativeList<byte>(Allocator.Temp);
|
||||
var harvToLedger = new NativeList<bool>(Allocator.Temp);
|
||||
foreach (var (hx, node, he) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<ResourceNode>>().WithEntityAccess())
|
||||
{
|
||||
harvEntity.Add(he);
|
||||
harvPos.Add(hx.ValueRO.Position);
|
||||
harvRemaining.Add(node.ValueRO.Remaining);
|
||||
harvYieldId.Add(node.ValueRO.ResourceId);
|
||||
harvPerHit.Add(node.ValueRO.HarvestPerHit);
|
||||
harvIsClutter.Add(false);
|
||||
harvVariant.Add(0);
|
||||
harvToLedger.Add(m_RegionLookup.HasComponent(he) && m_RegionLookup[he].Region == RegionId.Base);
|
||||
}
|
||||
foreach (var (hx, clutter, he) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<BlightClutter>>().WithEntityAccess())
|
||||
{
|
||||
harvEntity.Add(he);
|
||||
harvPos.Add(hx.ValueRO.Position);
|
||||
harvRemaining.Add(clutter.ValueRO.Remaining);
|
||||
harvYieldId.Add(clutter.ValueRO.ScrapResourceId);
|
||||
harvPerHit.Add(clutter.ValueRO.ScrapPerHit);
|
||||
harvIsClutter.Add(true);
|
||||
harvVariant.Add(clutter.ValueRO.Variant);
|
||||
harvToLedger.Add(m_RegionLookup.HasComponent(he) && m_RegionLookup[he].Region == RegionId.Base);
|
||||
}
|
||||
var harvDestroyed = new NativeArray<bool>(harvEntity.Length, Allocator.Temp);
|
||||
|
||||
|
||||
m_KnockbackLookup.Update(ref state);
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
@@ -192,10 +240,85 @@ namespace ProjectM.Simulation
|
||||
}
|
||||
}
|
||||
}
|
||||
// HARVEST: deplete every node/clutter in each swing's cone, crediting the shared ledger; write
|
||||
// Remaining back so the [GhostField] replicates -> WorldFeedbackSystem chips fire on melee mining.
|
||||
for (int s = 0; s < cleaves.Length; s++)
|
||||
{
|
||||
var hc = cleaves[s];
|
||||
for (int i = 0; i < harvEntity.Length; i++)
|
||||
{
|
||||
if (harvDestroyed[i])
|
||||
continue;
|
||||
if (!MeleeConeMath.InCone(hc.From, hc.Face, hc.Range, cosHalf, harvPos[i]))
|
||||
continue;
|
||||
|
||||
int amount = math.max(1, (int)harvPerHit[i]);
|
||||
byte yieldId = harvYieldId[i];
|
||||
// Route by region: Base nodes credit the shared ledger DIRECTLY (the build pool); an
|
||||
// expedition / un-tagged target goes to the swinging player's PERSONAL inventory (spill to
|
||||
// ledger), mirroring ResourceHarvestSystem. Only deplete if the yield landed somewhere —
|
||||
// never consume a node for zero credit (e.g. no ledger singleton present).
|
||||
int remainder = amount;
|
||||
bool deposited = false;
|
||||
if (!harvToLedger[i]
|
||||
&& meleePlayerByConn.TryGetValue(hc.OwnerId, out var meleePlayer)
|
||||
&& m_InvLookup.HasBuffer(meleePlayer))
|
||||
{
|
||||
var inv = m_InvLookup[meleePlayer];
|
||||
remainder = InventoryMath.Deposit(inv, yieldId, amount, Tuning.DefaultStackMax, Tuning.InventoryMaxSlots);
|
||||
deposited = true;
|
||||
}
|
||||
if (remainder > 0 && haveLedger)
|
||||
{
|
||||
var ledger = SystemAPI.GetBuffer<StorageEntry>(ledgerEntity);
|
||||
StorageMath.Deposit(ledger, yieldId, remainder);
|
||||
deposited = true;
|
||||
}
|
||||
if (!deposited)
|
||||
continue;
|
||||
|
||||
int rem = harvRemaining[i] - amount;
|
||||
harvRemaining[i] = rem;
|
||||
if (rem <= 0)
|
||||
{
|
||||
harvDestroyed[i] = true;
|
||||
ecb.DestroyEntity(harvEntity[i]);
|
||||
}
|
||||
else if (harvIsClutter[i])
|
||||
{
|
||||
ecb.SetComponent(harvEntity[i], new BlightClutter
|
||||
{
|
||||
Remaining = rem,
|
||||
Variant = harvVariant[i],
|
||||
ScrapResourceId = yieldId,
|
||||
ScrapPerHit = harvPerHit[i],
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
ecb.SetComponent(harvEntity[i], new ResourceNode
|
||||
{
|
||||
ResourceId = yieldId,
|
||||
Remaining = rem,
|
||||
HarvestPerHit = harvPerHit[i],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
enemyEntities.Dispose();
|
||||
enemyPositions.Dispose();
|
||||
harvEntity.Dispose();
|
||||
harvPos.Dispose();
|
||||
harvRemaining.Dispose();
|
||||
harvYieldId.Dispose();
|
||||
harvPerHit.Dispose();
|
||||
harvIsClutter.Dispose();
|
||||
harvVariant.Dispose();
|
||||
harvDestroyed.Dispose();
|
||||
harvToLedger.Dispose();
|
||||
meleePlayerByConn.Dispose();
|
||||
}
|
||||
|
||||
if (cleaves.IsCreated)
|
||||
|
||||
@@ -40,6 +40,9 @@ namespace ProjectM.Simulation
|
||||
public float HeatThreshold;
|
||||
public byte ScheduleEnabled;
|
||||
public uint ScheduleIntervalTicks;
|
||||
/// <summary>Extra Husks per surviving wave for a SCHEDULED base siege (size = SizeBase + this*WaveNumber). 0 = flat SizeBase.</summary>
|
||||
public int ScheduleSizePerWave;
|
||||
|
||||
}
|
||||
|
||||
/// <summary>Start-condition constants for <see cref="ThreatConfig.StartCondition"/> (bytes — never an enum, never in an RPC).</summary>
|
||||
|
||||
Reference in New Issue
Block a user