e0c59ad663
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>
175 lines
8.8 KiB
C#
175 lines
8.8 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 BLIGHT-GEYSER telegraph + erupt VFX (Phase 1.5b bundle 3; Geyser_Build_Spec). Observe-only
|
|
/// <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/> — sibling of
|
|
/// <see cref="EnemyDangerTelegraphSystem"/>, never mutates the sim. Reads each geyser's replicated
|
|
/// <see cref="Geyser.NextEruptTick"/> against the client's PREDICTED <c>NetworkTime.ServerTick</c> and derives
|
|
/// everything from that ABSOLUTE tick (never an edge on the field — so it survives snapshot loss + relevancy
|
|
/// re-entry; the design-review-hardened contract, review wf_900e9965-8f0 H1):
|
|
/// <list type="bullet">
|
|
/// <item>a growing/pulsing warning DISC at <see cref="Tuning.GeyserEruptRadius"/> over the last
|
|
/// <see cref="Tuning.GeyserTelegraphTicks"/> before the eruption (the absolute-tick countdown, exactly the
|
|
/// enemy-telegraph idiom);</item>
|
|
/// <item>the erupt BURST fired when the countdown CROSSES <c>>0 → <=0</c>, LATCHED once per
|
|
/// <c>NextEruptTick</c> value + an ARM-guard (only if a prior frame saw it counting down) — immune to the
|
|
/// <c>0→stamp</c> phantom, relevancy re-entry mid-period, and snapshot loss.</item>
|
|
/// </list>
|
|
/// Erupt VFX reuse the shipped statics — <see cref="FeedbackFx"/> burst + <see cref="DynamicLightSystem.RequestFlash"/>
|
|
/// + <see cref="ScorchDecalSystem.RequestScorch"/> (bundle 2, built for this). Pooled disc GameObjects under a
|
|
/// private root, pruned every frame; a pruned geyser (teardown / relevancy drop) is SILENT.
|
|
/// </summary>
|
|
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
|
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
|
public partial class GeyserTelegraphSystem : SystemBase
|
|
{
|
|
static readonly Color DiscColor = new Color(2.6f, 0.35f, 2.4f); // hot blight magenta-purple (danger + biome id)
|
|
static readonly Color EruptColor = new Color(2.9f, 0.5f, 3.2f); // brighter burst
|
|
static readonly int ColorId = Shader.PropertyToID("_Color");
|
|
|
|
Transform _fxRoot;
|
|
Material _discMat;
|
|
Material _burstMat;
|
|
Mesh _discMesh;
|
|
ParticleSystem _eruptFx;
|
|
AudioClip _eruptClip;
|
|
MaterialPropertyBlock _mpb;
|
|
|
|
readonly Dictionary<Entity, GameObject> _discs = new();
|
|
readonly Dictionary<Entity, uint> _armed = new(); // NextEruptTick we've seen counting down for
|
|
readonly Dictionary<Entity, uint> _lastFired = new(); // NextEruptTick we last fired the burst for (latch)
|
|
readonly HashSet<Entity> _seen = new();
|
|
readonly List<Entity> _stale = new();
|
|
|
|
protected override void OnCreate()
|
|
{
|
|
_eruptClip = MakeClip("geyser_erupt", 90f, 300f, 0.30f, 0.55f, noise: true, decay: 5f);
|
|
_mpb = new MaterialPropertyBlock();
|
|
}
|
|
|
|
protected override void OnStartRunning()
|
|
{
|
|
if (_fxRoot != null) return;
|
|
_fxRoot = new GameObject("~GeyserTelegraphFX").transform;
|
|
_discMat = MakeParticleMaterial("GeyserDisc");
|
|
_discMat.color = DiscColor;
|
|
_discMesh = BuildDisc(40);
|
|
_burstMat = MakeParticleMaterial("GeyserErupt");
|
|
_eruptFx = MakeBurst(_fxRoot, "GeyserErupt", _burstMat, EruptColor, 0.18f, 8f, 0.5f, 256, -0.15f, 0.25f, 0.25f);
|
|
}
|
|
|
|
protected override void OnDestroy()
|
|
{
|
|
if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject);
|
|
if (_discMat != null) Object.Destroy(_discMat);
|
|
if (_burstMat != null) Object.Destroy(_burstMat);
|
|
if (_discMesh != null) Object.Destroy(_discMesh);
|
|
FeedbackFx.DestroyClip(ref _eruptClip); // not owned by _fxRoot — see FeedbackFx.DestroyClip
|
|
}
|
|
|
|
protected override void OnUpdate()
|
|
{
|
|
if (_fxRoot == null || _discMat == null) return;
|
|
if (!SystemAPI.TryGetSingleton<NetworkTime>(out var nt) || !nt.ServerTick.IsValid) return;
|
|
var serverTick = nt.ServerTick;
|
|
|
|
EntityManager.CompleteDependencyBeforeRO<Geyser>();
|
|
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
|
|
|
|
_seen.Clear();
|
|
foreach (var (geyser, xf, e) in
|
|
SystemAPI.Query<RefRO<Geyser>, RefRO<LocalTransform>>().WithEntityAccess())
|
|
{
|
|
uint next = geyser.ValueRO.NextEruptTick;
|
|
if (next == 0u) continue; // unstamped -> no telegraph yet (born-correct pending)
|
|
var untilTick = new NetworkTick(next);
|
|
if (!untilTick.IsValid) continue;
|
|
_seen.Add(e);
|
|
|
|
int lead = untilTick.TicksSince(serverTick); // >0 = erupt in the future, <=0 = erupt now/past
|
|
float3 pos = xf.ValueRO.Position;
|
|
|
|
if (lead > 0)
|
|
{
|
|
_armed[e] = next; // arm this eruption while it counts down
|
|
}
|
|
else // lead <= 0: the erupt tick has arrived / passed
|
|
{
|
|
bool armedForThis = _armed.TryGetValue(e, out var av) && av == next;
|
|
bool alreadyFired = _lastFired.TryGetValue(e, out var lf) && lf == next;
|
|
if (armedForThis && !alreadyFired)
|
|
{
|
|
// fire ONCE per eruption, on the ServerTick crossing (co-located with the disc completion)
|
|
EmitTinted(_eruptFx, (Vector3)pos + Vector3.up * 0.3f, 44, EruptColor);
|
|
DynamicLightSystem.RequestFlash((Vector3)pos, new Color(0.85f, 0.35f, 1f), 1.4f);
|
|
ScorchDecalSystem.RequestScorch((Vector3)pos, Tuning.GeyserEruptRadius);
|
|
PlayClip(_eruptClip, (Vector3)pos, 0.7f);
|
|
_lastFired[e] = next;
|
|
}
|
|
}
|
|
|
|
// Warning disc: grow + pulse over the telegraph window; hidden when dormant or erupted.
|
|
bool showing = lead > 0 && lead <= Tuning.GeyserTelegraphTicks;
|
|
if (showing)
|
|
{
|
|
float charge = 1f - lead / (float)Tuning.GeyserTelegraphTicks; // 0 at window open -> 1 at erupt
|
|
if (!_discs.TryGetValue(e, out var go) || go == null)
|
|
{
|
|
go = new GameObject("GeyserWarning");
|
|
go.transform.SetParent(_fxRoot, false);
|
|
go.AddComponent<MeshFilter>().sharedMesh = _discMesh;
|
|
var mr = go.AddComponent<MeshRenderer>();
|
|
mr.sharedMaterial = _discMat;
|
|
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
|
mr.receiveShadows = false;
|
|
mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
|
|
_discs[e] = go;
|
|
}
|
|
if (!go.activeSelf) go.SetActive(true);
|
|
float radius = Tuning.GeyserEruptRadius * (0.55f + 0.45f * charge);
|
|
go.transform.position = new Vector3(pos.x, 0.055f, pos.z);
|
|
go.transform.localScale = new Vector3(radius, 1f, radius);
|
|
float alpha = 0.14f + 0.5f * charge + 0.12f * Mathf.Abs(Mathf.Sin(UnityEngine.Time.time * 10f));
|
|
_mpb.SetColor(ColorId, new Color(DiscColor.r, DiscColor.g, DiscColor.b, alpha));
|
|
go.GetComponent<MeshRenderer>().SetPropertyBlock(_mpb);
|
|
}
|
|
else if (_discs.TryGetValue(e, out var go) && go != null && go.activeSelf)
|
|
{
|
|
go.SetActive(false); // dormant (lead > window) or just erupted (lead <= 0)
|
|
}
|
|
}
|
|
|
|
// Prune despawned geysers (teardown / relevancy drop) — destroy the disc, drop tracking, emit nothing.
|
|
if (_discs.Count > 0)
|
|
{
|
|
_stale.Clear();
|
|
foreach (var kv in _discs) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
|
|
for (int i = 0; i < _stale.Count; i++)
|
|
{
|
|
if (_discs[_stale[i]] != null) Object.Destroy(_discs[_stale[i]]);
|
|
_discs.Remove(_stale[i]);
|
|
}
|
|
}
|
|
PruneMap(_armed);
|
|
PruneMap(_lastFired);
|
|
}
|
|
|
|
void PruneMap(Dictionary<Entity, uint> dict)
|
|
{
|
|
if (dict.Count == 0) return;
|
|
_stale.Clear();
|
|
foreach (var kv in dict) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
|
|
for (int i = 0; i < _stale.Count; i++) dict.Remove(_stale[i]);
|
|
}
|
|
}
|
|
}
|