Static game audio manager for NoChillquarium. Defines SFX and music event constants, chooses and plays appropriate music beds and UI sounds based on game state (death stages, chaos, sharks, battle, training, detonations), handles volume multipliers, retries for missing assets, and per-tick updates.
namespace NoChillquarium;
/// <summary>
/// UI SFX + music beds.
/// Rule of thumb: <b>chill</b> when the tank is calm; <b>no-chill / metal</b>
/// when things get absurd or dark (chaos, sharks, meth, M80, death, battles…).
/// Priority: detonation sting > death ramp > absurd/no-chill > chill.
/// </summary>
public static class GameAudio
{
public const string Pop = "sounds/ui/pop";
public const string Notice = "sounds/ui/notice";
public const string Close = "sounds/ui/close_window";
public const string Error = "sounds/ui/error";
public const string Confirm = "sounds/ui/confirm";
public const string AlertWarning = "sounds/ui/alert_warning";
/// <summary>Dog bark — Dogecoin jackpots / big wallet wins only. Not for UI chrome.</summary>
public const string Doge = "sounds/ui/doge";
/// <summary>Short coin / trade sting — Dogecoin spend/earn feedback (not bark).</summary>
public const string Coin = "sounds/ui/trade";
public const string Feed = "sounds/ui/feed";
public const string Upgrade = "sounds/ui/upgrade";
public const string Trade = "sounds/ui/trade";
public const string Tape = "sounds/ui/tape";
public const string M80 = "sounds/ui/m80";
public const string Achievement = "sounds/ui/achievement";
public const string Inject = "sounds/ui/inject";
public const string MenuMusic = "sounds/music/bitten_crown";
public const string ChillTankMusic = "sounds/music/moonberry";
// Escalation ladder when a fish is dying (especially from chill).
// 1 unease → 2 metal → 3 heavy → 4 death peak
const string DeathStage1 = "sounds/music/gloom_cartridge"; // uneasy / dark
const string DeathStage2 = "sounds/music/game_boy_hex"; // gear up
const string DeathStage3 = "sounds/music/pixel_horns"; // heavy
const string DeathStage4 = "sounds/music/moonbyte_blasphemy"; // death metal peak
static readonly string[] MetalPlaylist =
{
"sounds/music/bitten_crown",
"sounds/music/game_boy_hex",
"sounds/music/pixel_horns",
"sounds/music/pixel_hex_rite"
};
static SoundHandle _music;
static string _musicEvent;
static int _metalIndex;
static int _lastDeathStage = -1;
static bool? _lastNoChillBed;
static float _detonationStingLeft;
static string _lastAbsurdReason = "";
static float _musicRetryCooldown;
static string _lastMissingMusic;
public static bool DetonationStingActive => _detonationStingLeft > 0f;
/// <summary>
/// True when gameplay should feel NO-CHILL (music + chrome), not only high chaos.
/// </summary>
public static bool WantsNoChillMusic => EvaluateAbsurd( out _ );
public static string AbsurdReason
{
get
{
EvaluateAbsurd( out var reason );
return reason ?? "";
}
}
public static string CurrentBedLabel
{
get
{
if ( string.IsNullOrEmpty( _musicEvent ) )
return "—";
if ( DetonationStingActive )
return "DEATH METAL";
if ( _musicEvent.Contains( "moonberry" ) )
return "CHILL BED";
if ( _musicEvent.Contains( "gloom" ) )
return "UNEASE BED";
if ( _musicEvent.Contains( "moonbyte" ) || _musicEvent.Contains( "pixel_hex" ) )
return "DEATH METAL";
if ( _musicEvent.Contains( "pixel_horns" ) )
return "HEAVY METAL";
return "NO-CHILL BED";
}
}
/// <summary>
/// Absurd / dark moments that pull the bed off chill even if chaos is still low.
/// Calm tank + low chaos + no hazards → chill.
/// </summary>
public static bool EvaluateAbsurd( out string reason )
{
reason = "";
if ( DetonationStingActive )
{
reason = "detonation";
return true;
}
if ( BoomFeel.IsActive )
{
reason = "boom";
return true;
}
if ( DeathFeel.PeakStage > 0 )
{
reason = "dying";
return true;
}
if ( SharkSystem.HasSharks )
{
reason = "sharks";
return true;
}
if ( BattleSystem.Phase == BattlePhase.Fighting )
{
reason = "battle";
return true;
}
if ( TrainingSystem.InjectOpen )
{
reason = "inject";
return true;
}
if ( ExplodeSystem.ModeActive && ExplodeSystem.SelectedCount > 0 )
{
reason = "m80-armed";
return true;
}
// Living absurdity in the active tank
if ( TankSim.IsActive )
{
var meth = 0;
var jacked = 0;
var wd = 0;
foreach ( var f in TankSim.Fish )
{
if ( f.OnMeth )
meth++;
if ( f.Jacked )
jacked++;
if ( f.InWithdrawal )
wd++;
}
if ( meth > 0 )
{
reason = "meth";
return true;
}
if ( wd >= 2 )
{
reason = "withdrawal";
return true;
}
if ( jacked >= 3 )
{
reason = "jacked-pack";
return true;
}
}
// High chaos meter = classic no-chill band
if ( Chaos.IsNoChill )
{
reason = "chaos";
return true;
}
// Ocean alone is tense but still "chill bed" until sharks or chaos — keep calm water chill.
return false;
}
/// <summary>Hard cut to peak death metal for a few seconds after M80 detonation.</summary>
public static void TriggerDetonationSting( int victimCount )
{
if ( GameFlow.Screen != GameScreen.Playing )
return;
var n = Math.Max( 1, victimCount );
_detonationStingLeft = 4.2f + n * 0.55f;
// Peak blasphemy + louder.
StartMusic( DeathStage4, force: true, volumeMult: 1.28f );
}
public static void PlayUi( string soundEvent )
{
if ( string.IsNullOrWhiteSpace( soundEvent ) )
return;
// Never let the bark leak through a mis-wire — only PlayDogeBark uses it.
if ( string.Equals( soundEvent, Doge, StringComparison.OrdinalIgnoreCase ) )
{
PlayDogeBark();
return;
}
var handle = PlayLocal( soundEvent, GameSettings.SfxVolume );
// One soft fail is fine — avoid console spam for SFX.
_ = handle;
}
/// <summary>
/// Dogecoin jackpot bark. Call only for meaningful Ð wins (fight prize, shark loot, big milestone).
/// </summary>
public static void PlayDogeBark()
{
var handle = PlayLocal( Doge, GameSettings.SfxVolume );
_ = handle;
}
/// <summary>Soft coin / trade ping for Dogecoin earn or spend feedback.</summary>
public static void PlayCoin()
{
var handle = PlayLocal( Coin, GameSettings.SfxVolume * 0.9f );
_ = handle;
}
/// <summary>Training minigame — clear hit / miss feedback (slightly hotter than chrome UI).</summary>
public static void PlayTrainRep( bool hit, bool perfect = false )
{
var vol = Math.Clamp( GameSettings.SfxVolume * 1.2f, 0f, 1f );
if ( !hit )
{
_ = PlayLocal( Error, vol );
return;
}
// Perfect = brighter confirm; solid hit = upgrade sting
_ = PlayLocal( perfect ? Confirm : Upgrade, vol );
// Soft second layer so a hit always "lands" even if one event fails
if ( perfect )
_ = PlayLocal( Notice, vol * 0.55f );
}
/// <summary>End of a 3-rep training set.</summary>
public static void PlayTrainSessionDone( bool anyHits )
{
var vol = Math.Clamp( GameSettings.SfxVolume * 1.15f, 0f, 1f );
_ = PlayLocal( anyHits ? Achievement : Close, vol );
}
public static void StartMenuMusic()
{
_lastDeathStage = -1;
_lastNoChillBed = null;
_detonationStingLeft = 0f;
_lastAbsurdReason = "";
StartMusic( MenuMusic, force: true );
}
public static void StartTankMusic()
{
_lastNoChillBed = null;
_lastDeathStage = -1;
_detonationStingLeft = 0f;
_lastAbsurdReason = "";
RefreshPlayingMusic( force: true );
}
/// <summary>Good-boy credits — pure chill bed.</summary>
public static void StartMusicChillEnding()
{
_detonationStingLeft = 0f;
_lastDeathStage = -1;
_lastNoChillBed = false;
StartMusic( ChillTankMusic, force: true, volumeMult: 1f );
}
/// <summary>Bad/mixed credits — metal bed (bad = death metal peak).</summary>
public static void StartMusicMetalEnding( bool peakDeathMetal )
{
_detonationStingLeft = 0f;
_lastDeathStage = -1;
_lastNoChillBed = true;
if ( peakDeathMetal )
StartMusic( DeathStage4, force: true, volumeMult: 1.2f );
else
StartMusic( MetalPlaylist[_metalIndex % MetalPlaylist.Length], force: true, volumeMult: 1.1f );
}
/// <summary>Called when chaos crosses chill/no-chill threshold or other systems request a recheck.</summary>
public static void OnMoodMaybeChanged( bool force = false )
{
if ( GameFlow.Screen != GameScreen.Playing )
return;
RefreshPlayingMusic( force: force );
}
/// <summary>Called from DeathFeel when sickness stage changes.</summary>
public static void OnDeathStageChanged( int stage )
{
if ( GameFlow.Screen != GameScreen.Playing )
return;
if ( stage == _lastDeathStage )
return;
_lastDeathStage = stage;
RefreshPlayingMusic( force: true );
}
/// <summary>
/// Pick the right bed for playing: dark moments beat chill.
/// </summary>
public static void RefreshPlayingMusic( bool force = false )
{
if ( GameFlow.Screen != GameScreen.Playing )
return;
var want = ResolvePlayingBed( out var volumeMult, out var deathStage, out var noChillBed, out var reason );
if ( want is null )
return;
var deathChanged = deathStage != _lastDeathStage;
var moodChanged = _lastNoChillBed != noChillBed;
var reasonChanged = reason != _lastAbsurdReason;
if ( !force
&& !deathChanged
&& !moodChanged
&& !reasonChanged
&& _music.IsValid()
&& _music.IsPlaying
&& _musicEvent == want )
{
ApplyMusicVolume( volumeMult );
return;
}
// Advance metal playlist when we freshly enter a no-chill bed from chill.
if ( noChillBed && _lastNoChillBed != true && deathStage <= 0 && !DetonationStingActive )
_metalIndex = (_metalIndex + 1) % MetalPlaylist.Length;
_lastDeathStage = deathStage;
_lastNoChillBed = noChillBed;
_lastAbsurdReason = reason ?? "";
StartMusic( want, force: true, volumeMult: volumeMult );
}
static string CurrentBedLabelFor( string evt )
{
if ( string.IsNullOrEmpty( evt ) )
return "—";
if ( evt.Contains( "moonberry" ) )
return "CHILL";
if ( evt.Contains( "gloom" ) )
return "UNEASE";
if ( evt.Contains( "moonbyte" ) )
return "DEATH METAL";
return "METAL";
}
static string ResolvePlayingBed( out float volumeMult, out int deathStage, out bool noChillBed, out string reason )
{
deathStage = DeathFeel.PeakStage;
volumeMult = 1f;
noChillBed = false;
reason = "";
// --- Detonation sting outranks everything for a few seconds ---
if ( _detonationStingLeft > 0f )
{
volumeMult = 1.28f;
noChillBed = true;
reason = "detonation";
return DeathStage4;
}
// --- Death ramp (from chill, this is the big gear shift) ---
if ( deathStage >= 4 )
{
volumeMult = 1.18f;
noChillBed = true;
reason = "dying";
return DeathStage4;
}
if ( deathStage >= 3 )
{
volumeMult = 1.12f;
noChillBed = true;
reason = "dying";
return DeathStage3;
}
if ( deathStage >= 2 )
{
volumeMult = 1.08f;
noChillBed = true;
reason = "dying";
return DeathStage2;
}
if ( deathStage >= 1 )
{
// Leave chill: dark unease first, not full blast.
volumeMult = 1.05f;
noChillBed = true;
reason = "dying";
return DeathStage1;
}
// --- Absurd / dark moments → metal no-chill beds ---
if ( EvaluateAbsurd( out reason ) )
{
noChillBed = true;
volumeMult = reason switch
{
"sharks" => 1.12f,
"meth" => 1.1f,
"battle" => 1.08f,
"m80-armed" => 1.08f,
"jacked-pack" => 1.1f,
"boom" => 1.15f,
_ => 1.05f
};
// Sharks / meth get the heavier end of the playlist feel.
if ( reason is "sharks" or "meth" or "boom" )
return MetalPlaylist[Math.Min( _metalIndex + 1, MetalPlaylist.Length - 1 ) % MetalPlaylist.Length];
return MetalPlaylist[_metalIndex % MetalPlaylist.Length];
}
// --- Calm tank → chill bed ---
noChillBed = false;
reason = "calm";
volumeMult = 1f;
return ChillTankMusic;
}
static void StartMusic( string soundEvent, bool force = false, float volumeMult = 1f )
{
if ( string.IsNullOrWhiteSpace( soundEvent ) )
return;
if ( !force && _music.IsValid() && _music.IsPlaying && _musicEvent == soundEvent )
{
ApplyMusicVolume( volumeMult );
return;
}
// Don't thrash the mixer every frame if a track failed to resolve.
if ( !force && _musicRetryCooldown > 0f && _musicEvent == soundEvent && !_music.IsValid() )
return;
StopMusic();
var vol = GameSettings.MusicVolume * volumeMult;
_music = PlayLocal( soundEvent, vol );
_musicEvent = soundEvent;
if ( !_music.IsValid() )
{
_musicRetryCooldown = 2.5f;
if ( _lastMissingMusic != soundEvent )
{
_lastMissingMusic = soundEvent;
Log.Warning( $"[NO-CHILLquarium] Music missing: {soundEvent} (recompile Assets/sounds/music if needed)" );
}
return;
}
_musicRetryCooldown = 0f;
_lastMissingMusic = null;
}
/// <summary>
/// Play a project .sound event as local 2D audio.
/// Paths are under Assets/ (e.g. sounds/music/moonberry).
/// Tries several path forms; failures are quiet (no resource-spam).
/// </summary>
static SoundHandle PlayLocal( string path, float volume )
{
if ( string.IsNullOrWhiteSpace( path ) )
return default;
var clean = path.Trim().Replace( '\\', '/' );
if ( clean.EndsWith( ".sound", StringComparison.OrdinalIgnoreCase ) )
clean = clean[..^6];
if ( clean.EndsWith( ".vsnd", StringComparison.OrdinalIgnoreCase ) )
clean = clean[..^5];
// Path forms S&box commonly accepts for SoundEvents.
var candidates = new[]
{
clean + ".sound",
clean,
"sounds/" + clean.TrimStart( '/' ).Replace( "sounds/", "" ) + ".sound",
clean + ".vsnd",
};
foreach ( var candidate in candidates )
{
try
{
if ( ResourceLibrary.Get<SoundEvent>( candidate ) is SoundEvent evt )
{
var fromEvt = Sound.Play( evt, Vector3.Zero );
if ( fromEvt.IsValid() )
{
fromEvt.ListenLocal = true;
fromEvt.SpacialBlend = 0f;
fromEvt.Volume = volume;
return fromEvt;
}
}
}
catch
{
// next form
}
try
{
var h = Sound.Play( candidate, Vector3.Zero );
if ( !h.IsValid() )
continue;
h.ListenLocal = true;
h.SpacialBlend = 0f;
h.Volume = volume;
return h;
}
catch
{
// next form
}
}
return default;
}
public static void StopMusic()
{
if ( _music.IsValid() )
_music.Stop();
_music = default;
// Keep _musicEvent so retry cooldown can match the failed track.
}
public static void ApplyMusicVolume()
{
ResolvePlayingBed( out var mult, out _, out _, out _ );
ApplyMusicVolume( mult );
}
static void ApplyMusicVolume( float mult )
{
if ( !_music.IsValid() )
return;
_music.Volume = GameSettings.MusicVolume * mult;
}
/// <summary>
/// Keep bed looping; re-resolve every tick so calm ↔ absurd swaps cleanly.
/// </summary>
public static void TickMusic()
{
if ( _musicRetryCooldown > 0f )
_musicRetryCooldown = MathF.Max( 0f, _musicRetryCooldown - Time.Delta );
if ( GameFlow.Screen is GameScreen.MainMenu or GameScreen.Settings or GameScreen.Achievements )
{
_detonationStingLeft = 0f;
if ( !_music.IsValid() || !_music.IsPlaying || _musicEvent != MenuMusic )
StartMusic( MenuMusic, force: _musicEvent != MenuMusic );
return;
}
// Ending screens own their own bed (chill vs metal) — just keep it looping.
if ( GameFlow.Screen == GameScreen.Ending )
{
if ( !_music.IsValid() || !_music.IsPlaying )
{
if ( EndingSystem.Kind == EndingKind.GoodBoy )
StartMusicChillEnding();
else
StartMusicMetalEnding( EndingSystem.Kind == EndingKind.BadBoy );
}
else
{
ApplyMusicVolume( EndingSystem.Kind == EndingKind.BadBoy ? 1.15f : 1f );
}
return;
}
if ( GameFlow.Screen != GameScreen.Playing )
return;
// Count down detonation sting, then hand bus back.
if ( _detonationStingLeft > 0f )
{
_detonationStingLeft -= Time.Delta;
if ( _detonationStingLeft <= 0f )
{
_detonationStingLeft = 0f;
RefreshPlayingMusic( force: true );
}
}
var want = ResolvePlayingBed( out var mult, out var deathStage, out var noChillBed, out var reason );
// Continuous volume ride as death progress / sting / sharks run.
if ( _music.IsValid() && _music.IsPlaying )
{
var progressBoost = 1f;
if ( deathStage > 0 )
progressBoost += DeathFeel.PeakProgress * 0.12f;
if ( DetonationStingActive )
progressBoost += 0.08f;
if ( SharkSystem.HasSharks )
progressBoost += 0.06f;
ApplyMusicVolume( mult * progressBoost );
}
// Bed mismatch (calm ↔ absurd) or track ended — switch immediately.
if ( _music.IsValid() && _music.IsPlaying && _musicEvent == want
&& _lastNoChillBed == noChillBed
&& _lastAbsurdReason == (reason ?? "") )
return;
RefreshPlayingMusic( force: true );
}
}