649f656833
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>
178 lines
8.4 KiB
C#
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 (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();
|
|
}
|
|
}
|
|
}
|