Files
Project-M/Assets/_Project/Scripts/Client/Presentation/EnemyDangerTelegraphSystem.cs
T
kronic e0c59ad663 Perf: pool one-shot SFX + authored VFX, cut per-frame presentation allocation
Track B. All 21 one-shot cues funnelled through FeedbackFx.PlayClip ->
AudioSource.PlayClipAtPoint, which allocates a GameObject + AudioSource per call
and schedules a delayed Destroy — ~20-33 times a second in light combat. New
OneShotAudioPool is a 32-voice 3D ring behind an UNCHANGED PlayClip signature, so
all 20 consuming call sites are untouched.

Parity is the whole game here: PlayClipAtPoint sets spatialBlend = 1 explicitly (a
fresh AudioSource is 2D) and leaves the rest at stock defaults. Two deliberate
divergences, both forced by the voices being long-lived: playOnAwake = false, and
dopplerLevel = 0 because a pooled voice TELEPORTS between events and would
otherwise pitch-bend. Root is DontDestroyOnLoad (WorldLauncher does
LoadScene(Single) while the client world is alive) with a SubsystemRegistration
reset, or session two rents destroyed voices.

Authored impact VFX are pooled per prefab instead of Instantiate/Destroy per hit:
components cached per INSTANCE (refs are instance-scoped), main.stopAction forced
to None (a prefab set to Destroy silently drains the pool), instances filled under
an inactive root so Awake/Start never run — which is what makes the DestroyImmediate
in StripCosmetic safe — ps.Clear before Play, TrailRenderer.Clear after the
reposition, and a Rented flag as the at-most-once guard against a double Return
aliasing one instance to two callers.

Per-frame allocation: the slash-arc and enemy-wedge mesh builders each allocated
four arrays on every call (up to twice a frame, and once per winding enemy); HUD
and ability-bar labels rebuilt their strings every frame; damage-number fades
rewrote TextMesh vertex colours every frame; health bars pushed uGUI writes
unconditionally; two systems played back an empty EntityCommandBuffer (a
structural-change sync point) every frame.

Also closes an AudioClip leak across all seven clip-owning systems: an
AudioClip.Create'd clip is a standalone UnityEngine.Object, so destroying a
system's FX root left it alive (MusicSystem ~6.8 MB, AmbientAudioSystem ~2 MB per
client-world teardown).

CombatFeedbackSystem's TryHold call sites go with this commit because they share
the file; the camera-side removal lands in the next one.

Verified live: PlayClipAtPoint's "One shot audio" GameObject never appears again
across 270 frames of combat with kills; the VFX pool fills to its retain cap and
stabilises; real cues route through the ring. 304/304 EditMode green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:02:50 -07:00

326 lines
18 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>
/// MC-3/MC-4/A7 — client-only enemy attack TELEGRAPHS. Observe-only presentation <see cref="SystemBase"/> in
/// <see cref="PresentationSystemGroup"/> that reads replicated state and never mutates the sim. While an enemy's
/// <see cref="AttackWindup"/> counts down (or a Charger is mid-lunge) it paints a red ground danger shape in the
/// enemy's facing — a melee cone, a Spitter aim LANE, or the boss's radial SLAM ring / lunge wedge — brightening +
/// scaling as the strike nears so the player reads WHERE and WHEN to dodge. Also plays a near-impact "dodge NOW"
/// strike beep once per windup for enemies near the local player. SELF-DETECTS the windup-onset edge via its own
/// <c>_prevWindup</c> map (a 0 -&gt; nonzero <c>WindUpUntilTick</c> transition arms the anticipation scale-pulse —
/// this was formerly written by CombatFeedbackSystem's health-scan loop). One pooled mesh per winding-up enemy,
/// pruned each frame; the tracking maps are pruned against a full enemy-seen set. Extracted from CombatFeedbackSystem;
/// owns its own FX-root + danger material + beep clip.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class EnemyDangerTelegraphSystem : SystemBase
{
Transform _fxRoot;
Material _dangerMat;
readonly Dictionary<Entity, GameObject> _dangerZones = new();
readonly HashSet<Entity> _dangerSeen = new(); // telegraph-ACTIVE enemies (zone lifecycle: a zone vanishes when its enemy stops winding up)
readonly List<Entity> _dangerStale = new(); // scratch list reused by every prune
readonly Dictionary<Entity, float> _pulseStart = new(); // per-enemy windup-onset time (anticipation scale-pulse)
readonly Dictionary<Entity, uint> _strikeBeeped = new(); // entity -> the WindUpUntilTick it last beeped for (once/windup)
readonly Dictionary<Entity, uint> _prevWindup = new(); // self-detect the windup-onset edge (was the core _cache.Windup)
readonly HashSet<Entity> _enemySeen = new(); // ALL enemies this frame (prunes _pulseStart/_strikeBeeped/_prevWindup)
AudioClip _strikeBeepClip; // near-impact "dodge NOW" beep
AudioClip[] _kindGrowls; // per-kind windup voice (attack distinctness)
Material[] _kindMats; // per-kind telegraph colours (attack distinctness)
Entity _localPlayer = Entity.Null;
protected override void OnCreate()
{
_strikeBeepClip = MakeClip("strike", 1150f, 1500f, 0.05f, 0.30f, noise: false); // near-impact beep
// Attack-distinctness: per-kind windup VOICES so kinds read by EAR at onset (0 grunt thud,
// 1 charger rising roar, 2 spitter wet hiss, 3 swarmer chitter).
_kindGrowls = new AudioClip[]
{
MakeClip("growl_grunt", 180f, 110f, 0.14f, 0.30f, noise: false),
MakeClip("growl_charger", 90f, 420f, 0.32f, 0.34f, noise: false),
MakeClip("growl_spitter", 1900f, 500f, 0.18f, 0.26f, noise: true),
MakeClip("growl_swarmer", 1500f, 2100f, 0.07f, 0.22f, noise: false),
};
}
protected override void OnStartRunning()
{
if (_fxRoot != null) return;
_fxRoot = new GameObject("~EnemyDangerFX").transform;
_dangerMat = MakeParticleMaterial();
_dangerMat.name = "EnemyDanger";
_dangerMat.color = new Color(3.2f, 0.28f, 0.18f, 1f); // HDR red fallback (per-zone intensity in vertex alpha)
// Attack-distinctness: per-kind telegraph COLORS (0 grunt orange, 1 charger crimson,
// 2 spitter toxic green, 3 swarmer yellow; the boss shares the charger crimson — shapes differ).
_kindMats = new Material[4];
var kindColors = new Color[]
{
new Color(3.2f, 0.85f, 0.15f, 1f),
new Color(3.2f, 0.18f, 0.12f, 1f),
new Color(0.55f, 2.9f, 0.35f, 1f),
new Color(2.9f, 2.3f, 0.22f, 1f),
};
for (int i = 0; i < 4; i++)
{
_kindMats[i] = MakeParticleMaterial();
_kindMats[i].name = "EnemyDanger_K" + i;
_kindMats[i].color = kindColors[i];
}
}
protected override void OnDestroy()
{
if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject);
if (_dangerMat != null) Object.Destroy(_dangerMat);
if (_kindMats != null)
for (int i = 0; i < _kindMats.Length; i++)
if (_kindMats[i] != null) Object.Destroy(_kindMats[i]);
// Procedural clips are not owned by _fxRoot — see FeedbackFx.DestroyClip.
FeedbackFx.DestroyClip(ref _strikeBeepClip);
if (_kindGrowls != null)
for (int i = 0; i < _kindGrowls.Length; i++) FeedbackFx.DestroyClip(ref _kindGrowls[i]);
foreach (var kv in _dangerZones)
if (kv.Value != null) { var mf = kv.Value.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); }
}
protected override void OnUpdate()
{
if (_fxRoot == null || _dangerMat == null) return;
// Predicted/physics jobs writing these must finish before this main-thread read.
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
EntityManager.CompleteDependencyBeforeRO<AttackWindup>();
EntityManager.CompleteDependencyBeforeRO<EnemyStats>();
EntityManager.CompleteDependencyBeforeRO<EnemyTelegraph>();
// Local player (strike-beep proximity gate).
_localPlayer = Entity.Null;
float3 localPos = default;
foreach (var (xf, entity) in SystemAPI.Query<RefRO<LocalTransform>>()
.WithAll<GhostOwnerIsLocal, PlayerTag>().WithEntityAccess())
{
_localPlayer = entity;
localPos = xf.ValueRO.Position;
}
UpdateEnemyDanger(localPos);
}
// Enemy attack TELEGRAPH (MC-4 clarity): while an enemy's AttackWindup counts down, paint a red ground danger
// cone in its facing out to its reach, brightening + scaling as the strike nears -> the player reads WHERE +
// WHEN to dodge. Client-only, observe-only; one pooled mesh per winding-up enemy, pruned each frame.
void UpdateEnemyDanger(float3 localPos)
{
if (_fxRoot == null || _dangerMat == null) return;
// Enemies are ownerless INTERPOLATED ghosts, so their replicated AttackWindup arrives on the
// INTERPOLATION timeline. Timing the danger cone against the PREDICTED ServerTick makes the cue
// finish ~RTT/2 + interp-buffer ticks EARLY over a real connection — the dodge tell lies. Invisible on
// loopback, which is exactly why it survived (audit finding M1). Same idiom as ZoneTelegraphSystem.
Unity.NetCode.NetworkTick serverTick = default;
if (SystemAPI.TryGetSingleton<NetworkTime>(out var nt))
serverTick = nt.InterpolationTick.IsValid ? nt.InterpolationTick : nt.ServerTick;
_dangerSeen.Clear();
_enemySeen.Clear();
if (serverTick.IsValid)
{
foreach (var (xf, stats, windup, tele, entity) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<EnemyStats>, RefRO<AttackWindup>, RefRO<EnemyTelegraph>>()
.WithAll<EnemyTag>().WithEntityAccess())
{
_enemySeen.Add(entity);
uint until = windup.ValueRO.WindUpUntilTick;
// Self-detect the windup-onset edge (formerly written by the core's health-scan loop): a 0 -> nonzero
// transition of WindUpUntilTick arms the anticipation scale-pulse (Feature C). Requires a prior 0
// record so a mid-windup relevancy re-entry doesn't spuriously pulse (matches the old prev.Windup==0).
bool hadPrev = _prevWindup.TryGetValue(entity, out var pw);
if (until != 0u && hadPrev && pw == 0u)
{
_pulseStart[entity] = (float)SystemAPI.Time.ElapsedTime;
// Attack-distinctness: the per-kind windup VOICE at onset (same gate family as the strike beep).
if (FeelConfig.StrikeBeepEnabled && _localPlayer != Entity.Null && _kindGrowls != null
&& math.distancesq(xf.ValueRO.Position, localPos) <= FeelConfig.StrikeBeepMaxDistSq)
{
byte gk = tele.ValueRO.Kind;
if (gk < _kindGrowls.Length && _kindGrowls[gk] != null)
PlayClip(_kindGrowls[gk], (Vector3)xf.ValueRO.Position, 0.22f);
}
}
_prevWindup[entity] = until;
if (until == 0u) continue;
var untilTick = new Unity.NetCode.NetworkTick(until);
if (!untilTick.IsValid || !untilTick.IsNewerThan(serverTick)) continue; // windup already elapsed
int remaining = untilTick.TicksSince(serverTick);
// Feature C: per-enemy windup duration (baked, client-safe) -> ramps 0->1 ending AT impact for
// any windup length.
float windupDur = math.max(1f, tele.ValueRO.WindupTicks);
float intensity = math.saturate(1f - remaining / windupDur);
{;
// Near-impact strike beep (deferred-items pass): a "dodge NOW" cue once per windup, gated to
// enemies near the local player (the danger cone already proves it's winding up to strike).
if (FeelConfig.StrikeBeepEnabled && _localPlayer != Entity.Null && remaining <= FeelConfig.StrikeBeepLeadTicks
&& (!_strikeBeeped.TryGetValue(entity, out var beepedUntil) || beepedUntil != until))
{
float3 ep = xf.ValueRO.Position;
if (math.distancesq(ep, localPos) <= FeelConfig.StrikeBeepMaxDistSq)
{
PlayClip(_strikeBeepClip, (Vector3)ep, FeelConfig.StrikeBeepVolume);
_strikeBeeped[entity] = until;
}
}
}
// Feature C: a short anticipation scale-pulse folded into the client-owned cone (never the ghost).
float pulse = 0f;
if (_pulseStart.TryGetValue(entity, out var t0))
{
float age = (float)SystemAPI.Time.ElapsedTime - t0;
const float PulseLife = 0.18f;
if (age < PulseLife) pulse = (1f - age / PulseLife) * 0.35f;
else _pulseStart.Remove(entity);
}
_dangerSeen.Add(entity);
if (!_dangerZones.TryGetValue(entity, out var go) || go == null)
{
go = new GameObject("EnemyDanger");
go.transform.SetParent(_fxRoot, false);
go.AddComponent<MeshFilter>().sharedMesh = new Mesh { name = "EnemyDanger" };
var mr = go.AddComponent<MeshRenderer>();
mr.sharedMaterial = _kindMats != null && tele.ValueRO.Kind < _kindMats.Length && _kindMats[tele.ValueRO.Kind] != null
? _kindMats[tele.ValueRO.Kind] : _dangerMat; // per-kind telegraph colour (kind is fixed per entity)
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false;
_dangerZones[entity] = go;
}
float coneRange = math.max(1f, stats.ValueRO.AttackRange + 0.6f);
// 2026-08-07 audit purge: the boss radial-slam ring, the boss lunge wedge and the Spitter aim
// lane are gone with BossState / IsLunging / SpitterState. One enemy kind, one melee wedge.
BuildDangerMesh(go.GetComponent<MeshFilter>().sharedMesh, coneRange, 0.7f, intensity);
float2 fwd = AnimParamMath.PlanarForward(xf.ValueRO.Rotation);
var tr = go.transform;
tr.position = (Vector3)xf.ValueRO.Position + Vector3.up * 0.06f;
tr.rotation = Quaternion.LookRotation(new Vector3(fwd.x, 0f, fwd.y), Vector3.up);
tr.localScale = Vector3.one * (0.92f + 0.12f * intensity + pulse);
}
}
// Zone lifecycle: a zone vanishes the moment its enemy stops winding up (not in _dangerSeen) or despawns.
if (_dangerZones.Count != _dangerSeen.Count)
{
_dangerStale.Clear();
foreach (var kv in _dangerZones) if (!_dangerSeen.Contains(kv.Key)) _dangerStale.Add(kv.Key);
for (int i = 0; i < _dangerStale.Count; i++)
{
var g = _dangerZones[_dangerStale[i]];
if (g != null) { var mf = g.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); Object.Destroy(g); }
_dangerZones.Remove(_dangerStale[i]);
}
}
// Prune the tracking maps against the FULL enemy-seen set (a despawned enemy drops its pulse/beep/windup state).
PruneTracking(_pulseStart);
PruneTracking(_strikeBeeped);
PruneTracking(_prevWindup);
}
// Remove entries whose enemy wasn't seen this frame (keyed on the full enemy-seen set); reuses _dangerStale.
void PruneTracking<T>(Dictionary<Entity, T> dict)
{
if (dict.Count == 0) return;
_dangerStale.Clear();
foreach (var kv in dict) if (!_enemySeen.Contains(kv.Key)) _dangerStale.Add(kv.Key);
for (int i = 0; i < _dangerStale.Count; i++) dict.Remove(_dangerStale[i]);
}
// Filled forward wedge (pizza-slice) from the enemy out to `range`, vertex-alpha ramped by `intensity`.
// Track B: the four arrays used to be allocated on EVERY call — once per winding enemy per frame
// (~840 B a time). seg is a compile-time constant, so they are hoisted to scratch and filled in place;
// UVs/triangles are argument-independent and upload to a given mesh only on its first fill.
static void BuildDangerMesh(Mesh mesh, float range, float halfAngle, float intensity)
{
const int seg = WedgeSeg;
if (!s_wedgeStaticsBuilt)
{
s_wedgeUvs[0] = new Vector2(0.5f, 0.5f);
for (int i = 0; i <= seg; i++) s_wedgeUvs[i + 1] = new Vector2(0.5f, 0.5f);
for (int i = 0; i < seg; i++) { s_wedgeTris[i * 3] = 0; s_wedgeTris[i * 3 + 1] = i + 1; s_wedgeTris[i * 3 + 2] = i + 2; }
s_wedgeStaticsBuilt = true;
}
float aCenter = 0.18f + 0.62f * intensity;
s_wedgeVerts[0] = Vector3.zero;
s_wedgeCols[0] = new Color(1f, 1f, 1f, aCenter);
for (int i = 0; i <= seg; i++)
{
float a = Mathf.Lerp(-halfAngle, halfAngle, i / (float)seg);
s_wedgeVerts[i + 1] = new Vector3(Mathf.Sin(a) * range, 0f, Mathf.Cos(a) * range);
s_wedgeCols[i + 1] = new Color(1f, 1f, 1f, aCenter * 0.22f);
}
if (mesh.vertexCount != s_wedgeVerts.Length)
{
mesh.Clear();
mesh.vertices = s_wedgeVerts; mesh.colors = s_wedgeCols;
mesh.uv = s_wedgeUvs; mesh.triangles = s_wedgeTris;
}
else
{
mesh.vertices = s_wedgeVerts; mesh.colors = s_wedgeCols;
}
mesh.RecalculateBounds();
}
// Wedge scratch (see BuildDangerMesh). Static is safe: presentation systems are main-thread only, and
// the contents are deterministic geometry, so surviving a domain reload leaves them valid.
const int WedgeSeg = 14;
static readonly Vector3[] s_wedgeVerts = new Vector3[WedgeSeg + 2];
static readonly Color[] s_wedgeCols = new Color[WedgeSeg + 2];
static readonly Vector2[] s_wedgeUvs = new Vector2[WedgeSeg + 2];
static readonly int[] s_wedgeTris = new int[WedgeSeg * 3];
static bool s_wedgeStaticsBuilt;
// MC-3: a thin forward LANE (filled quad in local +Z) for a Spitter's ranged aim telegraph, vertex-alpha
// ramped by `intensity` (brightening toward the shot). Built into the same pooled danger mesh; the GO is
// already rotated to the enemy facing, so +Z is "toward the locked target".
static void BuildLaneMesh(Mesh mesh, float length, float halfWidth, float intensity)
{
float a = 0.18f + 0.62f * intensity;
var verts = new Vector3[4]
{
new Vector3(-halfWidth, 0f, 0.2f),
new Vector3( halfWidth, 0f, 0.2f),
new Vector3(-halfWidth, 0f, length),
new Vector3( halfWidth, 0f, length),
};
var cols = new Color[4]
{
new Color(1f, 1f, 1f, a),
new Color(1f, 1f, 1f, a),
new Color(1f, 1f, 1f, a * 0.12f),
new Color(1f, 1f, 1f, a * 0.12f),
};
var uvs = new Vector2[4] { new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f) };
var tris = new int[6] { 0, 2, 1, 1, 2, 3 };
mesh.Clear();
mesh.vertices = verts; mesh.colors = cols; mesh.uv = uvs; mesh.triangles = tris;
mesh.RecalculateBounds();
}
}
}