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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 63769d219acf0b248b321ef76bdf4189
|
||||
@@ -0,0 +1,68 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <see cref="WorldFeelConfig"/> / <see cref="FeelConfig"/>)
|
||||
/// so values can be poked at runtime via MCP <c>execute_code</c> without a recompile
|
||||
/// (e.g. <c>ProjectM.Client.DecalConfig.ScorchColor = ...;</c>). Read ONLY by the decal presentation systems
|
||||
/// (<see cref="ScorchDecalSystem"/>, <see cref="CoverDamageSystem"/>) and <see cref="RoomDressingSystem"/> — all
|
||||
/// managed, main-thread. NEVER read from a [BurstCompile] system (managed-static + Color-in-Burst hazards).
|
||||
/// <see cref="ResetDefaults"/> re-stamps on play-enter via [RuntimeInitializeOnLoadMethod] because statics survive
|
||||
/// fast-enter-playmode reloads (else a poked value leaks across play-enters).
|
||||
/// </summary>
|
||||
public static class DecalConfig
|
||||
{
|
||||
/// <summary>Master gate for every decal (scorch pool + cover cracks + room scars).</summary>
|
||||
public static bool Enabled;
|
||||
|
||||
// ---- explosion scorch pool (ScorchDecalSystem) ----
|
||||
/// <summary>Scorch disc radius as a multiple of the explosion radius passed to RequestScorch.</summary>
|
||||
public static float ScorchRadiusMul;
|
||||
/// <summary>Seconds a fresh scorch holds at full strength before it starts fading.</summary>
|
||||
public static float ScorchHoldSec;
|
||||
/// <summary>Seconds a scorch fades from full to gone (then returns to the pool).</summary>
|
||||
public static float ScorchFadeSec;
|
||||
/// <summary>Peak scorch tint (dark char); the alpha is the peak opacity it fades from.</summary>
|
||||
public static Color ScorchColor;
|
||||
/// <summary>Max live scorch decals; the oldest is recycled past this.</summary>
|
||||
public static int ScorchPoolCap;
|
||||
|
||||
// ---- cover damage cracks (CoverDamageSystem) ----
|
||||
/// <summary>Crack-decal tint (dark).</summary>
|
||||
public static Color CoverCrackColor;
|
||||
/// <summary>Max crack decals accreted around one cover rock as it is carved to 0.</summary>
|
||||
public static int CoverCrackMaxCount;
|
||||
/// <summary>Drop a small ground scorch/debris mark where a cover rock shattered.</summary>
|
||||
public static bool CoverShatterScorch;
|
||||
|
||||
// ---- room-arena scars (RoomDressingSystem) ----
|
||||
/// <summary>Persistent crack/scorch detail decals laid at the room arena centre (0 = off).</summary>
|
||||
public static int RoomArenaDecalCount;
|
||||
/// <summary>Room-arena scar tint (dark, subtle).</summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 95c95df641b3d1a4cbce24348a716d66
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[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<Collider>(true)) Object.Destroy(col);
|
||||
foreach (var rb in go.GetComponentsInChildren<Rigidbody>(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<MeshFilter>().sharedMesh = _decalMeshes[rng.NextInt(0, _decalMeshes.Length)];
|
||||
var mr = go.AddComponent<MeshRenderer>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Collections.Generic;
|
||||
using Unity.Entities;
|
||||
using UnityEngine;
|
||||
using static ProjectM.Client.FeedbackFx;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <see cref="RequestScorch"/> queue — the exact request-drain idiom of
|
||||
/// <see cref="DynamicLightSystem.RequestFlash"/>: <see cref="WorldFeedbackSystem"/> enqueues at a barrel
|
||||
/// detonation (same thread, same <see cref="PresentationSystemGroup"/>), we drain once per frame. Each decal
|
||||
/// HOLDS then FADES its alpha through a single reused <see cref="MaterialPropertyBlock"/> (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 <see cref="DecalConfig"/>.
|
||||
/// </summary>
|
||||
[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<ScorchRequest> Pending = new List<ScorchRequest>();
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
static void ResetStatics() { Pending.Clear(); }
|
||||
|
||||
/// <summary>Queue a ground scorch at a detonation. <paramref name="radius"/> is the explosion radius; the
|
||||
/// decal is scaled by <see cref="DecalConfig.ScorchRadiusMul"/>.</summary>
|
||||
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> _active = new List<Active>();
|
||||
readonly Stack<GameObject> _pool = new Stack<GameObject>();
|
||||
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<MeshFilter>().sharedMesh = _meshes[mi];
|
||||
_active.Add(new Active { Go = go, Renderer = go.GetComponent<MeshRenderer>(), 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<MeshFilter>();
|
||||
var mr = go.AddComponent<MeshRenderer>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 225e2db8aab51ad47847beba0f4e0d7f
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user