Files
Project-M/Assets/_Project/Scripts/Client/Presentation/MusicSystem.cs
T
kronic 62e48a3b0b LANTERN purge: delete the superseded base/expedition shell (audit H1/H3/M5)
The 2026-08-06 audit found the shipping scene was still the abandoned
co-op-Hades game with LANTERN combat bolted on, and that a third of the
codebase was live code for a direction abandoned on 2026-07-13. Operator
chose deletion over freezing: "everything is saved in source control if
needed. I want the project to be clean."

DELETED (~140 source files, Scripts 335->231, Tests 77->43):
- Enemy variants + boss (H3). ChargerAuthoring / SpitterAuthoring /
  SwarmerAuthoring were attached to ZERO prefabs, so LungeState /
  SpitterState / SwarmerTag were never baked: ~272 lines of Bursted AI
  passes, BossAISystem (261 lines) and the whole MixBands escalation
  curve could not match a single chunk at runtime, while 734 lines of
  green tests certified them. Both shipping enemy prefabs were already
  byte-identical in stats.
- Run/room lifecycle: RunDirector FSM, RunInfo/RunMap/RoomPlan/RoomTag,
  route select, portal interact, ready-check, room field/teardown.
- Meta shop, prep loadout, boons (incl. KillRewardSystem and
  DashTrailDamageSystem, which existed only to serve boon flags).
- Build palette + structures, shared storage, inventory/equipment
  (already recorded PAUSED in CLAUDE.md).
- The HUD panels driving all of the above (HudSystem 1168 -> 610).

KEPT deliberately: BaseGridMath + BaseAnchor (8 systems use PlotCenter
for spawn rings, respawn and dynamic light), the resource ledger +
StorageMath, the save system, region/relevancy. Three of these were in
the delete set until I checked their consumers — worth remembering that
the file-level manifest was wrong about them.

Also folds in audit finding M5: PlayerClass was a second, server-only
copy of the byte FrameId already replicates. It existed for the meta
shop; with that gone, FrameId is the single frame identity.

Harvest is now single-sink (ledger). HarvestMath keeps its shape so
LANTERN's carried-vs-banked cargo split lands in one place, not two.

295/295 EditMode green, zero compile errors. Subscene re-bake and Play
validation follow in the next commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 12:59:39 -07:00

218 lines
8.9 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;
// 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;
}
}
}