Files
Project-M/Assets/_Project/Scripts/Client/Presentation/MusicSystem.cs
T
kronic b34945c2d2 LANTERN purge B3+B5: delete the cycle/core/win-lose spine + onboarding; save epoch v7
Deletes CyclePhaseSystem, GoalReachedSystem, CoreDamage/CoreRestore, ThreatDirector,
CoreIntegrity/GoalProgress/RunPhase/RunOutcome/ThreatState components,
CoreVisualFeedbackSystem, and the whole Client/Onboarding slice (+6 test files).

Keepers reworked: RunDirectorSystem (UpdateBefore attr + launch guard + goal/threat
bank removed; sole SaveRequest raiser now), CycleDirectorSpawnSystem (ledger/meta
host only), WaveSystem UNGATED (waves run wherever a WaveDirector is baked),
EnemyAISystem core-fallback stripped, AmbientAudioSystem reworked (bed + run cues;
no CycleState gate), MusicSystem RunInfo-only, HudSystem big trim (goal meter, core
bar, siege banner, terminal banner, outcome flash, onboarding hook all gone),
MetaShop/ClassPrep/AimReticle siege gates dropped, DebugOverlay/ops re-meant
(SpawnWave=force next wave, EndSiege=quiet arena; SetCalm/AdvanceGoal/SetHeat
retired, bytes reserved), TuningConfig Core knobs retired (ids 20-23 reserved),
StorageMath.DrainFraction deleted, HowToPlay copy rewritten.

Save epoch v7 (fresh epoch, operator-approved): SaveData drops goal/core/outcome +
conveyor/machine-IO fields; MinLoadableVersion=7; PendingSave/PendingStructure
trimmed; RollTerminalCampaignForward deleted; SaveStructureScan signature slimmed.

390 tests green; Play world-creation clean (player + waves live, no exceptions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:27:12 -07:00

239 lines
9.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, AmFCG) 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()
{
if (_root != null) Object.Destroy(_root);
}
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;
if (SystemAPI.TryGetSingleton<RunInfo>(out var run))
{
switch (run.Lifecycle)
{
case RunLifecycle.Launching:
tBass = 0.55f; tPad = 0.50f; tArp = 0.35f; tPulse = 0.20f; // anticipation swell
break;
case RunLifecycle.InRoom:
bool boss = run.CurrentRoomType == RoomTypeId.Boss;
tBass = boss ? 0.75f : 0.65f;
tPad = 0.40f;
tArp = boss ? 0.80f : 0.65f;
tPulse = boss ? 0.75f : 0.35f;
break;
case RunLifecycle.RoomReward:
case RunLifecycle.RouteSelect:
tBass = 0.45f; tPad = 0.55f; tArp = 0.25f; tPulse = 0.08f; // between-rooms lull
break;
case RunLifecycle.Returning:
tBass = 0.45f; tPad = 0.60f; tArp = 0.15f; tPulse = 0f; // resolution
break;
// Staging keeps the defaults.
}
}
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;
}
}
}