diff --git a/Assets/_Project/Scripts/Client/Presentation/CoverDamageSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CoverDamageSystem.cs
new file mode 100644
index 000000000..6120fea60
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Presentation/CoverDamageSystem.cs
@@ -0,0 +1,177 @@
+using System.Collections.Generic;
+using ProjectM.Simulation;
+using Unity.Entities;
+using Unity.Mathematics;
+using Unity.NetCode;
+using Unity.Transforms;
+using UnityEngine;
+using static ProjectM.Client.FeedbackFx;
+
+namespace ProjectM.Client
+{
+ ///
+ /// Client-only COVER DAMAGE decals (Phase 1.5b bundle 2 — decals). Destructible cover = a
+ /// ghost (Variant 4, Remaining 8 → 0; see the Destructible_Cover_Build_Spec). This
+ /// observe-only edge-detects the replicated
+ /// on cover ghosts and accretes jagged CRACK decals on the ground around
+ /// the rock as it is carved ( already fires the chip puff / SFX / camera-punch
+ /// on the same edge — we only add the persistent cracks). On despawn (the rock shattered NEAR the local player)
+ /// it drops a small ground scorch via and releases the piece's
+ /// cracks. The despawn scorch is PROXIMITY-GATED (mirrors WorldFeedbackSystem) so a room-teardown / region-transit
+ /// despawn storm — every cover ghost dropped at once, far off-camera — can't spray scorches or exhaust the pool.
+ ///
+ /// Pure procedural decal quads — NO material/shader dependency. This is deliberate: cover's Synty prop shader
+ /// declares _BaseColor as Unity-Per-Material (NOT Hybrid-Per-Instance), so the
+ /// URPMaterialPropertyBaseColor darken that uses on the DOTS-authored
+ /// AnimatedLitShader would silently no-op here. Decals sidestep that entirely and read as literal damage.
+ ///
+ /// No new component / [GhostField], no server work. Knobs live in .
+ ///
+ [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
+ [UpdateInGroup(typeof(PresentationSystemGroup))]
+ public partial class CoverDamageSystem : SystemBase
+ {
+ const byte CoverVariant = 4;
+
+ class Piece
+ {
+ public int LastRemaining;
+ public int MaxRemaining;
+ public float3 Pos;
+ public readonly List Cracks = new();
+ }
+
+ readonly Dictionary _tracked = new();
+ readonly HashSet _seen = new();
+ readonly List _stale = new();
+
+ GameObject _root;
+ Material _mat;
+ Mesh[] _crackMeshes; // pre-built variants, reused across all cracks (no per-crack Mesh alloc/leak)
+ MaterialPropertyBlock _mpb;
+ static readonly int ColorId = Shader.PropertyToID("_Color");
+
+ protected override void OnCreate()
+ {
+ _crackMeshes = new Mesh[6];
+ for (int i = 0; i < _crackMeshes.Length; i++) _crackMeshes[i] = BuildCrackMesh(2 + (i % 2), i + 1);
+ _mpb = new MaterialPropertyBlock();
+ }
+
+ protected override void OnDestroy()
+ {
+ if (_root != null) Object.Destroy(_root);
+ if (_mat != null) Object.Destroy(_mat);
+ if (_crackMeshes != null)
+ for (int i = 0; i < _crackMeshes.Length; i++)
+ if (_crackMeshes[i] != null) Object.Destroy(_crackMeshes[i]);
+ }
+
+ protected override void OnUpdate()
+ {
+ if (UnityEngine.SceneManagement.SceneManager.GetActiveScene().name != "Game") return;
+ if (!DecalConfig.Enabled) { if (_tracked.Count > 0) ClearAll(); return; }
+
+ EntityManager.CompleteDependencyBeforeRO();
+ EntityManager.CompleteDependencyBeforeRO();
+
+ if (_root == null)
+ {
+ _root = new GameObject("~CoverDecals");
+ Object.DontDestroyOnLoad(_root);
+ _mat = MakeDecalMaterial("CoverCrackDecal");
+ }
+
+ // Local player position for the shatter-scorch proximity gate (mirrors WorldFeedbackSystem).
+ bool haveLocal = false;
+ float3 localPos = default;
+ foreach (var xf in SystemAPI.Query>().WithAll())
+ {
+ localPos = xf.ValueRO.Position;
+ haveLocal = true;
+ }
+
+ _seen.Clear();
+ foreach (var (clutter, xf, e) in
+ SystemAPI.Query, RefRO>().WithEntityAccess())
+ {
+ if (clutter.ValueRO.Variant != CoverVariant) continue; // cover only
+ _seen.Add(e);
+ int remaining = clutter.ValueRO.Remaining;
+ float3 pos = xf.ValueRO.Position;
+ if (!_tracked.TryGetValue(e, out var piece))
+ {
+ // First sight: cache the baseline; a joiner mid-carve keeps its already-damaged look implicitly.
+ _tracked[e] = new Piece { LastRemaining = remaining, MaxRemaining = math.max(1, remaining), Pos = pos };
+ continue;
+ }
+ piece.Pos = pos;
+ if (remaining < piece.LastRemaining)
+ {
+ // accrete cracks proportional to damage taken; cap at CoverCrackMaxCount.
+ float dmgFrac = 1f - remaining / (float)piece.MaxRemaining;
+ int want = math.clamp((int)math.round(DecalConfig.CoverCrackMaxCount * dmgFrac), 0, DecalConfig.CoverCrackMaxCount);
+ while (piece.Cracks.Count < want) SpawnCrack(piece, piece.Cracks.Count);
+ piece.LastRemaining = remaining;
+ }
+ }
+
+ // ---- prune shattered cover ----
+ if (_tracked.Count != _seen.Count)
+ {
+ float rangeSq = WorldFeelConfig.ProximityRange * WorldFeelConfig.ProximityRange;
+ _stale.Clear();
+ foreach (var kv in _tracked) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
+ for (int i = 0; i < _stale.Count; i++)
+ {
+ var piece = _tracked[_stale[i]];
+ // Only a real shatter NEAR the local player leaves a scorch; a teardown / region-transit despawn
+ // storm (all cover dropped at once, off-camera) stays silent — no spray, no pool exhaustion.
+ if (DecalConfig.CoverShatterScorch && haveLocal && math.distancesq(piece.Pos, localPos) <= rangeSq)
+ ScorchDecalSystem.RequestScorch((Vector3)piece.Pos, 1.6f);
+ DestroyCracks(piece);
+ _tracked.Remove(_stale[i]);
+ }
+ }
+ }
+
+ void SpawnCrack(Piece piece, int index)
+ {
+ uint seed = (uint)math.max(1, index * 131 + (int)(math.abs(piece.Pos.x) * 7f + math.abs(piece.Pos.z) * 13f));
+ var rng = new Unity.Mathematics.Random(seed);
+ var go = new GameObject("Crack");
+ go.transform.SetParent(_root.transform, false);
+ go.AddComponent().sharedMesh = _crackMeshes[(index + (int)(seed % 3u)) % _crackMeshes.Length];
+ var mr = go.AddComponent();
+ mr.sharedMaterial = _mat;
+ mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
+ mr.receiveShadows = false;
+ mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
+ _mpb.SetColor(ColorId, DecalConfig.CoverCrackColor);
+ mr.SetPropertyBlock(_mpb);
+
+ // scatter as GROUND cracks radiating from the rock's footprint (avoids z-fighting the rock mesh)
+ float ang = rng.NextFloat(0f, math.PI * 2f);
+ float rad = rng.NextFloat(0.8f, 1.8f);
+ float scale = rng.NextFloat(0.9f, 1.6f);
+ go.transform.SetPositionAndRotation(
+ new Vector3(piece.Pos.x + math.cos(ang) * rad, 0.06f + 0.003f * index, piece.Pos.z + math.sin(ang) * rad),
+ Quaternion.Euler(0f, rng.NextFloat(0f, 360f), 0f));
+ go.transform.localScale = new Vector3(scale, 1f, scale);
+ piece.Cracks.Add(go);
+ }
+
+ void DestroyCracks(Piece piece)
+ {
+ for (int c = 0; c < piece.Cracks.Count; c++)
+ if (piece.Cracks[c] != null) Object.Destroy(piece.Cracks[c]);
+ piece.Cracks.Clear();
+ }
+
+ void ClearAll()
+ {
+ foreach (var kv in _tracked) DestroyCracks(kv.Value);
+ _tracked.Clear();
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/Presentation/CoverDamageSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/CoverDamageSystem.cs.meta
new file mode 100644
index 000000000..f6ccfe890
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Presentation/CoverDamageSystem.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 63769d219acf0b248b321ef76bdf4189
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/Presentation/DecalConfig.cs b/Assets/_Project/Scripts/Client/Presentation/DecalConfig.cs
new file mode 100644
index 000000000..56f344f95
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Presentation/DecalConfig.cs
@@ -0,0 +1,68 @@
+using UnityEngine;
+
+namespace ProjectM.Client
+{
+ ///
+ /// Live-tunable knobs for the client-only DECALS slice (Phase 1.5b bundle 2: explosion scorch pool, cover
+ /// damage cracks, room-arena scars). A static bridge (mirrors / )
+ /// so values can be poked at runtime via MCP execute_code without a recompile
+ /// (e.g. ProjectM.Client.DecalConfig.ScorchColor = ...;). Read ONLY by the decal presentation systems
+ /// (, ) and — all
+ /// managed, main-thread. NEVER read from a [BurstCompile] system (managed-static + Color-in-Burst hazards).
+ /// re-stamps on play-enter via [RuntimeInitializeOnLoadMethod] because statics survive
+ /// fast-enter-playmode reloads (else a poked value leaks across play-enters).
+ ///
+ public static class DecalConfig
+ {
+ /// Master gate for every decal (scorch pool + cover cracks + room scars).
+ public static bool Enabled;
+
+ // ---- explosion scorch pool (ScorchDecalSystem) ----
+ /// Scorch disc radius as a multiple of the explosion radius passed to RequestScorch.
+ public static float ScorchRadiusMul;
+ /// Seconds a fresh scorch holds at full strength before it starts fading.
+ public static float ScorchHoldSec;
+ /// Seconds a scorch fades from full to gone (then returns to the pool).
+ public static float ScorchFadeSec;
+ /// Peak scorch tint (dark char); the alpha is the peak opacity it fades from.
+ public static Color ScorchColor;
+ /// Max live scorch decals; the oldest is recycled past this.
+ public static int ScorchPoolCap;
+
+ // ---- cover damage cracks (CoverDamageSystem) ----
+ /// Crack-decal tint (dark).
+ public static Color CoverCrackColor;
+ /// Max crack decals accreted around one cover rock as it is carved to 0.
+ public static int CoverCrackMaxCount;
+ /// Drop a small ground scorch/debris mark where a cover rock shattered.
+ public static bool CoverShatterScorch;
+
+ // ---- room-arena scars (RoomDressingSystem) ----
+ /// Persistent crack/scorch detail decals laid at the room arena centre (0 = off).
+ public static int RoomArenaDecalCount;
+ /// Room-arena scar tint (dark, subtle).
+ public static Color RoomDecalColor;
+
+ [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
+ public static void ResetDefaults()
+ {
+ Enabled = true;
+
+ // scorch pool — slightly inside the blast radius so it reads as the burn, not the danger ring
+ ScorchRadiusMul = 0.85f;
+ ScorchHoldSec = 4f;
+ ScorchFadeSec = 6f;
+ ScorchColor = new Color(0.05f, 0.04f, 0.03f, 0.85f); // near-black char, mostly opaque at peak
+ ScorchPoolCap = 24;
+
+ // cover cracks
+ CoverCrackColor = new Color(0.06f, 0.05f, 0.05f, 0.8f);
+ CoverCrackMaxCount = 3;
+ CoverShatterScorch = true;
+
+ // room-arena scars
+ RoomArenaDecalCount = 3;
+ RoomDecalColor = new Color(0.08f, 0.07f, 0.06f, 0.5f); // subtle
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/Presentation/DecalConfig.cs.meta b/Assets/_Project/Scripts/Client/Presentation/DecalConfig.cs.meta
new file mode 100644
index 000000000..f9b62cc98
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Presentation/DecalConfig.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 95c95df641b3d1a4cbce24348a716d66
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs b/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs
index a59e8e5d9..8ea5dc44e 100644
--- a/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs
+++ b/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs
@@ -109,5 +109,76 @@ namespace ProjectM.Client
if (clip == null) return;
AudioSource.PlayClipAtPoint(clip, pos, vol * GameVolume.Sfx);
}
+
+ // ---- ground DECAL primitives (Bundle 2 — explosion scorch, cover cracks, room scars) ----
+
+ // Transparent unlit ground-decal material: Sprites/Default honours vertex colour × the per-renderer _Color
+ // MPB, blends alpha, is double-sided (Cull Off) and writes no depth (ZWrite Off) — so flat, overlapping
+ // ground decals never z-fight and never cast/receive shadows. Same shader family as the fuse disc.
+ public static Material MakeDecalMaterial(string name = "GroundDecal")
+ {
+ Shader sh = Shader.Find("Sprites/Default");
+ if (sh == null) sh = Shader.Find("Universal Render Pipeline/Particles/Unlit");
+ if (sh == null) sh = Shader.Find("Unlit/Transparent");
+ return new Material(sh) { name = name, renderQueue = 3000 }; // Transparent
+ }
+
+ // Irregular soft radial disc on the XZ plane (already flat — scale x/z, spin about Y): vertex alpha 1 at the
+ // centre fading to 0 at a JITTERED rim, so it reads as an organic scorch blob with NO hard rectangle edge
+ // (the feathered rim is what keeps it from ghosting over fog the way an opaque quad did). White vertex colour
+ // so the caller's material/MPB tint (dark char) shows through.
+ public static Mesh BuildScorchMesh(int segments = 24, float jitter = 0.3f, int seed = 1)
+ {
+ if (segments < 6) segments = 6;
+ var rng = new System.Random(seed * 6151 + 13);
+ var m = new Mesh { name = "ScorchDecal" };
+ var v = new Vector3[segments + 1];
+ var col = new Color[segments + 1];
+ var tris = new int[segments * 3];
+ v[0] = Vector3.zero;
+ col[0] = new Color(1f, 1f, 1f, 1f);
+ for (int i = 0; i < segments; i++)
+ {
+ float a = i / (float)segments * Mathf.PI * 2f;
+ float r = 1f - jitter * (float)rng.NextDouble();
+ v[i + 1] = new Vector3(Mathf.Cos(a) * r, 0f, Mathf.Sin(a) * r);
+ col[i + 1] = new Color(1f, 1f, 1f, 0f); // transparent feathered rim
+ int n = (i + 1) % segments;
+ tris[i * 3] = 0; tris[i * 3 + 1] = n + 1; tris[i * 3 + 2] = i + 1;
+ }
+ m.vertices = v; m.colors = col; m.triangles = tris;
+ m.RecalculateBounds();
+ return m;
+ }
+
+ // A jagged crack star on the XZ plane: `spokes` thin tapered triangles radiating from the centre at random
+ // angles/lengths, opaque at the base and feathered to nothing at the tip. Reads as surface cracking, visually
+ // distinct from the scorch blob. White vertex colour; caller tints dark and scales it to the piece.
+ public static Mesh BuildCrackMesh(int spokes = 3, int seed = 1)
+ {
+ if (spokes < 1) spokes = 1;
+ var rng = new System.Random(seed * 9277 + 5);
+ var m = new Mesh { name = "CrackDecal" };
+ var v = new Vector3[spokes * 3];
+ var col = new Color[spokes * 3];
+ var tris = new int[spokes * 3];
+ for (int s = 0; s < spokes; s++)
+ {
+ float a = (s / (float)spokes) * Mathf.PI * 2f + (float)(rng.NextDouble() - 0.5) * 1.2f;
+ float len = 0.6f + 0.4f * (float)rng.NextDouble();
+ float w = 0.06f + 0.05f * (float)rng.NextDouble();
+ var dir = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a));
+ var perp = new Vector3(-dir.z, 0f, dir.x) * w;
+ int b = s * 3;
+ v[b] = perp; v[b + 1] = -perp; v[b + 2] = dir * len;
+ col[b] = new Color(1f, 1f, 1f, 1f);
+ col[b + 1] = new Color(1f, 1f, 1f, 1f);
+ col[b + 2] = new Color(1f, 1f, 1f, 0f); // feathered tip
+ tris[b] = b; tris[b + 1] = b + 2; tris[b + 2] = b + 1;
+ }
+ m.vertices = v; m.colors = col; m.triangles = tris;
+ m.RecalculateBounds();
+ return m;
+ }
}
}
diff --git a/Assets/_Project/Scripts/Client/Presentation/RoomDressingSystem.cs b/Assets/_Project/Scripts/Client/Presentation/RoomDressingSystem.cs
index 348a06cbb..bdc535f3e 100644
--- a/Assets/_Project/Scripts/Client/Presentation/RoomDressingSystem.cs
+++ b/Assets/_Project/Scripts/Client/Presentation/RoomDressingSystem.cs
@@ -14,6 +14,11 @@ namespace ProjectM.Client
/// (0xC17). Deterministic from the replicated run seed: every co-op client (and a late-joiner) sees the
/// same floor. Torn down when the room changes or the run ends. Colliders + rigidbodies are stripped on
/// spawn — the aim-reticle raycast and the physics world must never see dressing. Never mutates the sim.
+ ///
+ /// Bundle 2 (decals): also lays a few persistent feathered crack/scorch DETAIL scars at the arena centre
+ /// (higher-frequency detail the baked ground splat can't resolve), on its own hash sub-stream, parented to
+ /// the same dressing root so they tear down together. Base POIs are skipped — Part O baked their wear in.
+ ///
///
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
@@ -22,6 +27,13 @@ namespace ProjectM.Client
Transform _root;
uint _activeKey;
+ // Bundle 2 arena-scar decal assets (reused across rebuilds; the scar GameObjects are children of _root and
+ // torn down each room change, these shared assets persist until OnDestroy).
+ Material _decalMat;
+ Mesh[] _decalMeshes;
+ MaterialPropertyBlock _decalMpb;
+ static readonly int DecalColorId = Shader.PropertyToID("_Color");
+
protected override void OnStartRunning()
{
if (_root == null) _root = new GameObject("~RoomDressing").transform;
@@ -30,6 +42,10 @@ namespace ProjectM.Client
protected override void OnDestroy()
{
if (_root != null) Object.Destroy(_root.gameObject);
+ if (_decalMat != null) Object.Destroy(_decalMat);
+ if (_decalMeshes != null)
+ for (int i = 0; i < _decalMeshes.Length; i++)
+ if (_decalMeshes[i] != null) Object.Destroy(_decalMeshes[i]);
}
protected override void OnUpdate()
@@ -106,6 +122,51 @@ namespace ProjectM.Client
foreach (var col in go.GetComponentsInChildren(true)) Object.Destroy(col);
foreach (var rb in go.GetComponentsInChildren(true)) Object.Destroy(rb);
}
+
+ // Bundle 2 arena scars: distinct hash sub-stream (0xD2E56) so they never co-locate with the prop
+ // scatter (0xD2E55); clustered around the arena centre. Persistent (no fade); torn down with dressing.
+ if (DecalConfig.Enabled && DecalConfig.RoomArenaDecalCount > 0)
+ {
+ EnsureDecalAssets();
+ var srng = new Unity.Mathematics.Random(RunMapMath.Hash(ri.RunSeed, (uint)(ri.CurrentRoom + 1), 0xD2E56u) | 1u);
+ for (int i = 0; i < DecalConfig.RoomArenaDecalCount; i++)
+ SpawnArenaScar(origin, i, ref srng);
+ }
+ }
+
+ void EnsureDecalAssets()
+ {
+ if (_decalMat == null) _decalMat = FeedbackFx.MakeDecalMaterial("RoomScarDecal");
+ if (_decalMpb == null) _decalMpb = new MaterialPropertyBlock();
+ if (_decalMeshes == null)
+ {
+ _decalMeshes = new Mesh[4];
+ _decalMeshes[0] = FeedbackFx.BuildScorchMesh(20, 0.32f, 11);
+ _decalMeshes[1] = FeedbackFx.BuildScorchMesh(20, 0.34f, 23);
+ _decalMeshes[2] = FeedbackFx.BuildCrackMesh(3, 31);
+ _decalMeshes[3] = FeedbackFx.BuildCrackMesh(2, 47);
+ }
+ }
+
+ void SpawnArenaScar(float3 origin, int index, ref Unity.Mathematics.Random rng)
+ {
+ var go = new GameObject("ArenaScar");
+ go.transform.SetParent(_root, false);
+ go.AddComponent().sharedMesh = _decalMeshes[rng.NextInt(0, _decalMeshes.Length)];
+ var mr = go.AddComponent();
+ mr.sharedMaterial = _decalMat;
+ mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
+ mr.receiveShadows = false;
+ mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
+ _decalMpb.SetColor(DecalColorId, DecalConfig.RoomDecalColor);
+ mr.SetPropertyBlock(_decalMpb);
+ float ang = rng.NextFloat(0f, math.PI * 2f);
+ float rad = rng.NextFloat(0f, 4.5f); // clustered around the arena centre where the fight happens
+ float scale = rng.NextFloat(1.4f, 3.0f);
+ go.transform.SetPositionAndRotation(
+ new Vector3(origin.x + math.cos(ang) * rad, 0.05f + 0.003f * index, origin.z + math.sin(ang) * rad),
+ Quaternion.Euler(0f, rng.NextFloat(0f, 360f), 0f));
+ go.transform.localScale = new Vector3(scale, 1f, scale);
}
}
}
diff --git a/Assets/_Project/Scripts/Client/Presentation/ScorchDecalSystem.cs b/Assets/_Project/Scripts/Client/Presentation/ScorchDecalSystem.cs
new file mode 100644
index 000000000..10477f836
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Presentation/ScorchDecalSystem.cs
@@ -0,0 +1,144 @@
+using System.Collections.Generic;
+using Unity.Entities;
+using UnityEngine;
+using static ProjectM.Client.FeedbackFx;
+
+namespace ProjectM.Client
+{
+ ///
+ /// Client-only EXPLOSION SCORCH pool (Phase 1.5b bundle 2 — decals). A fading pool of flat ground scorch discs
+ /// dropped at boom sites via the static queue — the exact request-drain idiom of
+ /// : enqueues at a barrel
+ /// detonation (same thread, same ), we drain once per frame. Each decal
+ /// HOLDS then FADES its alpha through a single reused (per-renderer copy →
+ /// independent lifetimes, no shared-material bleed) and returns to the pool. Pooled GameObjects live under a
+ /// private DontDestroyOnLoad root (never scene-saved), scene-gated to "Game", null-guarded across scene reloads.
+ /// Observe-only — never touches the sim, no netcode, no [GhostField]. Reusable by the future Blight-geyser hazard.
+ /// Knobs live in .
+ ///
+ [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
+ [UpdateInGroup(typeof(PresentationSystemGroup))]
+ public partial class ScorchDecalSystem : SystemBase
+ {
+ struct ScorchRequest { public Vector3 Pos; public float Radius; }
+ class Active { public GameObject Go; public MeshRenderer Renderer; public float SpawnTime; public float PeakAlpha; }
+
+ // Static request queue: WorldFeedbackSystem (same thread, PresentationSystemGroup) enqueues, we drain.
+ static readonly List Pending = new List();
+
+ [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
+ static void ResetStatics() { Pending.Clear(); }
+
+ /// Queue a ground scorch at a detonation. is the explosion radius; the
+ /// decal is scaled by .
+ public static void RequestScorch(Vector3 pos, float radius)
+ {
+ if (Pending.Count < 64) Pending.Add(new ScorchRequest { Pos = pos, Radius = radius });
+ }
+
+ GameObject _root;
+ Material _mat;
+ Mesh[] _meshes; // a few pre-jittered variants for organic variety (reused, no per-decal alloc)
+ MaterialPropertyBlock _mpb;
+ readonly List _active = new List();
+ readonly Stack _pool = new Stack();
+ static readonly int ColorId = Shader.PropertyToID("_Color");
+
+ protected override void OnCreate()
+ {
+ _meshes = new Mesh[4];
+ for (int i = 0; i < _meshes.Length; i++) _meshes[i] = BuildScorchMesh(24, 0.3f, i + 1);
+ _mpb = new MaterialPropertyBlock();
+ }
+
+ protected override void OnDestroy()
+ {
+ if (_root != null) Object.Destroy(_root);
+ if (_mat != null) Object.Destroy(_mat);
+ if (_meshes != null)
+ for (int i = 0; i < _meshes.Length; i++)
+ if (_meshes[i] != null) Object.Destroy(_meshes[i]);
+ }
+
+ protected override void OnUpdate()
+ {
+ if (UnityEngine.SceneManagement.SceneManager.GetActiveScene().name != "Game") { Pending.Clear(); return; }
+ if (!DecalConfig.Enabled)
+ {
+ Pending.Clear();
+ if (_root != null && _root.activeSelf) _root.SetActive(false);
+ return;
+ }
+ if (_root == null)
+ {
+ _root = new GameObject("~ScorchDecals");
+ Object.DontDestroyOnLoad(_root);
+ _mat = MakeDecalMaterial("ScorchDecal");
+ }
+ if (!_root.activeSelf) _root.SetActive(true);
+
+ float now = UnityEngine.Time.time;
+
+ // ---- drain new scorch requests ----
+ for (int i = 0; i < Pending.Count; i++)
+ {
+ if (_active.Count >= Mathf.Max(1, DecalConfig.ScorchPoolCap)) RecycleOldest();
+ var req = Pending[i];
+ var go = Rent();
+ float scale = Mathf.Max(0.5f, req.Radius * DecalConfig.ScorchRadiusMul);
+ // small per-decal y-stagger avoids coplanar transparent z-fight between overlapping scorches
+ float y = 0.045f + 0.002f * (_active.Count % 8);
+ float yaw = Mathf.Repeat(req.Pos.x * 53f + req.Pos.z * 31f, 360f);
+ go.transform.SetPositionAndRotation(new Vector3(req.Pos.x, y, req.Pos.z), Quaternion.Euler(0f, yaw, 0f));
+ go.transform.localScale = new Vector3(scale, 1f, scale);
+ int mi = Mathf.Abs(Mathf.RoundToInt(req.Pos.x + req.Pos.z)) % _meshes.Length;
+ go.GetComponent().sharedMesh = _meshes[mi];
+ _active.Add(new Active { Go = go, Renderer = go.GetComponent(), SpawnTime = now, PeakAlpha = DecalConfig.ScorchColor.a });
+ }
+ Pending.Clear();
+
+ // ---- hold + fade + recycle ----
+ float hold = Mathf.Max(0f, DecalConfig.ScorchHoldSec);
+ float fade = Mathf.Max(0.01f, DecalConfig.ScorchFadeSec);
+ var baseCol = DecalConfig.ScorchColor;
+ for (int i = _active.Count - 1; i >= 0; i--)
+ {
+ var a = _active[i];
+ if (a.Go == null) { _active.RemoveAt(i); continue; } // scene reload can null pooled objects
+ float age = now - a.SpawnTime;
+ float alpha = age <= hold ? a.PeakAlpha : a.PeakAlpha * Mathf.Clamp01(1f - (age - hold) / fade);
+ if (alpha <= 0.001f) { Return(a.Go); _active.RemoveAt(i); continue; }
+ _mpb.SetColor(ColorId, new Color(baseCol.r, baseCol.g, baseCol.b, alpha));
+ a.Renderer.SetPropertyBlock(_mpb);
+ }
+ }
+
+ GameObject Rent()
+ {
+ GameObject go = null;
+ while (_pool.Count > 0) { go = _pool.Pop(); if (go != null) break; }
+ if (go == null)
+ {
+ go = new GameObject("Scorch");
+ go.transform.SetParent(_root.transform, false);
+ go.AddComponent();
+ var mr = go.AddComponent();
+ mr.sharedMaterial = _mat;
+ mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
+ mr.receiveShadows = false;
+ mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
+ }
+ go.SetActive(true);
+ return go;
+ }
+
+ void Return(GameObject go) { if (go != null) { go.SetActive(false); _pool.Push(go); } }
+
+ void RecycleOldest()
+ {
+ if (_active.Count == 0) return;
+ Return(_active[0].Go);
+ _active.RemoveAt(0);
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/Presentation/ScorchDecalSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/ScorchDecalSystem.cs.meta
new file mode 100644
index 000000000..a091e884e
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Presentation/ScorchDecalSystem.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 225e2db8aab51ad47847beba0f4e0d7f
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs
index 1dc7a76aa..350508f0f 100644
--- a/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs
+++ b/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs
@@ -139,6 +139,7 @@ namespace ProjectM.Client
PlayClip(_boomClip, (Vector3)c.Pos, 0.8f);
PrototypeCameraRig.PunchFov(WorldFeelConfig.ClearFovKick * 2.2f, 90f);
PrototypeCameraRig.AddShake(WorldFeelConfig.ClearShake * 2.5f);
+ ScorchDecalSystem.RequestScorch((Vector3)c.Pos, Tuning.BarrelExplodeRadius); // bundle 2: charred ground mark at the boom
}
else
{