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>
233 lines
9.5 KiB
C#
233 lines
9.5 KiB
C#
using ProjectM.Simulation;
|
||
using Unity.Entities;
|
||
using Unity.NetCode;
|
||
using UnityEngine;
|
||
|
||
namespace ProjectM.Client
|
||
{
|
||
/// <summary>
|
||
/// Client-only PROCEDURAL MUSIC — four synchronized loop layers (bass / chord pad / arpeggio / percussion
|
||
/// pulse), all generated over the SAME 4-bar buffer (100 BPM, Am–F–C–G) so they stay phase-locked forever:
|
||
/// every AudioSource starts the same frame with loop=true and identical clip length, and per-bar amplitude
|
||
/// envelopes reach ~zero at each bar boundary so both the chord changes and the loop point are click-free
|
||
/// (the <c>AmbientAudioSystem.Snap</c> trick generalized to enveloped segments). The MIX is the state
|
||
/// machine: layer volumes ease toward targets chosen from replicated state only — the <see cref="RunInfo"/>
|
||
/// lifecycle (staging / launch / combat / boss / reward lull / return). Observe-only presentation
|
||
/// system: no sim writes, no determinism surface; asset-free per the project convention. Sits under SFX at
|
||
/// <see cref="MasterVolume"/> × <see cref="GameVolume.Music"/>; the low <c>AmbientAudioSystem</c> drone
|
||
/// (vol 0.10) remains as texture beneath it.
|
||
/// </summary>
|
||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||
public partial class MusicSystem : SystemBase
|
||
{
|
||
const float MasterVolume = 0.45f;
|
||
const float FadePerSecond = 0.30f;
|
||
|
||
// ---- shared musical grid: 100 BPM, 4/4, one chord per bar, 4 bars ----
|
||
const int SampleRate = 44100;
|
||
const float BarSeconds = 2.4f; // 4 beats @ 0.6 s
|
||
const int Bars = 4;
|
||
const float LoopSeconds = BarSeconds * Bars; // 9.6 s
|
||
|
||
// Am – F – C – G (natural-minor loop; index = bar): root/third/fifth per chord.
|
||
static readonly float[][] ChordHz =
|
||
{
|
||
new[] { 110.00f, 130.81f, 164.81f }, // Am: A2 C3 E3
|
||
new[] { 87.31f, 110.00f, 130.81f }, // F: F2 A2 C3
|
||
new[] { 130.81f, 164.81f, 196.00f }, // C: C3 E3 G3
|
||
new[] { 98.00f, 123.47f, 146.83f }, // G: G2 B2 D3
|
||
};
|
||
|
||
GameObject _root;
|
||
AudioSource _bass, _pad, _arp, _pulse;
|
||
float _vBass, _vPad, _vArp, _vPulse; // current smoothed volumes (pre-master)
|
||
|
||
protected override void OnStartRunning()
|
||
{
|
||
if (_root != null) return;
|
||
_root = new GameObject("~Music");
|
||
_bass = MakeSource(BuildBassLoop());
|
||
_pad = MakeSource(BuildPadLoop());
|
||
_arp = MakeSource(BuildArpLoop());
|
||
_pulse = MakeSource(BuildPulseLoop());
|
||
// Same frame, same length, loop=true -> phase-locked for the whole session.
|
||
_bass.Play(); _pad.Play(); _arp.Play(); _pulse.Play();
|
||
}
|
||
|
||
protected override void OnDestroy()
|
||
{
|
||
// Read the clips off the sources BEFORE destroying _root: the AudioSources die with it, but the four
|
||
// AudioClip.Create'd loops are standalone objects that do not (~6.8 MB of native audio, leaked on
|
||
// every client-world teardown). See FeedbackFx.DestroyClip.
|
||
DestroySourceClip(_bass);
|
||
DestroySourceClip(_pad);
|
||
DestroySourceClip(_arp);
|
||
DestroySourceClip(_pulse);
|
||
if (_root != null) Object.Destroy(_root);
|
||
}
|
||
|
||
static void DestroySourceClip(AudioSource src)
|
||
{
|
||
if (src == null) return;
|
||
var clip = src.clip;
|
||
src.clip = null;
|
||
FeedbackFx.DestroyClip(ref clip);
|
||
}
|
||
|
||
AudioSource MakeSource(AudioClip clip)
|
||
{
|
||
var src = _root.AddComponent<AudioSource>();
|
||
src.clip = clip;
|
||
src.loop = true;
|
||
src.playOnAwake = false;
|
||
src.spatialBlend = 0f;
|
||
src.volume = 0f;
|
||
return src;
|
||
}
|
||
|
||
protected override void OnUpdate()
|
||
{
|
||
if (_bass == null) return;
|
||
|
||
// ---- pick the mix from replicated state (defaults = quiet staging bed) ----
|
||
float tBass = 0.50f, tPad = 0.55f, tArp = 0.12f, tPulse = 0f;
|
||
|
||
// 2026-08-07 audit purge: the mix used to key off RunInfo.Lifecycle (staging / launching / in-room /
|
||
// boss / returning). With the run FSM deleted the bed holds its defaults; re-key it off LANTERN's
|
||
// descent state when Phase 2 lands.
|
||
|
||
float dt = SystemAPI.Time.DeltaTime * FadePerSecond;
|
||
_vBass = Mathf.MoveTowards(_vBass, tBass, dt);
|
||
_vPad = Mathf.MoveTowards(_vPad, tPad, dt);
|
||
_vArp = Mathf.MoveTowards(_vArp, tArp, dt);
|
||
_vPulse = Mathf.MoveTowards(_vPulse, tPulse, dt);
|
||
|
||
float master = MasterVolume * GameVolume.Music;
|
||
_bass.volume = _vBass * master;
|
||
_pad.volume = _vPad * master;
|
||
_arp.volume = _vArp * master;
|
||
_pulse.volume = _vPulse * master;
|
||
}
|
||
|
||
|
||
|
||
// ================= clip builders (deterministic, asset-free) =================
|
||
|
||
static AudioClip NewLoopClip(string name, out float[] data)
|
||
{
|
||
int len = (int)(LoopSeconds * SampleRate);
|
||
data = new float[len];
|
||
return AudioClip.Create(name, len, 1, SampleRate, false);
|
||
}
|
||
|
||
/// <summary>Bar-local envelope that is ~0 at both bar edges (click-free chord changes + loop point).</summary>
|
||
static float BarEnv(float tInBar, float attack, float release)
|
||
{
|
||
float up = Mathf.Clamp01(tInBar / attack);
|
||
float down = Mathf.Clamp01((BarSeconds - tInBar) / release);
|
||
return up * up * down;
|
||
}
|
||
|
||
static AudioClip BuildBassLoop()
|
||
{
|
||
var clip = NewLoopClip("music_bass", out var data);
|
||
for (int i = 0; i < data.Length; i++)
|
||
{
|
||
float t = i / (float)SampleRate;
|
||
int bar = Mathf.Min(Bars - 1, (int)(t / BarSeconds));
|
||
float tb = t - bar * BarSeconds;
|
||
float f = ChordHz[bar][0] * 0.5f; // root, an octave down
|
||
float s = Mathf.Sin(2f * Mathf.PI * f * t) * 0.8f
|
||
+ Mathf.Sin(2f * Mathf.PI * f * 2f * t) * 0.2f;
|
||
data[i] = s * BarEnv(tb, 0.03f, 0.12f) * 0.50f;
|
||
}
|
||
clip.SetData(data, 0);
|
||
return clip;
|
||
}
|
||
|
||
static AudioClip BuildPadLoop()
|
||
{
|
||
var clip = NewLoopClip("music_pad", out var data);
|
||
for (int i = 0; i < data.Length; i++)
|
||
{
|
||
float t = i / (float)SampleRate;
|
||
int bar = Mathf.Min(Bars - 1, (int)(t / BarSeconds));
|
||
float tb = t - bar * BarSeconds;
|
||
var chord = ChordHz[bar];
|
||
float s = 0f;
|
||
for (int n = 0; n < chord.Length; n++)
|
||
s += Mathf.Sin(2f * Mathf.PI * chord[n] * t + n * 1.7f);
|
||
float trem = 0.85f + 0.15f * Mathf.Sin(2f * Mathf.PI * 0.8f * t);
|
||
data[i] = s / 3f * BarEnv(tb, 0.45f, 0.50f) * trem * 0.34f;
|
||
}
|
||
clip.SetData(data, 0);
|
||
return clip;
|
||
}
|
||
|
||
static AudioClip BuildArpLoop()
|
||
{
|
||
var clip = NewLoopClip("music_arp", out var data);
|
||
const float noteLen = BarSeconds / 8f; // 8th notes
|
||
for (int i = 0; i < data.Length; i++)
|
||
{
|
||
float t = i / (float)SampleRate;
|
||
int bar = Mathf.Min(Bars - 1, (int)(t / BarSeconds));
|
||
float tb = t - bar * BarSeconds;
|
||
int step = Mathf.Min(7, (int)(tb / noteLen));
|
||
float tn = tb - step * noteLen;
|
||
var chord = ChordHz[bar];
|
||
// up-and-over pattern: root, fifth, octave, tenth, back down
|
||
float f = step switch
|
||
{
|
||
0 => chord[0] * 2f,
|
||
1 => chord[2] * 2f,
|
||
2 => chord[0] * 4f,
|
||
3 => chord[1] * 4f,
|
||
4 => chord[2] * 4f,
|
||
5 => chord[0] * 4f,
|
||
6 => chord[2] * 2f,
|
||
_ => chord[1] * 2f,
|
||
};
|
||
float env = Mathf.Exp(-9f * tn);
|
||
data[i] = Mathf.Sin(2f * Mathf.PI * f * tn) * env * 0.32f;
|
||
}
|
||
clip.SetData(data, 0);
|
||
return clip;
|
||
}
|
||
|
||
static AudioClip BuildPulseLoop()
|
||
{
|
||
var clip = NewLoopClip("music_pulse", out var data);
|
||
const float beat = BarSeconds / 4f;
|
||
uint rng = 0x5EED5EEDu; // deterministic hat noise
|
||
for (int i = 0; i < data.Length; i++)
|
||
{
|
||
float t = i / (float)SampleRate;
|
||
float tBeat = t % beat;
|
||
// Low thump on every beat: 55->38 Hz sweep, tight decay.
|
||
float thump = 0f;
|
||
if (tBeat < 0.14f)
|
||
{
|
||
float f = Mathf.Lerp(55f, 38f, tBeat / 0.14f);
|
||
thump = Mathf.Sin(2f * Mathf.PI * f * tBeat) * Mathf.Exp(-22f * tBeat) * 0.85f;
|
||
}
|
||
// Off-beat hat: a 30 ms noise tick halfway through each beat.
|
||
float tHat = (t + beat * 0.5f) % beat;
|
||
float hat = 0f;
|
||
if (tHat < 0.03f)
|
||
{
|
||
rng ^= rng << 13; rng ^= rng >> 17; rng ^= rng << 5; // xorshift
|
||
float n = (rng & 0xFFFF) / 32768f - 1f;
|
||
hat = n * Mathf.Exp(-120f * tHat) * 0.22f;
|
||
}
|
||
data[i] = thump + hat;
|
||
}
|
||
clip.SetData(data, 0);
|
||
return clip;
|
||
}
|
||
|
||
|
||
}
|
||
}
|