Files
Project-M/Assets/_Project/Scripts/Client/Presentation/CoverDamageSystem.cs
T
kronic c58439d33a LANTERN realignment P3: one look everywhere + scene surgery
- PostFX_Lantern.asset: the ONE shared grade (ACES, bloom 0.6 cool-tinted for the
  HDR gloam, vignette 0.28, faint teal filter) applied to Game, DevSandbox, and
  ArtStaging; PostFX_DarkSciFi / PostFX_Daylight / Sky_DaytimeProcedural deleted.
- Env_SeabedKit.prefab: ArtStaging's environment (seabed, flora, rocks, marine
  snow, caustics, warm pool, gloam fill, key light, StagingAmbiance animator)
  prefab-ized and placed in Game + DevSandbox; ArtStaging connected to it.
- Scene surgery: old DevSandbox.unity deleted; Gym.unity RENAMED DevSandbox.unity
  (GUID preserved -> GymSub wiring + the F1/F2 dev scripts that gate on the
  'DevSandbox' name come back to life). SyntyWorld root deleted from DevSandbox;
  BaseBiome/ExpeditionBiome/Slot1 deleted from Game.
- RenderSettings unified: no skybox, Exp2 teal fog {0.02,0.10,0.12} @ 0.035, flat
  near-black ambient {0.03,0.055,0.08}; camera clearFlags -> solid deep-water.
- ScenePolicy.IsGameplayScene() replaces the six scene.name=="Game" string gates
  (Game + DevSandbox share the dynamic look; menu/ArtStaging untouched).
- WorldAtmosphereSystem rewritten as water-column murk (LANTERN defaults; biome
  variants re-meant to kelp/trench/gloam-bloom; Ground_Arid tint block deleted).

390 green; Play-verified in Game: murk values live, warm-vs-cold reads, no errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 16:12:00 -07:00

178 lines
8.4 KiB
C#

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
{
/// <summary>
/// Client-only COVER DAMAGE decals (Phase 1.5b bundle 2 — decals). Destructible cover = a
/// <see cref="BlightClutter"/> ghost (Variant 4, Remaining 8 → 0; see the Destructible_Cover_Build_Spec). This
/// observe-only <see cref="PresentationSystemGroup"/> <see cref="SystemBase"/> edge-detects the replicated
/// <see cref="BlightClutter.Remaining"/> on cover ghosts and accretes jagged CRACK decals on the ground around
/// the rock as it is carved (<see cref="WorldFeedbackSystem"/> 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 <see cref="ScorchDecalSystem.RequestScorch"/> 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.
/// <para>
/// Pure procedural decal quads — NO material/shader dependency. This is deliberate: cover's Synty prop shader
/// declares <c>_BaseColor</c> as Unity-Per-Material (NOT Hybrid-Per-Instance), so the
/// <c>URPMaterialPropertyBaseColor</c> darken that <see cref="EnemyHitFlashSystem"/> uses on the DOTS-authored
/// AnimatedLitShader would silently no-op here. Decals sidestep that entirely and read as literal damage.
/// </para>
/// No new component / [GhostField], no server work. Knobs live in <see cref="DecalConfig"/>.
/// </summary>
[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<GameObject> Cracks = new();
}
readonly Dictionary<Entity, Piece> _tracked = new();
readonly HashSet<Entity> _seen = new();
readonly List<Entity> _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 (!ScenePolicy.IsGameplayScene()) return;
if (!DecalConfig.Enabled) { if (_tracked.Count > 0) ClearAll(); return; }
EntityManager.CompleteDependencyBeforeRO<BlightClutter>();
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
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<RefRO<LocalTransform>>().WithAll<GhostOwnerIsLocal, PlayerTag>())
{
localPos = xf.ValueRO.Position;
haveLocal = true;
}
_seen.Clear();
foreach (var (clutter, xf, e) in
SystemAPI.Query<RefRO<BlightClutter>, RefRO<LocalTransform>>().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<MeshFilter>().sharedMesh = _crackMeshes[(index + (int)(seed % 3u)) % _crackMeshes.Length];
var mr = go.AddComponent<MeshRenderer>();
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();
}
}
}