Decals: explosion scorch pool + cover damage-cracks + room-arena scars (Phase 1.5b bundle 2)
All procedural, client-only, observe-only in PresentationSystemGroup, zero netcode surface (read existing replicated state). Shared FeedbackFx decal primitives (scorch blob + crack star meshes + transparent decal material). ScorchDecalSystem: static RequestScorch queue -> pooled fading discs (mirrors DynamicLightSystem), wired from the barrel boom, reusable by the geyser. CoverDamageSystem: crack accretion keyed on BlightClutter.Remaining (Variant 4) + proximity-gated shatter-scorch. RoomDressingSystem: persistent arena scars on a distinct hash sub-stream, torn down with dressing. Knobs in DecalConfig. Cover cracks use decals (not URPMaterialPropertyBaseColor) because the Synty prop shader is not Hybrid-Per-Instance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
{
|
||||
/// <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 (UnityEngine.SceneManagement.SceneManager.GetActiveScene().name != "Game") 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user