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(); } } }