GameAudio.cs
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 Inject = "sounds/ui/inject";
public const string Achievement = "sounds/ui/achievement";
/// <summary>Golden stamp — short "favorite" ding.</summary>
public const string Favorite = "sounds/ui/favorite";
/// <summary>Egg hatch chirp.</summary>
public const string Hatch = "sounds/ui/hatch";
/// <summary>Abomination birth growl (trimmed).</summary>
public const string Growl = "sounds/ui/growl";
/// <summary>Salt tank / ocean sting.</summary>
public const string Dolphin = "sounds/ui/dolphin";
/// <summary>Blue whale paperwork sting.</summary>
public const string Whale = "sounds/ui/whale";
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
/// <summary>All no-chill beds — rotate so monsters/meth don't lock one track forever.</summary>
static readonly string[] MetalPlaylist =
{
"sounds/music/bitten_crown",
"sounds/music/game_boy_hex",
"sounds/music/pixel_horns",
"sounds/music/pixel_hex_rite",
"sounds/music/gloom_cartridge",
"sounds/music/moonbyte_blasphemy"
};
/// <summary>Seconds on one metal bed before auto-advancing (while still no-chill).</summary>
const float MetalRotateSeconds = 110f;
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;
static float _metalBedTimer;
static string _activeMetalPick;
static readonly Random _musicRng = new();
/// <summary>Short music volume kick on big moments (sell, boom, stage, fight).</summary>
static float _pulseBoost;
static float _pulseTimer;
public static bool DetonationStingActive => _detonationStingLeft > 0f;
/// <summary>Kick music intensity for a beat so the bed reacts to gameplay, not only mood swaps.</summary>
public static void PulseMusic( float amount = 0.14f, float seconds = 0.9f )
{
amount = Math.Clamp( amount, 0.04f, 0.35f );
seconds = Math.Clamp( seconds, 0.25f, 2.5f );
_pulseBoost = Math.Max( _pulseBoost, amount );
_pulseTimer = Math.Max( _pulseTimer, seconds );
}
/// <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 ( BattleSystem.IsOpen )
{
reason = "arena";
return true;
}
if ( LotRunSystem.IsOpen && !LotRunSystem.IsFlushing )
{
reason = "adventure";
return true;
}
if ( MethRush.Open )
{
reason = "meth-rush";
return true;
}
if ( TrainingSystem.InjectOpen )
{
reason = "inject";
return true;
}
if ( ExplodeSystem.ModeActive && ExplodeSystem.SelectedCount > 0 )
{
reason = "m80-armed";
return true;
}
// Fusion abominations force NO-CHILL bed (any unlocked tank).
if ( TankSim.HasAnyMonster )
{
reason = "monsters";
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>
/// Pack-open ticks. Step 0 deal · 1 unfold · 2 fish · 3 rarity stamp.
/// Pitch climbs so the unfold feels like a slot stopping.
/// </summary>
public static void PlayBuyTick( int step, FishRarity rarity )
{
var vol = Math.Clamp( GameSettings.SfxVolume, 0f, 1f );
step = Math.Clamp( step, 0, 3 );
var pitch = 0.92f + step * 0.08f;
if ( step == 0 )
{
PlayPitched( Pop, vol * 0.88f, pitch );
return;
}
if ( step == 1 )
{
PlayPitched( Pop, vol * 0.72f, pitch + 0.04f );
return;
}
if ( step == 2 )
{
PlayPitched( Trade, vol * 0.95f, 1.02f );
return;
}
// Stamp — commons thud, jackpots explode.
if ( rarity >= FishRarity.Mythical )
{
PlayBigWin( bark: true );
PlayPitched( Favorite, vol * 0.85f, 1.12f );
return;
}
if ( rarity >= FishRarity.Legendary )
{
PlayBigWin( bark: false );
PlayPitched( Favorite, vol * 0.95f, 1.05f );
return;
}
if ( rarity >= FishRarity.PaintedRare )
{
PlayLayered( Achievement, Confirm, 0.55f );
PulseMusic( 0.14f, 0.7f );
return;
}
if ( rarity >= FishRarity.Rare )
{
PlayLayered( Upgrade, Confirm, 0.5f );
PulseMusic( 0.1f, 0.55f );
return;
}
if ( rarity >= FishRarity.Uncommon )
{
PlayPitched( Confirm, vol, 1.1f );
return;
}
// Common: quiet pop so the next click is the thought.
PlayPitched( Pop, vol * 0.7f, 0.88f );
}
static void PlayPitched( string soundEvent, float volume, float pitch )
{
_ = PlayLocal( soundEvent, volume, pitch );
}
/// <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>
/// Layer two SFX so big moments land even if one event fails to load.
/// Primary at full SFX vol; secondary quieter and slightly delayed feel via volume only.
/// </summary>
public static void PlayLayered( string primary, string secondary, float secondaryMul = 0.55f )
{
var vol = Math.Clamp( GameSettings.SfxVolume, 0f, 1f );
if ( !string.IsNullOrWhiteSpace( primary ) )
_ = PlayLocal( primary, vol );
if ( !string.IsNullOrWhiteSpace( secondary ) && secondaryMul > 0.01f )
_ = PlayLocal( secondary, vol * Math.Clamp( secondaryMul, 0.1f, 1f ) );
}
/// <summary>Sell / stage / fight win — coin + upgrade so the wallet *and* the room react.</summary>
public static void PlayBigCash()
{
PlayLayered( Coin, Upgrade, 0.5f );
PulseMusic( 0.12f, 0.7f );
}
/// <summary>Combine birth / championship / shark kill — bark-tier without always barking.</summary>
public static void PlayBigWin( bool bark = false )
{
if ( bark )
{
PlayDogeBark();
_ = PlayLocal( Upgrade, Math.Clamp( GameSettings.SfxVolume * 0.7f, 0f, 1f ) );
PulseMusic( 0.22f, 1.2f );
return;
}
PlayLayered( Confirm, Coin, 0.65f );
PulseMusic( 0.16f, 0.9f );
}
/// <summary>Egg hatch — chirp, hotter if the guppy is spicy.</summary>
public static void PlayHatch( FishRarity rarity )
{
var vol = Math.Clamp( GameSettings.SfxVolume, 0f, 1f );
PlayPitched( Hatch, vol, rarity >= FishRarity.Rare ? 1.12f : 1f );
if ( rarity >= FishRarity.Legendary )
PlayPitched( Favorite, vol * 0.7f, 1.04f );
}
/// <summary>Combine birth — growl over the upgrade sting.</summary>
public static void PlayFusion()
{
PlayLayered( Growl, Upgrade, 0.55f );
PulseMusic( 0.18f, 0.9f );
}
/// <summary>Salt tank online — short dolphin.</summary>
public static void PlaySaltUnlock()
{
PlayLayered( Dolphin, Upgrade, 0.5f );
PulseMusic( 0.12f, 0.7f );
}
/// <summary>Blue whale bought.</summary>
public static void PlayWhale()
{
PlayLayered( Whale, Coin, 0.4f );
PulseMusic( 0.16f, 1.0f );
}
/// <summary>Shark enters — alert + notice so it never feels silent.</summary>
public static void PlaySharkAlert()
{
PlayLayered( AlertWarning, Notice, 0.45f );
}
/// <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 ? Upgrade : Close, vol );
}
public static void StartMenuMusic()
{
_lastDeathStage = -1;
_lastNoChillBed = null;
_detonationStingLeft = 0f;
_lastAbsurdReason = "";
_metalBedTimer = 0f;
_activeMetalPick = null;
StartMusic( MenuMusic, force: true );
}
public static void StartTankMusic()
{
_lastNoChillBed = null;
_lastDeathStage = -1;
_detonationStingLeft = 0f;
_lastAbsurdReason = "";
_metalBedTimer = 0f;
_activeMetalPick = null;
RefreshPlayingMusic( force: true );
}
/// <summary>
/// Next metal bed, never the same as the one just playing when possible.
/// </summary>
static string NextMetalTrack()
{
if ( MetalPlaylist.Length == 0 )
return MenuMusic;
// Prefer a random different track so long monster sessions don't cycle 1-2-3-1…
if ( MetalPlaylist.Length > 1 )
{
var avoid = _activeMetalPick ?? _musicEvent;
for ( var attempt = 0; attempt < 8; attempt++ )
{
var i = _musicRng.Next( MetalPlaylist.Length );
var pick = MetalPlaylist[i];
if ( !string.Equals( pick, avoid, StringComparison.OrdinalIgnoreCase ) )
{
_metalIndex = i;
_activeMetalPick = pick;
_metalBedTimer = 0f;
return pick;
}
}
}
_metalIndex = (_metalIndex + 1) % MetalPlaylist.Length;
_activeMetalPick = MetalPlaylist[_metalIndex];
_metalBedTimer = 0f;
return _activeMetalPick;
}
static string CurrentMetalTrack()
{
if ( !string.IsNullOrEmpty( _activeMetalPick ) )
return _activeMetalPick;
_activeMetalPick = MetalPlaylist[_metalIndex % MetalPlaylist.Length];
return _activeMetalPick;
}
/// <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.
/// Metal beds rotate on enter / reason change / track end / timed swap.
/// </summary>
/// <param name="force">Re-resolve mood even if already playing.</param>
/// <param name="rotateMetal">Advance to a different metal track while staying no-chill.</param>
public static void RefreshPlayingMusic( bool force = false, bool rotateMetal = 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;
var enteringMetal = noChillBed && deathStage <= 0 && !DetonationStingActive
&& (moodChanged || _lastNoChillBed != true);
var metalPool = noChillBed && deathStage <= 0 && !DetonationStingActive
&& reason is not "dying" and not "detonation";
// Pick / rotate metal playlist tracks (not the fixed death-ramp beds).
if ( metalPool )
{
if ( rotateMetal || enteringMetal || reasonChanged || string.IsNullOrEmpty( _activeMetalPick ) )
want = NextMetalTrack();
else
want = CurrentMetalTrack();
}
else if ( !noChillBed )
{
_activeMetalPick = null;
_metalBedTimer = 0f;
}
if ( !force
&& !deathChanged
&& !moodChanged
&& !reasonChanged
&& !rotateMetal
&& _music.IsValid()
&& _music.IsPlaying
&& _musicEvent == want )
{
ApplyMusicVolume( volumeMult );
return;
}
_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 (track chosen in RefreshPlayingMusic) ---
if ( EvaluateAbsurd( out reason ) )
{
noChillBed = true;
volumeMult = reason switch
{
"sharks" => 1.16f,
"monsters" => 1.14f,
"meth" => 1.12f,
"meth-rush" => 1.14f,
"battle" => 1.12f,
"arena" => 1.08f,
"adventure" => 1.1f,
"m80-armed" => 1.1f,
"jacked-pack" => 1.12f,
"boom" => 1.18f,
"inject" => 1.08f,
_ => 1.06f
};
// Placeholder — RefreshPlayingMusic swaps via NextMetalTrack / CurrentMetalTrack.
return CurrentMetalTrack();
}
// --- 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, float pitch = 1f )
{
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;
if ( pitch > 0.01f && MathF.Abs( pitch - 1f ) > 0.01f )
fromEvt.Pitch = pitch;
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;
if ( pitch > 0.01f && MathF.Abs( pitch - 1f ) > 0.01f )
h.Pitch = pitch;
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 )
{
_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 );
// While stuck in no-chill (e.g. monsters), rotate metal beds on a timer.
if ( noChillBed && deathStage <= 0 && !DetonationStingActive
&& reason is not "dying" and not "detonation" )
{
_metalBedTimer += Time.Delta;
if ( _metalBedTimer >= MetalRotateSeconds )
{
_metalBedTimer = 0f;
RefreshPlayingMusic( force: true, rotateMetal: true );
return;
}
}
else
{
_metalBedTimer = 0f;
}
// Pulse decay — big gameplay moments kick the bed.
if ( _pulseTimer > 0f )
{
_pulseTimer = MathF.Max( 0f, _pulseTimer - Time.Delta );
if ( _pulseTimer <= 0f )
_pulseBoost = 0f;
else
_pulseBoost *= 0.985f; // soft falloff while timer runs
}
// Continuous volume ride as death / sting / sharks / chaos / pulse.
if ( _music.IsValid() && _music.IsPlaying )
{
var progressBoost = 1f;
if ( deathStage > 0 )
progressBoost += DeathFeel.PeakProgress * 0.16f;
if ( DetonationStingActive )
progressBoost += 0.1f;
if ( SharkSystem.HasSharks )
progressBoost += 0.08f + Math.Min( 0.06f, SharkSystem.SharkCount * 0.02f );
if ( TankSim.HasAnyMonster )
progressBoost += 0.06f;
if ( Chaos.Value > 40f )
progressBoost += Math.Clamp( (Chaos.Value - 40f) / 200f, 0f, 0.1f );
if ( BattleSystem.Phase == BattlePhase.Fighting )
progressBoost += 0.07f;
if ( _pulseBoost > 0.01f )
progressBoost += _pulseBoost;
// Thriving calm porch: slight chill bed lift so silence never feels empty.
if ( !noChillBed && TankSim.FishCount >= 3 )
progressBoost += 0.03f;
ApplyMusicVolume( mult * progressBoost );
}
// Track ended while still no-chill → next metal bed (not the same one).
if ( noChillBed && deathStage <= 0 && !DetonationStingActive
&& reason is not "dying" and not "detonation"
&& (!_music.IsValid() || !_music.IsPlaying) )
{
RefreshPlayingMusic( force: true, rotateMetal: true );
return;
}
// Bed mismatch (calm ↔ absurd) or wrong track — switch immediately.
if ( _music.IsValid() && _music.IsPlaying && _musicEvent == want
&& _lastNoChillBed == noChillBed
&& _lastAbsurdReason == (reason ?? "") )
return;
RefreshPlayingMusic( force: true );
}
}