Map Updates

This commit is contained in:
2026-06-04 21:49:03 -07:00
parent 16b01bec38
commit 15bc1022ee
43 changed files with 4054 additions and 62 deletions
@@ -0,0 +1,35 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Generic authoring for a non-functional build-structure ghost prefab (Wall / Pylon) — duplicate
/// Turret.prefab so the ownerless interpolated GhostAuthoringComponent on PlacedStructure.Type comes free,
/// then swap TurretAuthoring for this. Bakes ONLY <see cref="PlacedStructure"/>{Type=<see cref="Kind"/>}
/// (no <see cref="Turret"/> stats, so TurretFireSystem ignores it). BuildPlaceSystem overrides Cell +
/// LastProcessedTick and adds RegionTag{Base} at placement. <see cref="Kind"/> is a byte (StructureType.*) to
/// dodge the cross-assembly enum-in-Burst hazard and the MCP enum-drop gotcha.
/// </summary>
public class StructureAuthoring : MonoBehaviour
{
[Tooltip("StructureType byte: 5 = Wall, 6 = Pylon (do NOT use 1-4: Turret + reserved M7 automation).")]
public byte Kind = StructureType.Wall;
private class StructureBaker : Baker<StructureAuthoring>
{
public override void Bake(StructureAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent(entity, new PlacedStructure
{
Type = authoring.Kind,
Cell = default,
NextTick = 0u,
LastProcessedTick = 0u,
});
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3f03349205fb1fe43bf6aaff14fce0b7
@@ -20,6 +20,18 @@ namespace ProjectM.Authoring
[Tooltip("Ore cost to build a turret.")]
[Min(0)] public int TurretCostOre = 10;
[Tooltip("Wall structure ghost prefab (StructureAuthoring{Wall} + GhostAuthoring).")]
public GameObject WallPrefab;
[Tooltip("Ore cost to build a wall.")]
[Min(0)] public int WallCostOre = 4;
[Tooltip("Pylon cosmetic-beacon ghost prefab (StructureAuthoring{Pylon} + GhostAuthoring).")]
public GameObject PylonPrefab;
[Tooltip("Ore cost to build a pylon.")]
[Min(0)] public int PylonCostOre = 2;
private class StructureCatalogBaker : Baker<StructureCatalogAuthoring>
{
public override void Bake(StructureCatalogAuthoring authoring)
@@ -38,6 +50,28 @@ namespace ProjectM.Authoring
CostAmount = authoring.TurretCostOre,
});
}
if (authoring.WallPrefab != null)
{
buf.Add(new StructureCatalogEntry
{
Type = StructureType.Wall,
Prefab = GetEntity(authoring.WallPrefab, TransformUsageFlags.Dynamic),
CostResourceId = ResourceId.Ore,
CostAmount = authoring.WallCostOre,
});
}
if (authoring.PylonPrefab != null)
{
buf.Add(new StructureCatalogEntry
{
Type = StructureType.Pylon,
Prefab = GetEntity(authoring.PylonPrefab, TransformUsageFlags.Dynamic),
CostResourceId = ResourceId.Ore,
CostAmount = authoring.PylonCostOre,
});
}
}
}
}
@@ -0,0 +1,45 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Authoring for a Blight-clutter ghost prefab (ownerless interpolated — duplicate from ResourceNode.prefab
/// so the GhostAuthoringComponent comes free). Bakes <see cref="BlightClutter"/> + <see cref="HitRadius"/>
/// (reused for the harvest/clear sweep) + <see cref="RegionTag"/>{Expedition} so GhostRelevancy scopes it to
/// expedition players. The field spawner overrides Variant (round-robin) + Position per instance. Defaults are
/// inline here (mirrors ResourceNodeAuthoring), not in Tuning.
/// </summary>
public class BlightClutterAuthoring : MonoBehaviour
{
[Tooltip("Hit-points before the clutter shatters.")]
[Min(1)] public int Remaining = 8;
[Tooltip("Scrap (Biomass) yielded per projectile hit — the 'minor scrap' trickle.")]
[Min(1f)] public float ScrapPerHit = 2f;
[Tooltip("Hit radius (world units) for the clear sweep.")]
[Min(0f)] public float HitRadius = 1.0f;
[Tooltip("Default visual variant (the spawner round-robins this per piece).")]
public byte Variant = 0;
private class BlightClutterBaker : Baker<BlightClutterAuthoring>
{
public override void Bake(BlightClutterAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent(entity, new BlightClutter
{
Remaining = authoring.Remaining,
Variant = authoring.Variant,
ScrapResourceId = ResourceId.Biomass,
ScrapPerHit = authoring.ScrapPerHit,
});
AddComponent(entity, new HitRadius { Value = authoring.HitRadius });
AddComponent(entity, new RegionTag { Region = RegionId.Expedition });
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6b1aa9b83194a2c41b940dd9532377f4
@@ -0,0 +1,39 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Authoring for the baked <see cref="ClutterFieldSpawner"/> singleton (mirrors ResourceFieldSpawnerAuthoring).
/// Place once in the gameplay subscene and assign the Blight-clutter ghost prefab; ExpeditionFieldSystem
/// scatters the clutter each expedition alongside the resource field. Carries no transform.
/// </summary>
public class ClutterFieldSpawnerAuthoring : MonoBehaviour
{
[Tooltip("Blight-clutter ghost prefab. Must carry BlightClutterAuthoring + a GhostAuthoringComponent (ownerless, interpolated).")]
public GameObject ClutterPrefab;
[Tooltip("Number of clutter pieces per expedition.")]
[Min(1)] public int Count = 14;
[Tooltip("Scatter radius (world units) around the expedition origin.")]
[Min(1f)] public float Radius = 14f;
private class ClutterFieldSpawnerBaker : Baker<ClutterFieldSpawnerAuthoring>
{
public override void Bake(ClutterFieldSpawnerAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.None);
AddComponent(entity, new ClutterFieldSpawner
{
Prefab = authoring.ClutterPrefab != null
? GetEntity(authoring.ClutterPrefab, TransformUsageFlags.Dynamic)
: Entity.Null,
Count = authoring.Count,
Radius = authoring.Radius,
});
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 02623cc30f984104b88a75782bf0dd07
@@ -30,6 +30,12 @@ namespace ProjectM.Client
/// <summary>EDITOR / execute_code hook: queue a turret placement at a specific cell.</summary>
public static void PlaceTurret(int cellX, int cellZ) => PlaceStructure(StructureType.Turret, cellX, cellZ);
/// <summary>EDITOR / execute_code hook: queue a wall placement at a specific cell.</summary>
public static void PlaceWall(int cellX, int cellZ) => PlaceStructure(StructureType.Wall, cellX, cellZ);
/// <summary>EDITOR / execute_code hook: queue a pylon placement at a specific cell.</summary>
public static void PlacePylon(int cellX, int cellZ) => PlaceStructure(StructureType.Pylon, cellX, cellZ);
/// <summary>EDITOR / execute_code hook: queue an ability-damage upgrade.</summary>
public static void UpgradeAbility() => s_PendingUpgrades++;
#endif
@@ -49,6 +55,10 @@ namespace ProjectM.Client
{
if (keyboard.bKey.wasPressedThisFrame && TryGetLocalPlayerCell(out int2 cell))
SendBuild(connection, StructureType.Turret, cell.x, cell.y);
if (keyboard.vKey.wasPressedThisFrame && TryGetLocalPlayerCell(out int2 wcell))
SendBuild(connection, StructureType.Wall, wcell.x, wcell.y);
if (keyboard.nKey.wasPressedThisFrame && TryGetLocalPlayerCell(out int2 pcell))
SendBuild(connection, StructureType.Pylon, pcell.x, pcell.y);
if (keyboard.uKey.wasPressedThisFrame)
SendUpgrade(connection);
}
@@ -0,0 +1,226 @@
using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-only WORLD JUICE for harvesting resource nodes + smashing Blight clutter. A managed presentation
/// system (SystemBase, main thread, NO Burst) in <see cref="PresentationSystemGroup"/> that REACTS to
/// replicated state — it never runs simulation. Each frame it edge-detects every node/clutter ghost's
/// replicated <c>Remaining</c>: a decrease spawns a small tinted chip burst + soft SFX; a despawn (the server
/// destroyed it — depletion or shatter) spawns a clear burst + SFX, and clutter adds a camera punch (the
/// "carve through the frontier" smash). A PROXIMITY GATE suppresses the prune VFX unless the despawned
/// entity's last position was near the local player, so the region-transit despawn storm at +1000 X stays
/// silent off-camera (GhostRelevancy drops every expedition ghost at once when the player walks home).
/// Procedural particles + procedural SFX (mirrors CombatFeedbackSystem; self-contained); knobs live in
/// <see cref="WorldFeelConfig"/>. Never destroys a ghost — GhostDespawnSystem owns despawn; we only OBSERVE.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class WorldFeedbackSystem : SystemBase
{
struct Cache { public int Remaining; public float3 Pos; public bool IsClutter; public Color Tint; }
readonly Dictionary<Entity, Cache> _cache = new();
readonly HashSet<Entity> _seen = new();
readonly List<Entity> _stale = new();
Transform _fxRoot;
ParticleSystem _chipFx;
ParticleSystem _clearFx;
AudioClip _chipClip;
AudioClip _clearClip;
protected override void OnCreate()
{
_chipClip = MakeClip("harvest_chip", 900f, 1400f, 0.06f, 0.30f);
_clearClip = MakeClip("clutter_clear", 420f, 90f, 0.22f, 0.50f);
}
protected override void OnStartRunning()
{
if (_fxRoot != null) return;
_fxRoot = new GameObject("~WorldFeedbackFX").transform;
var mat = MakeParticleMaterial();
_chipFx = MakeBurst("HarvestChips", mat, new Color(2.6f, 1.9f, 0.7f), 0.10f, 5f, 0.30f, 256);
_clearFx = MakeBurst("ClutterClear", mat, new Color(3.0f, 1.1f, 0.25f), 0.16f, 7f, 0.45f, 512);
}
protected override void OnDestroy()
{
if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject);
}
protected override void OnUpdate()
{
if (!WorldFeelConfig.Enabled) { _cache.Clear(); return; }
// Complete predicted/interpolation jobs writing these before the main-thread reads.
EntityManager.CompleteDependencyBeforeRO<ResourceNode>();
EntityManager.CompleteDependencyBeforeRO<BlightClutter>();
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
// Local player position (for the proximity gate).
bool haveLocal = false;
float3 localPos = default;
foreach (var xf in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<GhostOwnerIsLocal, PlayerTag>())
{
localPos = xf.ValueRO.Position;
haveLocal = true;
}
_seen.Clear();
// Resource nodes — chip on depletion.
foreach (var (node, xf, e) in
SystemAPI.Query<RefRO<ResourceNode>, RefRO<LocalTransform>>().WithEntityAccess())
{
_seen.Add(e);
Observe(e, node.ValueRO.Remaining, xf.ValueRO.Position, false, TintForResource(node.ValueRO.ResourceId));
}
// Blight clutter — chip on damage.
foreach (var (clutter, xf, e) in
SystemAPI.Query<RefRO<BlightClutter>, RefRO<LocalTransform>>().WithEntityAccess())
{
_seen.Add(e);
Observe(e, clutter.ValueRO.Remaining, xf.ValueRO.Position, true, WorldFeelConfig.WildTint);
}
// Prune: a despawn = the server destroyed it (node depleted / clutter shattered). Gate on proximity so
// a region-transit despawn storm (every expedition ghost dropped at once, far at +1000 X) stays silent.
if (_cache.Count != _seen.Count)
{
_stale.Clear();
foreach (var kv in _cache)
if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
float rangeSq = WorldFeelConfig.ProximityRange * WorldFeelConfig.ProximityRange;
for (int i = 0; i < _stale.Count; i++)
{
var c = _cache[_stale[i]];
if (haveLocal && math.distancesq(c.Pos, localPos) <= rangeSq)
{
EmitTinted(_clearFx, (Vector3)c.Pos + Vector3.up * 0.6f, WorldFeelConfig.ClearBurstCount, c.Tint);
PlayClip(_clearClip, (Vector3)c.Pos, WorldFeelConfig.ClearSfxVolume);
if (c.IsClutter)
{
PrototypeCameraRig.PunchFov(WorldFeelConfig.ClearFovKick, 90f);
PrototypeCameraRig.AddShake(WorldFeelConfig.ClearShake);
}
}
_cache.Remove(_stale[i]);
}
}
}
void Observe(Entity e, int remaining, float3 pos, bool isClutter, Color tint)
{
if (_cache.TryGetValue(e, out var prev) && remaining < prev.Remaining)
{
EmitTinted(_chipFx, (Vector3)pos + Vector3.up * 0.6f, WorldFeelConfig.ChipBurstCount, tint);
PlayClip(_chipClip, (Vector3)pos, WorldFeelConfig.ChipSfxVolume);
}
_cache[e] = new Cache { Remaining = remaining, Pos = pos, IsClutter = isClutter, Tint = tint };
}
static Color TintForResource(byte resourceId)
{
if (resourceId == ResourceId.Ore) return WorldFeelConfig.OreTint;
if (resourceId == ResourceId.Biomass) return WorldFeelConfig.BiomassTint;
return WorldFeelConfig.WildTint; // Aether + default
}
// ---- procedural particles + SFX (mirrors CombatFeedbackSystem; self-contained) ----
static void EmitTinted(ParticleSystem ps, Vector3 pos, int count, Color tint)
{
if (ps == null) return;
var main = ps.main;
main.startColor = tint;
ps.transform.position = pos;
ps.Emit(count);
}
static Material MakeParticleMaterial()
{
Shader sh = Shader.Find("Sprites/Default");
if (sh == null) sh = Shader.Find("Universal Render Pipeline/Particles/Unlit");
if (sh == null) sh = Shader.Find("Unlit/Color");
return new Material(sh) { name = "WorldFeedbackParticle" };
}
ParticleSystem MakeBurst(string name, Material mat, Color color, float size, float speed, float life, int max)
{
var go = new GameObject(name);
go.transform.SetParent(_fxRoot, false);
var ps = go.AddComponent<ParticleSystem>();
var main = ps.main;
main.loop = false;
main.playOnAwake = false;
main.startLifetime = life;
main.startSpeed = speed;
main.startSize = size;
main.startColor = color;
main.maxParticles = max;
main.gravityModifier = 0.25f;
main.simulationSpace = ParticleSystemSimulationSpace.World;
var emission = ps.emission;
emission.enabled = false; // manual Emit(count)
var shape = ps.shape;
shape.enabled = true;
shape.shapeType = ParticleSystemShapeType.Sphere;
shape.radius = 0.10f;
var colOverLife = ps.colorOverLifetime;
colOverLife.enabled = true;
var grad = new Gradient();
grad.SetKeys(
new[] { new GradientColorKey(Color.white, 0f), new GradientColorKey(Color.white, 1f) },
new[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(0f, 1f) });
colOverLife.color = new ParticleSystem.MinMaxGradient(grad);
var sizeOverLife = ps.sizeOverLifetime;
sizeOverLife.enabled = true;
sizeOverLife.size = new ParticleSystem.MinMaxCurve(1f, AnimationCurve.Linear(0f, 1f, 1f, 0.2f));
var renderer = ps.GetComponent<ParticleSystemRenderer>();
renderer.material = mat;
renderer.renderMode = ParticleSystemRenderMode.Billboard;
return ps;
}
static AudioClip MakeClip(string name, float f0, float f1, float dur, float vol)
{
const int rate = 44100;
int len = Mathf.Max(16, (int)(dur * rate));
var clip = AudioClip.Create(name, len, 1, rate, false);
var data = new float[len];
float phase = 0f;
for (int i = 0; i < len; i++)
{
float t = i / (float)len;
float env = Mathf.Exp(-5f * t);
float freq = Mathf.Lerp(f0, f1, t);
phase += 2f * Mathf.PI * freq / rate;
data[i] = Mathf.Sin(phase) * env * vol;
}
clip.SetData(data, 0);
return clip;
}
static void PlayClip(AudioClip clip, Vector3 pos, float vol)
{
if (clip == null) return;
AudioSource.PlayClipAtPoint(clip, pos, vol);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 470fdf29ccc332d42bb93e0b1e249dfb
@@ -0,0 +1,61 @@
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Live-tunable knobs for the client-only WORLD-FEEDBACK slice (harvesting nodes + smashing Blight clutter).
/// A static bridge (mirrors <see cref="FeelConfig"/>) so values can be poked at runtime via MCP execute_code
/// without a recompile (e.g. <c>ProjectM.Client.WorldFeelConfig.ClearFovKick = 1.2f;</c>). Read ONLY by
/// <see cref="WorldFeedbackSystem"/> (managed, main-thread). NEVER read from a [BurstCompile] system
/// (managed-static + Color-in-Burst hazards). <see cref="ResetDefaults"/> re-stamps on play-enter via
/// [RuntimeInitializeOnLoadMethod] because statics survive fast-enter-playmode reloads (else a poked value
/// leaks across play-enters).
/// </summary>
public static class WorldFeelConfig
{
/// <summary>Master gate for harvest/clear feedback.</summary>
public static bool Enabled;
/// <summary>Particle burst when a node/clutter loses hit-points (a chip).</summary>
public static int ChipBurstCount;
/// <summary>Particle burst when a node depletes / clutter shatters (the clear).</summary>
public static int ClearBurstCount;
/// <summary>Soft SFX volume on a chip.</summary>
public static float ChipSfxVolume;
/// <summary>SFX volume on a shatter / deplete.</summary>
public static float ClearSfxVolume;
/// <summary>Camera FOV kick (deg) when clutter shatters near the player — the satisfying smash. 0 = off.</summary>
public static float ClearFovKick;
/// <summary>Camera shake when clutter shatters near the player.</summary>
public static float ClearShake;
/// <summary>Only fire prune VFX when the despawned entity's last position is within this distance of the
/// local player, so a region-transit despawn storm at +1000 X stays silent off-camera.</summary>
public static float ProximityRange;
/// <summary>Tint for wild-Aether clutter shatter + Aether-node chips (HDR orange, pushes past bloom).</summary>
public static Color WildTint;
/// <summary>Tint for Ore-node chips (HDR amber).</summary>
public static Color OreTint;
/// <summary>Tint for Biomass-node chips (HDR sickly green).</summary>
public static Color BiomassTint;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
public static void ResetDefaults()
{
Enabled = true;
ChipBurstCount = 6;
ClearBurstCount = 18;
ChipSfxVolume = 0.30f;
ClearSfxVolume = 0.55f;
ClearFovKick = 0.8f;
ClearShake = 0.12f;
ProximityRange = 40f;
WildTint = new Color(3.0f, 1.1f, 0.25f);
OreTint = new Color(2.6f, 1.9f, 0.7f);
BiomassTint = new Color(0.9f, 2.4f, 0.8f);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1d6f72b25a2a1d14e988b5999e029c5c
@@ -12,10 +12,13 @@ namespace ProjectM.Server
/// counts players whose server-only <see cref="RegionTag"/> is the Expedition region, and on the
/// empty-&gt;occupied edge (a new sortie) bumps <see cref="CycleRuntime.ExpeditionEpoch"/> and scatters
/// <see cref="ResourceFieldSpawner.Count"/> resource-node ghosts (seeded by the epoch) around the expedition
/// origin, each RegionTag{Expedition}; on the occupied-&gt;empty edge (the LAST player left) it destroys every
/// node. So the field lives as long as anyone is out there, not on a global timer. Plain server
/// SimulationSystemGroup. Server-authoritative; clients despawn nodes via GhostDespawnSystem. Per-epoch
/// reproducible (the seed is the monotonic int epoch, compared by equality — never tick math, never 0).
/// origin — PLUS, if a <see cref="ClutterFieldSpawner"/> singleton is present,
/// <see cref="ClutterFieldSpawner.Count"/> Blight-clutter ghosts (seeded DISTINCTLY so clutter and nodes don't
/// co-locate, Variant round-robined), each RegionTag{Expedition}; on the occupied-&gt;empty edge (the LAST
/// player left) it destroys every node AND every clutter piece. So the field lives as long as anyone is out
/// there, not on a global timer. Plain server SimulationSystemGroup. Server-authoritative; clients despawn
/// ghosts via GhostDespawnSystem. Per-epoch reproducible (the seed is the monotonic int epoch, compared by
/// equality — never tick math, never 0).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
@@ -80,14 +83,42 @@ namespace ProjectM.Server
rn.ResourceId = (byte)(ResourceId.Aether + (byte)(i % 3));
ecb.SetComponent(node, rn);
}
// Blight clutter (OPTIONAL singleton): scatter alongside the nodes with a DISTINCT seed so the
// two fields don't co-locate. Round-robin Variant for client visual variety.
if (SystemAPI.TryGetSingleton<ClutterFieldSpawner>(out var clutterSpawner)
&& clutterSpawner.Prefab != Entity.Null)
{
var clutterXform = SystemAPI.GetComponent<LocalTransform>(clutterSpawner.Prefab);
var prefabClutter = SystemAPI.GetComponent<BlightClutter>(clutterSpawner.Prefab);
var crng = new Random((uint)math.max(1, runtime.ExpeditionEpoch * 2 + 1));
int ccount = math.max(1, clutterSpawner.Count);
for (int i = 0; i < ccount; i++)
{
var piece = ecb.Instantiate(clutterSpawner.Prefab);
float ang = crng.NextFloat(0f, math.PI * 2f);
float rad = clutterSpawner.Radius * math.sqrt(crng.NextFloat(0f, 1f));
var xform = clutterXform;
xform.Position = origin + new float3(math.cos(ang) * rad, 0f, math.sin(ang) * rad);
ecb.SetComponent(piece, xform);
var bc = prefabClutter;
bc.Variant = (byte)(i % 3);
ecb.SetComponent(piece, bc);
}
}
runtime.LastSpawnedEpoch = runtime.ExpeditionEpoch;
}
// DESTROY: the last player left the expedition — clear the whole field.
// 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);
foreach (var (bc, e) in SystemAPI.Query<RefRO<BlightClutter>>().WithEntityAccess())
ecb.DestroyEntity(e);
}
runtime.PrevExpeditionOccupied = (byte)(occupied ? 1 : 0);
@@ -9,18 +9,21 @@ 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.
/// Server-only resource harvest + Blight-clutter clearing: sweeps each surviving projectile's this-tick travel
/// segment against a UNIFIED target set of resource-node ghosts AND Blight-clutter ghosts, deposits the hit
/// target's yield 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) and decrements its Remaining; the target despawns at &lt;= 0. Nodes deposit
/// <see cref="ResourceNode.ResourceId"/> @ HarvestPerHit; clutter deposits <see cref="BlightClutter.ScrapResourceId"/>
/// @ ScrapPerHit (a small "minor scrap" trickle — carving through the frontier). UNIFYING the two into one sweep
/// is a CORRECTNESS requirement: two separate sweeps would each DestroyEntity a projectile that overlaps a node
/// AND a clutter piece — a double DestroyEntity throws at ECB playback. Runs in the plain server
/// SimulationSystemGroup <c>[UpdateAfter(PredictedSimulationSystemGroup)]</c> — after ProjectileDamageSystem has
/// 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 target 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 reach an expedition target.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
@@ -34,7 +37,6 @@ namespace ProjectM.Server
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<Projectile>();
state.RequireForUpdate<ResourceNode>();
state.RequireForUpdate<ResourceLedger>();
}
@@ -44,26 +46,43 @@ namespace ProjectM.Server
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);
// Snapshot all harvest/clear targets (nodes + clutter) once this tick into a UNIFIED set.
var tgtEntity = new NativeList<Entity>(Allocator.Temp);
var tgtPos = new NativeList<float2>(Allocator.Temp);
var tgtRadius = new NativeList<float>(Allocator.Temp);
var tgtRemaining = new NativeList<int>(Allocator.Temp);
var tgtYieldId = new NativeList<byte>(Allocator.Temp);
var tgtYieldPerHit = new NativeList<float>(Allocator.Temp);
var tgtVariant = new NativeList<byte>(Allocator.Temp);
var tgtIsClutter = new NativeList<bool>(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);
tgtEntity.Add(e);
tgtPos.Add(xform.ValueRO.Position.xz);
tgtRadius.Add(hr.ValueRO.Value);
tgtRemaining.Add(node.ValueRO.Remaining);
tgtYieldId.Add(node.ValueRO.ResourceId);
tgtYieldPerHit.Add(node.ValueRO.HarvestPerHit);
tgtVariant.Add(0);
tgtIsClutter.Add(false);
}
var destroyed = new NativeArray<bool>(nodeEntities.Length, Allocator.Temp);
foreach (var (xform, hr, clutter, e) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>, RefRO<BlightClutter>>().WithEntityAccess())
{
tgtEntity.Add(e);
tgtPos.Add(xform.ValueRO.Position.xz);
tgtRadius.Add(hr.ValueRO.Value);
tgtRemaining.Add(clutter.ValueRO.Remaining);
tgtYieldId.Add(clutter.ValueRO.ScrapResourceId);
tgtYieldPerHit.Add(clutter.ValueRO.ScrapPerHit);
tgtVariant.Add(clutter.ValueRO.Variant);
tgtIsClutter.Add(true);
}
var destroyed = new NativeArray<bool>(tgtEntity.Length, Allocator.Temp);
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (xform, proj, projEntity) in
@@ -77,27 +96,34 @@ namespace ProjectM.Server
int bestIdx = -1;
float bestT = float.MaxValue;
for (int i = 0; i < nodeEntities.Length; i++)
bool overlappedCleared = false; // struck a target a sibling projectile already cleared THIS tick
for (int i = 0; i < tgtEntity.Length; i++)
{
if (destroyed[i]) continue;
float2 tp = nodePos[i];
float2 tp = tgtPos[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;
}
float hitDist = tgtRadius[i] + k_ProjectileRadius;
if (math.distancesq(tp, closest) > hitDist * hitDist)
continue;
if (destroyed[i]) { overlappedCleared = true; continue; }
if (t < bestT) { bestT = t; bestIdx = i; }
}
if (bestIdx < 0)
{
// No LIVE target on the segment. If the shot still overlapped a target a sibling projectile
// cleared this same tick, consume it anyway (a hit always spends the shot); else it's a miss.
if (overlappedCleared)
ecb.DestroyEntity(projEntity);
continue;
}
int amount = (int)nodePerHit[bestIdx];
StorageMath.Deposit(ledger, nodeResource[bestIdx], amount);
int rem = nodeRemaining[bestIdx] - amount;
nodeRemaining[bestIdx] = rem;
// A positive baked yield must always make progress: a raw (int) truncation of a sub-1.0 per-hit
// value would deposit 0 AND never decrement Remaining -> an immortal target that silently eats shots.
int amount = math.max(1, (int)tgtYieldPerHit[bestIdx]);
StorageMath.Deposit(ledger, tgtYieldId[bestIdx], amount);
int rem = tgtRemaining[bestIdx] - amount;
tgtRemaining[bestIdx] = rem;
ecb.DestroyEntity(projEntity);
if (rem <= 0)
@@ -105,17 +131,27 @@ namespace ProjectM.Server
if (!destroyed[bestIdx])
{
destroyed[bestIdx] = true;
ecb.DestroyEntity(nodeEntities[bestIdx]);
ecb.DestroyEntity(tgtEntity[bestIdx]);
}
}
else if (tgtIsClutter[bestIdx])
{
// Persist the decremented Remaining (replicated GhostField) so depletion carries across ticks.
SystemAPI.SetComponent(tgtEntity[bestIdx], new BlightClutter
{
Remaining = rem,
Variant = tgtVariant[bestIdx],
ScrapResourceId = tgtYieldId[bestIdx],
ScrapPerHit = tgtYieldPerHit[bestIdx],
});
}
else
{
// Persist the decremented Remaining (replicated GhostField) so depletion carries across ticks.
SystemAPI.SetComponent(nodeEntities[bestIdx], new ResourceNode
SystemAPI.SetComponent(tgtEntity[bestIdx], new ResourceNode
{
ResourceId = nodeResource[bestIdx],
ResourceId = tgtYieldId[bestIdx],
Remaining = rem,
HarvestPerHit = nodePerHit[bestIdx],
HarvestPerHit = tgtYieldPerHit[bestIdx],
});
}
}
@@ -123,12 +159,14 @@ namespace ProjectM.Server
ecb.Playback(state.EntityManager);
ecb.Dispose();
destroyed.Dispose();
nodeEntities.Dispose();
nodePos.Dispose();
nodeRadius.Dispose();
nodeRemaining.Dispose();
nodeResource.Dispose();
nodePerHit.Dispose();
tgtEntity.Dispose();
tgtPos.Dispose();
tgtRadius.Dispose();
tgtRemaining.Dispose();
tgtYieldId.Dispose();
tgtYieldPerHit.Dispose();
tgtVariant.Dispose();
tgtIsClutter.Dispose();
}
}
}
@@ -17,6 +17,10 @@ namespace ProjectM.Simulation
public const byte Harvester = 2;
public const byte Fabricator = 3;
public const byte Conveyor = 4;
// World-pass structural/cosmetic build-out (additive; these byte values keep PlacedStructure.Type's
// [GhostField] serializer identical — no re-bake of existing ghosts). Do NOT reuse 2-4 (M7 automation).
public const byte Wall = 5;
public const byte Pylon = 6;
}
/// <summary>
@@ -0,0 +1,33 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Destructible "Blight clutter" scattered across the expedition frontier — an ownerless INTERPOLATED ghost
/// (region-tagged Expedition; the <see cref="ResourceNode"/> sibling) that clients see and smash. The
/// server-only ResourceHarvestSystem sweeps projectiles against it UNIFIED with node harvesting (so an
/// overlapping projectile is consumed exactly once — two separate sweeps would double-DestroyEntity the
/// projectile and throw at ECB playback). Each hit deposits <see cref="ScrapPerHit"/> of
/// <see cref="ScrapResourceId"/> (a small trickle — "juice + minor scrap", no gating) into the GLOBAL
/// resource ledger and decrements <see cref="Remaining"/>; the clutter despawns at &lt;= 0.
/// <see cref="Remaining"/>/<see cref="Variant"/> are [GhostField] so clients can show depletion + pick a smash
/// visual; <see cref="ScrapResourceId"/>/<see cref="ScrapPerHit"/> are baked, server-only. Distinct from
/// <see cref="ResourceNode"/> so it carries its own visuals, scatter density and client "clear" feedback while
/// reusing the exact, tunnel-safe harvest hit-test.
/// </summary>
public struct BlightClutter : IComponentData
{
/// <summary>Remaining hit-points; the clutter despawns when this reaches 0.</summary>
[GhostField] public int Remaining;
/// <summary>Visual variant id (the client picks a mesh/tint; round-robined by the field spawner).</summary>
[GhostField] public byte Variant;
/// <summary>Scrap resource yielded per hit (baked; server-only) — see <see cref="ResourceId"/> (Biomass).</summary>
public byte ScrapResourceId;
/// <summary>Scrap units yielded per projectile hit (baked; server-only).</summary>
public float ScrapPerHit;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dd7823b664bb48e4e89d64439997d147
@@ -0,0 +1,24 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Baked singleton holding the Blight-clutter ghost prefab + field shape. ExpeditionFieldSystem reads it
/// (alongside <see cref="ResourceFieldSpawner"/>) to scatter <see cref="Count"/> clutter ghosts within
/// <see cref="Radius"/> of the expedition origin on the SAME empty-&gt;occupied epoch edge as the resource
/// field (seeded distinctly so clutter and nodes don't co-locate), and clears them on occupied-&gt;empty.
/// OPTIONAL — if the singleton is absent, ExpeditionFieldSystem simply skips clutter. Mirrors
/// <see cref="ResourceFieldSpawner"/>; carries no transform.
/// </summary>
public struct ClutterFieldSpawner : IComponentData
{
/// <summary>Baked Blight-clutter ghost prefab to instantiate.</summary>
public Entity Prefab;
/// <summary>Number of clutter pieces 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: 22d21ff4c74c22443ba94c88db9ec099