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>
284 lines
14 KiB
C#
284 lines
14 KiB
C#
using UnityEngine;
|
||
|
||
namespace ProjectM.Client
|
||
{
|
||
/// <summary>
|
||
/// Shared asset-free procedural-FX toolkit for the client presentation FEEDBACK systems (Combat / Structure /
|
||
/// World / Ambient), which previously each carried a near-identical private copy. Pure UnityEngine (no ECS), so
|
||
/// it stays a plain static helper. MakeBurst takes the owning FX-root transform + the per-use gravity / shape
|
||
/// radius / size-tail that were the ONLY real differences between the old copies (Combat 0/0.06/0.2,
|
||
/// Structure 0.3/0.18/0.15, World 0.25/0.10/0.2). MakeClip folds in the noise + envelope-decay knobs that were
|
||
/// the only differences between the CombatFeedback MakeClip and the AmbientAudio MakeSting.
|
||
/// </summary>
|
||
public static class FeedbackFx
|
||
{
|
||
public static AudioClip MakeClip(string name, float f0, float f1, float dur, float vol, bool noise = false, float decay = 5f)
|
||
{
|
||
const int rate = 44100;
|
||
int len = Mathf.Max(16, (int)(dur * rate));
|
||
var clip = AudioClip.Create(name, len, 1, rate, false);
|
||
var data = new float[len];
|
||
var rng = new System.Random(name.Length * 9973 + 7);
|
||
float phase = 0f;
|
||
for (int i = 0; i < len; i++)
|
||
{
|
||
float t = i / (float)len;
|
||
float env = Mathf.Exp(-decay * t);
|
||
float freq = Mathf.Lerp(f0, f1, t);
|
||
phase += 2f * Mathf.PI * freq / rate;
|
||
float s = noise ? (float)(rng.NextDouble() * 2.0 - 1.0) : Mathf.Sin(phase);
|
||
data[i] = s * env * vol;
|
||
}
|
||
clip.SetData(data, 0);
|
||
return clip;
|
||
}
|
||
|
||
static Texture2D s_softDot;
|
||
|
||
/// <summary>
|
||
/// Sprites/Default with NO texture renders hard SQUARES — which is what the ambient drift was painting
|
||
/// across the seabed (found at the 2026-08-07 A0 gate). Build the dot procedurally instead of referencing
|
||
/// an asset: runtime Resources.Load is stripped from player builds, and asset-free presentation is the
|
||
/// house rule. Self-healing across fast-enter-playmode — a destroyed texture compares == null and rebuilds.
|
||
/// </summary>
|
||
public static Texture2D SoftDot()
|
||
{
|
||
if (s_softDot != null) return s_softDot;
|
||
const int N = 32;
|
||
var t = new Texture2D(N, N, TextureFormat.RGBA32, false)
|
||
{
|
||
name = "T_SoftDotProc",
|
||
wrapMode = TextureWrapMode.Clamp
|
||
};
|
||
for (int y = 0; y < N; y++)
|
||
{
|
||
for (int x = 0; x < N; x++)
|
||
{
|
||
float dx = (x + 0.5f) / N * 2f - 1f;
|
||
float dy = (y + 0.5f) / N * 2f - 1f;
|
||
float a = Mathf.Clamp01(1f - Mathf.Sqrt(dx * dx + dy * dy));
|
||
t.SetPixel(x, y, new Color(1f, 1f, 1f, a * a)); // squared = softer falloff
|
||
}
|
||
}
|
||
t.Apply();
|
||
s_softDot = t;
|
||
return t;
|
||
}
|
||
|
||
public static Material MakeParticleMaterial(string name = "FeedbackParticle")
|
||
{
|
||
// Sprites/Default is an always-included transparent vertex-coloured shader (reliable billboarded sparks;
|
||
// HDR start colours still push past the bloom threshold); fall back through URP unlit / Unlit/Color.
|
||
Shader sh = Shader.Find("Sprites/Default");
|
||
if (sh == null) sh = Shader.Find("Universal Render Pipeline/Particles/Unlit");
|
||
if (sh == null) sh = Shader.Find("Unlit/Color");
|
||
return new Material(sh) { name = name, mainTexture = SoftDot() };
|
||
}
|
||
|
||
public static ParticleSystem MakeBurst(Transform parent, string name, Material mat, Color color,
|
||
float size, float speed, float life, int max,
|
||
float gravity = 0f, float shapeRadius = 0.06f, float sizeTail = 0.2f)
|
||
{
|
||
var go = new GameObject(name);
|
||
go.transform.SetParent(parent, false);
|
||
var ps = go.AddComponent<ParticleSystem>();
|
||
|
||
var main = ps.main;
|
||
main.loop = false;
|
||
main.playOnAwake = false;
|
||
main.startLifetime = life;
|
||
main.startSpeed = speed;
|
||
main.startSize = size;
|
||
main.startColor = color;
|
||
main.maxParticles = max;
|
||
main.gravityModifier = gravity;
|
||
main.simulationSpace = ParticleSystemSimulationSpace.World;
|
||
|
||
var emission = ps.emission;
|
||
emission.enabled = false; // manual Emit(count)
|
||
|
||
var shape = ps.shape;
|
||
shape.enabled = true;
|
||
shape.shapeType = ParticleSystemShapeType.Sphere;
|
||
shape.radius = shapeRadius;
|
||
|
||
var colOverLife = ps.colorOverLifetime;
|
||
colOverLife.enabled = true;
|
||
var grad = new Gradient();
|
||
grad.SetKeys(
|
||
new[] { new GradientColorKey(Color.white, 0f), new GradientColorKey(Color.white, 1f) },
|
||
new[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(0f, 1f) });
|
||
colOverLife.color = new ParticleSystem.MinMaxGradient(grad);
|
||
|
||
var sizeOverLife = ps.sizeOverLifetime;
|
||
sizeOverLife.enabled = true;
|
||
sizeOverLife.size = new ParticleSystem.MinMaxCurve(1f, AnimationCurve.Linear(0f, 1f, 1f, sizeTail));
|
||
|
||
var renderer = ps.GetComponent<ParticleSystemRenderer>();
|
||
renderer.material = mat;
|
||
renderer.renderMode = ParticleSystemRenderMode.Billboard;
|
||
return ps;
|
||
}
|
||
|
||
public static void EmitTinted(ParticleSystem ps, Vector3 pos, int count, Color tint)
|
||
{
|
||
if (ps == null) return;
|
||
var main = ps.main;
|
||
main.startColor = tint;
|
||
ps.transform.position = pos;
|
||
ps.Emit(count);
|
||
}
|
||
|
||
public static void EmitAt(ParticleSystem ps, Vector3 pos, int count)
|
||
{
|
||
if (ps == null) return;
|
||
ps.transform.position = pos;
|
||
ps.Emit(count);
|
||
}
|
||
|
||
public static void PlayClip(AudioClip clip, Vector3 pos, float vol)
|
||
{
|
||
if (clip == null) return;
|
||
// Pooled 3D voices, NOT AudioSource.PlayClipAtPoint: that allocates a GameObject +
|
||
// AudioSource per call and schedules a delayed Destroy, ~20-33 times a second in combat.
|
||
// GameVolume.Sfx is read HERE (at play time) so the bus trim applies per cue, as before.
|
||
OneShotAudioPool.Play(clip, pos, vol * GameVolume.Sfx);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Destroy a procedurally-built clip on world teardown. An <c>AudioClip.Create</c>d clip is a standalone
|
||
/// UnityEngine.Object — destroying the system's FX-root GameObject does NOT take it with it, so every
|
||
/// presentation system that builds clips leaked its native audio buffer on each client-world teardown
|
||
/// (unbounded growth across menu -> game -> menu cycles). Nulls the reference so a re-created system
|
||
/// rebuilds rather than holding a destroyed clip.
|
||
/// </summary>
|
||
public static void DestroyClip(ref AudioClip clip)
|
||
{
|
||
if (clip != null) Object.Destroy(clip);
|
||
clip = null;
|
||
}
|
||
|
||
// ---- 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;
|
||
}
|
||
|
||
// Unit-radius upward-facing disc fan on the XZ plane (centre vertex + rim) for ground telegraph / blast-radius
|
||
// rings — scale x/z to the radius. No vertex colours (white default) so the material or a per-renderer _Color
|
||
// MPB fully tints + fades it. Promoted from WorldFeedbackSystem so the barrel fuse ring + the geyser telegraph
|
||
// share ONE primitive (review M6).
|
||
public static Mesh BuildDisc(int segments)
|
||
{
|
||
if (segments < 6) segments = 6;
|
||
var m = new Mesh { name = "Disc" };
|
||
var v = new Vector3[segments + 1];
|
||
var tris = new int[segments * 3];
|
||
v[0] = Vector3.zero;
|
||
for (int i = 0; i < segments; i++)
|
||
{
|
||
float a = i / (float)segments * Mathf.PI * 2f;
|
||
v[i + 1] = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a));
|
||
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.triangles = tris;
|
||
m.RecalculateBounds();
|
||
return m;
|
||
}
|
||
|
||
// Unit-radius thin RING (annulus) on the XZ plane — the always-on rim of a zone telegraph (rim = the TRUE
|
||
// damage radius, guidelines G2) while a separate fill disc grows inside it. Scale x/z to the radius; inner
|
||
// edge fixed at innerFrac of the outer. No vertex colours (white) so an MPB _Color fully tints + fades it.
|
||
public static Mesh BuildRing(int segments, float innerFrac = 0.92f)
|
||
{
|
||
if (segments < 6) segments = 6;
|
||
innerFrac = Mathf.Clamp(innerFrac, 0.05f, 0.98f);
|
||
var m = new Mesh { name = "Ring" };
|
||
var v = new Vector3[segments * 2];
|
||
var tris = new int[segments * 6];
|
||
for (int i = 0; i < segments; i++)
|
||
{
|
||
float a = i / (float)segments * Mathf.PI * 2f;
|
||
var dir = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a));
|
||
v[i * 2] = dir * innerFrac;
|
||
v[i * 2 + 1] = dir;
|
||
int n = (i + 1) % segments;
|
||
int b = i * 6;
|
||
tris[b] = i * 2; tris[b + 1] = n * 2 + 1; tris[b + 2] = i * 2 + 1;
|
||
tris[b + 3] = i * 2; tris[b + 4] = n * 2; tris[b + 5] = n * 2 + 1;
|
||
}
|
||
m.vertices = v;
|
||
m.triangles = tris;
|
||
m.RecalculateBounds();
|
||
return m;
|
||
}
|
||
}
|
||
}
|