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 {