Files
Project-M/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs
T
kronic 52eda31360 Hygiene B4a: extract shared FeedbackFx (dedup 4 procedural-FX copies)
New FeedbackFx static (MakeClip/MakeParticleMaterial/MakeBurst/PlayClip/EmitTinted/EmitAt); the 3 *FeedbackSystem copies + AmbientAudio.MakeSting now route through it via 'using static'. MakeBurst takes the FX-root + the per-use gravity/radius/sizeTail that were the only diffs between copies; MakeClip folds in noise + decay. Behaviour-identical by construction (every particle/clip param preserved exactly).

459/459 EditMode tests pass; compiles clean. VFX are presentation-only (no EditMode coverage) -> wants a Play-mode smoke to eyeball hit/death/harvest/structure bursts.

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

182 lines
7.9 KiB
C#

using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-only AMBIENT audio + cycle-phase stingers. A managed presentation <see cref="SystemBase"/>
/// (<see cref="PresentationSystemGroup"/>, main thread, no Burst) that OBSERVES the replicated
/// <see cref="CycleState"/> and never touches the simulation. On start it plays a low, seamless-looping
/// procedural drone (asset-free, <c>AudioClip.Create</c> like <c>CombatFeedbackSystem.MakeClip</c>); each
/// time the cycle phase changes it plays a short procedural stinger and eases the drone's intensity by phase
/// (calmer at base, tenser during Defend / "wave incoming"). Lives only in the client world, so the server
/// never creates audio and nothing here affects determinism. Volumes are deliberately conservative + tunable.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class AmbientAudioSystem : SystemBase
{
AudioSource _ambient;
AudioClip _ambientClip;
AudioClip _stingExpedition;
AudioClip _stingDefend;
AudioClip _stingBuild;
GameObject _root;
byte _lastPhase;
bool _phaseInit;
AudioClip _stingCoreHit;
int _lastCore = -1;
float _coreStingCooldown;
AudioClip _stingBeep, _stingRoar;
int _lastCountdownSec = -1;
bool _bossRoared;
const float AmbientBaseVolume = 0.10f; // low bed; Defend eases up to ~1.7x
protected override void OnCreate()
{
_ambientClip = MakeDrone();
_stingExpedition = MakeSting(520f, 880f, 0.45f, 0.30f); // airy rising "deploy"
_stingDefend = MakeSting(300f, 140f, 0.55f, 0.42f); // tense falling "wave incoming"
_stingBuild = MakeSting(440f, 660f, 0.40f, 0.26f); // soft confirm
_stingCoreHit = MakeSting(240f, 70f, 0.30f, 0.55f); // harsh falling "Core hit" alarm
_stingBeep = MakeSting(880f, 880f, 0.09f, 0.30f); // countdown tick
_stingRoar = MakeSting(90f, 38f, 0.90f, 0.60f); // boss-arrival roar (low falling growl)
}
protected override void OnStartRunning()
{
if (_root != null) return;
_root = new GameObject("~AmbientAudio");
_ambient = _root.AddComponent<AudioSource>();
_ambient.clip = _ambientClip;
_ambient.loop = true;
_ambient.playOnAwake = false;
_ambient.spatialBlend = 0f; // 2D bed
_ambient.volume = AmbientBaseVolume * GameVolume.Music;
_ambient.Play();
}
protected override void OnDestroy()
{
if (_root != null) Object.Destroy(_root);
}
protected override void OnUpdate()
{
if (_ambient == null) return;
if (!SystemAPI.TryGetSingleton<CycleState>(out var cyc)) return;
byte phase = cyc.Phase;
if (!_phaseInit)
{
_lastPhase = phase; // adopt the current phase silently (no stinger on first observe)
_phaseInit = true;
}
else if (phase != _lastPhase)
{
PlaySting(phase);
_lastPhase = phase;
}
// Ease the drone intensity toward the phase target (tenser during Defend).
float target = phase == CyclePhase.Siege ? AmbientBaseVolume * 1.7f : AmbientBaseVolume;
_ambient.volume = Mathf.MoveTowards(_ambient.volume, target * GameVolume.Music, SystemAPI.Time.DeltaTime * 0.25f);
// Core-under-attack alarm: a falling sting on each CoreIntegrity drop (rate-limited) so a base
// breach is AUDIBLE even when the fight has the player's eyes elsewhere.
_coreStingCooldown -= SystemAPI.Time.DeltaTime;
if (SystemAPI.TryGetSingleton<CoreIntegrity>(out var core))
{
if (_lastCore >= 0 && core.Current < _lastCore && _coreStingCooldown <= 0f)
{
_ambient.PlayOneShot(_stingCoreHit, 0.8f * GameVolume.Sfx);
_coreStingCooldown = 0.7f;
}
_lastCore = core.Current;
}
// Launch countdown beeps (3-2-1) + the boss-arrival roar — replicated-state observations only.
if (SystemAPI.TryGetSingleton<RunInfo>(out var runAudio))
{
int sec = -1;
if (runAudio.Lifecycle == RunLifecycle.Launching && runAudio.LaunchTick != 0
&& SystemAPI.TryGetSingleton<NetworkTime>(out var antime) && antime.ServerTick.IsValid)
{
int tl = new NetworkTick(runAudio.LaunchTick).TicksSince(antime.ServerTick);
if (tl > 0) sec = tl / 60 + 1;
}
if (sec > 0 && sec != _lastCountdownSec && sec <= 3)
_ambient.PlayOneShot(_stingBeep, 0.5f * GameVolume.Sfx);
_lastCountdownSec = sec;
bool inBossRoom = runAudio.Lifecycle == RunLifecycle.InRoom
&& runAudio.CurrentRoomType == RoomTypeId.Boss;
if (!inBossRoom)
{
_bossRoared = false; // re-arm for the next boss room
}
else if (!_bossRoared)
{
foreach (var _ in SystemAPI.Query<RefRO<Health>>().WithAll<EnemyTag>())
{
_ambient.PlayOneShot(_stingRoar, 0.9f * GameVolume.Sfx);
_bossRoared = true;
break;
}
}
}
}
void PlaySting(byte phase)
{
AudioClip clip = phase == CyclePhase.Siege ? _stingDefend : _stingBuild;
if (clip != null && _ambient != null)
_ambient.PlayOneShot(clip, 0.6f * GameVolume.Music);
}
// ---- Procedural audio (asset-free; mirrors CombatFeedbackSystem.MakeClip) ----
// A low, seamless-looping pad: each partial completes an integer number of cycles over the buffer
// (freq snapped to k/duration) so the loop point has no click. A slow tremolo adds motion.
static AudioClip MakeDrone()
{
const int rate = 44100;
const float dur = 4f;
int len = (int)(dur * rate);
var clip = AudioClip.Create("ambient_drone", len, 1, rate, false);
var data = new float[len];
float f0 = Snap(55f, dur); // sub
float f1 = Snap(110f, dur); // root
float f2 = Snap(164.81f, dur); // fifth-ish
float f3 = Snap(220f, dur);
float trem = Snap(0.5f, dur); // slow amplitude wobble
for (int i = 0; i < len; i++)
{
float t = i / (float)rate;
float s = 0.50f * Mathf.Sin(2f * Mathf.PI * f0 * t)
+ 0.35f * Mathf.Sin(2f * Mathf.PI * f1 * t)
+ 0.18f * Mathf.Sin(2f * Mathf.PI * f2 * t)
+ 0.10f * Mathf.Sin(2f * Mathf.PI * f3 * t);
float amp = 0.75f + 0.25f * Mathf.Sin(2f * Mathf.PI * trem * t);
data[i] = s * amp * 0.5f; // peak ~0.57, no clipping
}
clip.SetData(data, 0);
return clip;
}
// freq snapped so freq*dur is an integer -> the waveform closes seamlessly at the loop point.
static float Snap(float freq, float dur)
{
float cycles = Mathf.Max(1f, Mathf.Round(freq * dur));
return cycles / dur;
}
// Short one-shot tone sweeping f0->f1 with an exponential decay envelope.
static AudioClip MakeSting(float f0, float f1, float dur, float vol) => FeedbackFx.MakeClip("sting", f0, f1, dur, vol, decay: 3.5f);
}
}