Music manager partial for the game's audio system. Defines music data types (patterns, clips, element defs), builds a clip registry from files, maintains active layers, advances a cycle-driven conductor that adds/removes layers based on intensity, fires clips each cycle, handles menu vs game contexts, and updates volumes and tails.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
using Sandbox.Audio;
/// <summary>
/// Defines a rhythmic on/off pattern for a music element across cycles.
/// E.g. On=3, Off=1 means play for 3 cycles then silent for 1, repeating.
/// </summary>
public class MusicPattern
{
public int On = 1;
public int Off = 0;
public float Weight = 1f;
}
/// <summary>
/// A single audio clip within a music element, with its own intensity eligibility range.
/// All clips in an element with overlapping ranges may be selected randomly at boundaries.
/// </summary>
public class MusicClip
{
/// <summary>Filename within the element's folder (e.g. "drums-main.wav"). Full path is populated by BuildClipRegistry.</summary>
public string File;
/// <summary>Minimum game intensity [0-1] at which this clip is eligible for selection.</summary>
public float IntensityMin = 0f;
/// <summary>Maximum game intensity [0-1] at which this clip is eligible. Overlapping ranges with other clips allow variation at intensity boundaries.</summary>
public float IntensityMax = 1f;
/// <summary>If true, this clip plays only once per element activation. On the next cycle the system picks a different in-range clip; if none exist, the element drains.</summary>
public bool PlayOnce = false;
/// <summary>If true, this clip is always chosen first on a fresh element activation (before any clips have played). Useful for lead-in clips that must precede a main loop.</summary>
public bool PlayFirst = false;
/// <summary>Full virtual path. Set automatically by BuildClipRegistry — do not assign manually.</summary>
public string Path;
}
/// <summary>
/// Template definition for a music element. Multiple defs can share the same LayerName,
/// meaning only one of them can be active at a time (e.g. bass_light and bass_heavy both on "bass").
/// The conductor picks eligible elements based on the current intensity value.
/// </summary>
public class MusicElementDef
{
/// <summary>Unique identifier and folder name for clip discovery. Clips are scanned from sounds/music/{Motif}/{FolderName}/.</summary>
public string FolderName;
/// <summary>Shared layer slot. Only one element per LayerName can play at a time.</summary>
public string LayerName;
/// <summary>Which audio Mixer to route to (found via Mixer.FindMixerByName).</summary>
public string MixerName = "Music";
/// <summary>Volume multiplier 0-1, applied on top of MUSIC_VOLUME.</summary>
public float BaseVolume = 1f;
/// <summary>Lower value = filled earlier in the run. Layers are sorted by this when the conductor adds elements.</summary>
public int Priority;
/// <summary>Core elements (e.g. pad) have a much higher chance of staying each conductor tick.</summary>
public bool IsCore;
/// <summary>Minimum intensity (0-1) at which this element becomes eligible.</summary>
public float IntensityMin;
/// <summary>Maximum intensity (0-1) at which this element is eligible. Overlapping ranges with other defs on the same layer create organic crossfade zones.</summary>
public float IntensityMax = 1f;
/// <summary>Motif group: "shared" is always available, others (e.g. "motif_circuit") are only included when selected for the run.</summary>
public string Motif = "shared";
/// <summary>
/// Clips for this element. Leave empty for auto-discovery (all files in the folder get the element's intensity range).
/// Populate manually for tiered elements where each clip has its own intensity range (e.g. drums variants).
/// Paths are auto-populated by BuildClipRegistry — only set File, IntensityMin, IntensityMax.
/// </summary>
public List<MusicClip> Clips = new();
/// <summary>Weighted list of on/off patterns. Null or empty = always on. One is chosen randomly when activated.</summary>
public List<MusicPattern> Patterns;
}
/// <summary>
/// Runtime state for a currently-playing music layer.
/// </summary>
public class ActiveMusicLayer
{
/// <summary>Name of the MusicElementDef driving this layer.</summary>
public string ElementName;
/// <summary>Which layer slot this occupies (matches MusicElementDef.LayerName).</summary>
public string LayerName;
/// <summary>The specific audio file being played (re-fired each cycle).</summary>
public string ClipPath;
/// <summary>The MusicPlayer instance for the current cycle's clip.</summary>
public MusicPlayer Player;
/// <summary>Number of full cycles this element has been active.</summary>
public int CyclesSinceAdded;
/// <summary>Randomized minimum cycles before the conductor can remove this element.</summary>
public int MinStayCycles;
/// <summary>Cycles to play before going silent.</summary>
public int PatternOn = 1;
/// <summary>Cycles to stay silent before playing again. 0 = always on.</summary>
public int PatternOff;
/// <summary>Current position within the pattern (0 to PatternOn+PatternOff-1).</summary>
public int PatternCycle;
/// <summary>True when in the silent phase of the pattern.</summary>
public bool IsPatternMuted => PatternOff > 0 && PatternCycle >= PatternOn;
/// <summary>True when the conductor has decided to remove this layer. No new clips will fire; player moves to tails at next cycle boundary.</summary>
public bool Draining;
/// <summary>Paths of play-once clips that have already fired for this activation. Cleared on re-activation.</summary>
public HashSet<string> PlayedOnceClips = new();
}
/// <summary>
/// Which song the conductor is currently playing. The same conductor machinery drives both;
/// only the element pool and intensity source differ between contexts.
/// </summary>
public enum MusicContext { None, Game, Menu }
public partial class Manager
{
private MusicContext _musicContext = MusicContext.None;
private Dictionary<string, ActiveMusicLayer> _activeLayers = new();
private List<(MusicPlayer Player, string ClipPath)> _tailPlayers = new();
private List<MusicElementDef> _runElementPool = new();
private List<string> _selectedMotifs = new();
private RealTimeSince _conductorTickTimer;
private bool _showMusicDebug;
private bool _conductorTickedDuringChoosing;
private RealTimeSince _choosingSessionTimer;
private bool _wasChoosingLastFrame;
public const float MUSIC_VOLUME = 0.6f;
/// <summary>Fixed intensity used while in the main menu (lobby). Doesn't affect anything currently.</summary>
private const float MENU_INTENSITY = 0.3f;
/// <summary>How long the menu song fades in (seconds) when arriving at the lobby.</summary>
private const float MENU_FADE_IN_DURATION = 0.5f;
private RealTimeSince _menuMusicStarted;
private const float CYCLE_DURATION = 22.588f; // 8 bars at 85 BPM (= 16 bars at 170 BPM)
private const float CORE_STAY_CHANCE = 0.85f;
private const float NONCORE_STAY_CHANCE = 0.5f;
private const float ADD_CHANCE_BASE = 0.65f;
private const float ADD_CHANCE_CORE = 0.85f;
private const int MIN_STAY_CYCLES = 1;
private const int MAX_STAY_CYCLES = 3;
private const float OUT_OF_RANGE_REMOVE_CHANCE = 0.85f;
// --- Public API (called from Manager.cs) ---
void RestartMusic()
{
InitMusic();
}
void StopMusic()
{
StopAllLayers();
}
void HandleMusic()
{
//if ( Input.Keyboard.Pressed( "M" ) && Game.IsEditor )
// _showMusicDebug = !_showMusicDebug;
// Keep the menu song in sync with lobby state. The same conductor drives both songs.
if ( GameState == GameState.Lobby )
{
if ( _musicContext != MusicContext.Menu )
StartMenuMusic();
}
else if ( _musicContext == MusicContext.Menu )
{
// Left the lobby — tear down the menu song. Game music (if any) is started via RestartMusic.
StopAllLayers();
_musicContext = MusicContext.None;
}
// Detect the start of a new choosing session and reset the per-session tick flag
if ( IsPausedForChoosing && !_wasChoosingLastFrame )
{
_conductorTickedDuringChoosing = false;
_choosingSessionTimer = 0f;
}
_wasChoosingLastFrame = IsPausedForChoosing;
UpdateLayerVolumes();
CleanupTailPlayers();
if ( _conductorTickTimer >= CYCLE_DURATION )
{
float overshoot = (float)_conductorTickTimer - CYCLE_DURATION;
_conductorTickTimer = overshoot;
// Advance patterns at every cycle boundary (even during pause)
AdvancePatterns();
if ( _musicContext == MusicContext.Menu )
{
// Menu music has no gameplay pause/choosing concept — tick every cycle.
ConductorTick();
}
else if ( !IsPaused && !IsGameOver && GameState == GameState.Playing )
{
if ( IsPausedForChoosing )
{
// Allow at most one conductor tick per choosing session, only if it started recently
if ( !_conductorTickedDuringChoosing && (float)_choosingSessionTimer < 5f )
{
ConductorTick();
_conductorTickedDuringChoosing = true;
}
}
else
{
ConductorTick();
}
}
// Fire new clips for all active layers; orphaned players play out their tails naturally
FireAllClips( overshoot );
}
if ( _showMusicDebug )
DrawMusicDebug();
}
void DrawMusicDebug()
{
float intensity = CalculateIntensity();
float cyclePos = (float)_conductorTickTimer;
float minutes = ElapsedTime / 60f;
var str = "--- MUSIC DEBUG ---\n";
str += $"Intensity: {intensity.ToString("0.###")} Minutes: {minutes.ToString("0.#")} Paused: {IsPaused}\n";
str += $"Motifs: [{string.Join( ", ", _selectedMotifs )}] Pool: {_runElementPool.Count} defs\n";
str += $"Cycle: {cyclePos:F3}s / {CYCLE_DURATION:F3}s";
if ( IsPaused ) str += " (PAUSED)";
str += $" Tails: {_tailPlayers.Count}";
str += $"\nActive layers: {_activeLayers.Count}\n\n";
var sortedKeys = _activeLayers.Keys
.OrderBy( k => {
var layer = _activeLayers[k];
var def = GetElementDef( layer.ElementName );
return def?.Priority ?? 99;
} )
.ToList();
foreach ( var key in sortedKeys )
{
var layer = _activeLayers[key];
var def = GetElementDef( layer.ElementName );
int priority = def?.Priority ?? -1;
bool isCore = def?.IsCore ?? false;
int stayCyclesLeft = Math.Max( 0, layer.MinStayCycles - layer.CyclesSinceAdded );
string intensityRange = def != null ? $"{def.IntensityMin:F2}-{def.IntensityMax:F2}" : "?";
bool inRange = def != null && intensity >= def.IntensityMin && intensity <= def.IntensityMax;
string clipFile = layer.ClipPath?.Split( '/' ).Last() ?? "?";
float clipTime = layer.Player?.PlaybackTime ?? 0f;
float clipDuration = layer.Player?.Duration ?? 0f;
float loopPct = clipTime / CYCLE_DURATION * 100f;
string drainTag = layer.Draining ? " [DRAINING]" : "";
var activeClip = def?.Clips.FirstOrDefault( c => c.Path == layer.ClipPath );
string clipRange = activeClip != null ? $"{activeClip.IntensityMin:F2}-{activeClip.IntensityMax:F2}" : "?";
string clipFlags = activeClip != null
? string.Join( " ", new[]
{
activeClip.PlayOnce ? "ONCE" : null,
activeClip.PlayFirst ? "FIRST" : null,
layer.PlayedOnceClips.Count > 0 ? $"(used:{layer.PlayedOnceClips.Count})" : null,
}.Where( s => s != null ) )
: "";
str += $"[{key}]{drainTag} {layer.ElementName} ({clipFile})\n";
str += $" loop: {clipTime:F3}s / {CYCLE_DURATION:F3}s ({loopPct:F0}%) | file: {clipDuration:F3}s\n";
str += $" clip range: {clipRange} {clipFlags}\n";
str += $" pri: {priority}{(isCore ? " CORE" : "")} elem range: {intensityRange} {(inRange ? "OK" : "OUT")} stay: {stayCyclesLeft} cycle(s) age: {layer.CyclesSinceAdded} cycle(s)\n";
if ( layer.PatternOff > 0 )
{
int total = layer.PatternOn + layer.PatternOff;
string blocks = "";
for ( int i = 0; i < total; i++ )
blocks += i == layer.PatternCycle ? (i < layer.PatternOn ? "[#]" : "[_]") : (i < layer.PatternOn ? " # " : " _ ");
str += $" pattern:{blocks} (play {layer.PatternOn}, skip {layer.PatternOff})\n";
}
str += "\n";
}
// Show eligible but inactive layers
var activeLayerNames = _activeLayers
.Where( p => !p.Value.Draining )
.Select( p => p.Value.LayerName )
.ToHashSet();
var inactiveLayers = _runElementPool
.Select( d => d.LayerName )
.Distinct()
.Where( l => !activeLayerNames.Contains( l ) )
.ToList();
if ( inactiveLayers.Count > 0 )
{
str += "\nInactive layers:\n";
foreach ( var layerName in inactiveLayers )
{
var allOnLayer = _runElementPool.Where( d => d.LayerName == layerName ).ToList();
var eligible = allOnLayer.Where( d => intensity >= d.IntensityMin && intensity <= d.IntensityMax ).ToList();
var ineligible = allOnLayer.Where( d => intensity < d.IntensityMin || intensity > d.IntensityMax ).ToList();
if ( eligible.Count > 0 )
{
str += $" [{layerName}] {eligible.Count} eligible: ";
str += string.Join( ", ", eligible.Select( d => $"{d.FolderName} ({d.IntensityMin:F2}-{d.IntensityMax:F2})" ) );
str += "\n";
}
if ( ineligible.Count > 0 )
{
foreach ( var d in ineligible )
{
string reason = intensity < d.IntensityMin
? $"need intensity >= {d.IntensityMin:F2}"
: $"need intensity <= {d.IntensityMax:F2}";
str += $" [{layerName}] {d.FolderName} NOT ELIGIBLE ({reason}, current: {intensity:F3})\n";
}
}
}
}
if ( _tailPlayers.Count > 0 )
{
str += $"\nTails ({_tailPlayers.Count}):\n";
foreach ( var t in _tailPlayers )
{
string clipFile = t.ClipPath?.Split( '/' ).Last() ?? "?";
float tTime = t.Player?.PlaybackTime ?? 0f;
float tDur = t.Player?.Duration ?? 0f;
float tPct = tDur > 0f ? tTime / tDur * 100f : 0f;
str += $" {clipFile} {tTime:F3}s / {tDur:F3}s ({tPct:F0}%)\n";
}
}
Gizmo.Draw.Color = Color.White.WithAlpha( 0.75f );
Gizmo.Draw.ScreenText( str, new Vector2( 150, 20 ), size: 20f );
}
void SetMusicMinute( int minute )
{
// Conductor is now cycle-driven — no action on minute change
}
// --- Setup ---
void InitMusic()
{
StopAllLayers();
_musicContext = MusicContext.Game;
var allDefs = BuildClipRegistry( BuildElementDefs() );
var motifNames = allDefs
.Where( d => d.Motif != "shared" )
.Select( d => d.Motif )
.Distinct()
.ToList();
_selectedMotifs = new List<string>();
if ( motifNames.Count > 0 )
{
_selectedMotifs.Add( motifNames[Game.Random.Next( motifNames.Count )] );
if ( motifNames.Count > 1 && Game.Random.Float( 0f, 1f ) < 0.5f )
{
var remaining = motifNames.Where( m => !_selectedMotifs.Contains( m ) ).ToList();
if ( remaining.Count > 0 )
_selectedMotifs.Add( remaining[Game.Random.Next( remaining.Count )] );
}
}
_runElementPool = FilterPoolForMotifs( allDefs, _selectedMotifs );
// Start with exactly one element: the highest-priority eligible one
var firstElement = _runElementPool
.Where( d => 0f >= d.IntensityMin && 0f <= d.IntensityMax )
.OrderBy( d => d.Priority )
.FirstOrDefault();
if ( firstElement != null )
ActivateElement( firstElement, firstElement.LayerName );
_conductorTickTimer = 0f;
//Log.Info( $"Music: InitMusic with motifs [{string.Join( ", ", _selectedMotifs )}], pool size {_runElementPool.Count}" );
}
/// <summary>
/// Starts the main-menu (lobby) song. Reuses the same conductor as gameplay, but with a
/// separate element pool (BuildMenuElementDefs) and a fixed intensity (MENU_INTENSITY).
/// </summary>
void StartMenuMusic()
{
StopAllLayers();
_musicContext = MusicContext.Menu;
_menuMusicStarted = 0f;
_selectedMotifs = new List<string>();
_runElementPool = BuildClipRegistry( BuildMenuElementDefs() );
// Start with exactly one element: the highest-priority element eligible at the menu intensity
var firstElement = _runElementPool
.Where( d => MENU_INTENSITY >= d.IntensityMin && MENU_INTENSITY <= d.IntensityMax )
.OrderBy( d => d.Priority )
.FirstOrDefault();
if ( firstElement != null )
ActivateElement( firstElement, firstElement.LayerName );
_conductorTickTimer = 0f;
}
void StopAllLayers()
{
foreach ( var pair in _activeLayers )
pair.Value.Player?.Stop();
_activeLayers.Clear();
foreach ( var t in _tailPlayers )
t.Player?.Stop();
_tailPlayers.Clear();
}
// --- Fire / Sync ---
/// <summary>
/// At each cycle boundary, fires a new clip for every active non-draining layer.
/// Old players are moved to _tailPlayers and play out naturally (reverb tails, etc.).
/// </summary>
void FireAllClips( float overshoot )
{
var toRemove = new List<string>();
foreach ( var pair in _activeLayers )
{
var layer = pair.Value;
if ( layer.Draining )
{
// Move current player to tails so its reverb tail can finish, then drop this layer
if ( layer.Player != null )
_tailPlayers.Add( (layer.Player, layer.ClipPath) );
toRemove.Add( pair.Key );
//Log.Info( $"Music: FireAllClips [{layer.ElementName}] drained, {_tailPlayers.Count} tail(s)" );
continue;
}
if ( layer.IsPatternMuted )
{
// Silent cycle — move current player to tails (or stop it if already silent)
if ( layer.Player != null )
{
_tailPlayers.Add( (layer.Player, layer.ClipPath) );
layer.Player = null;
}
//Log.Info( $"Music: FireAllClips [{layer.ElementName}] pattern muted, no new clip" );
continue;
}
// If the player was just created this cycle boundary (by ActivateElement), keep it — don't cycle into tails
if ( layer.Player != null && layer.Player.PlaybackTime < 0.5f )
{
if ( overshoot > 0f ) layer.Player.Seek( overshoot );
//Log.Info( $"Music: FireAllClips [{layer.ElementName}] keeping just-activated player (t={layer.Player.PlaybackTime:F3}s)" );
continue;
}
var def = GetElementDef( layer.ElementName );
// If the outgoing clip was play-once, record it so we never pick it again this activation
if ( def != null && layer.Player != null )
{
var outgoingClip = def.Clips.FirstOrDefault( c => c.Path == layer.ClipPath );
if ( outgoingClip != null && outgoingClip.PlayOnce )
layer.PlayedOnceClips.Add( layer.ClipPath );
}
// Move current player to tails (it may have a reverb tail beyond the loop point)
if ( layer.Player != null )
_tailPlayers.Add( (layer.Player, layer.ClipPath) );
// Re-pick based on current intensity; exclude exhausted play-once clips.
// Returns null if all in-range clips are exhausted — element drains in that case.
string newClipPath = def != null
? PickClipForIntensity( def, CalculateIntensity(), layer.PlayedOnceClips )
: layer.ClipPath;
if ( newClipPath == null )
{
// No eligible clip remains (e.g. lead-in played once, nothing else in range)
toRemove.Add( pair.Key );
//Log.Info( $"Music: FireAllClips [{layer.ElementName}] draining — no eligible clip after play-once exhaustion" );
continue;
}
var newPlayer = MusicPlayer.Play( FileSystem.Mounted, newClipPath );
newPlayer.Repeat = false;
newPlayer.ListenLocal = true;
newPlayer.TargetMixer = def != null ? (Mixer.FindMixerByName( def.MixerName ) ?? MusicMixer) : MusicMixer;
newPlayer.Volume = (def?.BaseVolume ?? 1f) * GLOBAL_VOLUME * MUSIC_VOLUME;
if ( overshoot > 0f ) newPlayer.Seek( overshoot );
layer.ClipPath = newClipPath;
layer.Player = newPlayer;
//Log.Info( $"Music: FireAllClips [{layer.ElementName}] fired new clip, {_tailPlayers.Count} tail(s)" );
}
foreach ( var key in toRemove )
_activeLayers.Remove( key );
}
/// <summary>
/// Removes tail players that have finished playing naturally.
/// </summary>
void CleanupTailPlayers()
{
_tailPlayers.RemoveAll( t =>
{
if ( t.Player == null ) return true;
bool done = t.Player.Duration > 0f && t.Player.PlaybackTime >= t.Player.Duration - 0.05f;
if ( done ) t.Player.Stop();
return done;
} );
}
// --- Clip Registry ---
[ConCmd( "reload_music_registry" )]
public static void ReloadMusicRegistry()
{
if ( Instance == null )
return;
Instance._runElementPool = Instance.FilterPoolForMotifs( BuildClipRegistry( BuildElementDefs() ), Instance._selectedMotifs );
Log.Info( $"Music registry reloaded, pool size {Instance._runElementPool.Count}" );
}
List<MusicElementDef> FilterPoolForMotifs( List<MusicElementDef> allDefs, List<string> motifs )
{
return allDefs
.Where( d => d.Motif == "shared" || motifs.Contains( d.Motif ) )
.ToList();
}
static List<MusicElementDef> BuildElementDefs()
{
var defs = new List<MusicElementDef>();
// shared elements
defs.Add( new MusicElementDef
{
FolderName = "drums",
LayerName = "drums",
BaseVolume = 1.0f,
Priority = 0,
IsCore = true,
IntensityMin = 0.0f,
IntensityMax = 1.0f,
Motif = "shared",
Clips = new List<MusicClip>
{
new() { File = "sausage-survivors-music-drums-lead-in.ogg", IntensityMin = 0.00f, IntensityMax = 0.30f, PlayOnce = true, PlayFirst = true },
new() { File = "sausage-survivors-music-drums-main.ogg", IntensityMin = 0.00f, IntensityMax = 0.2f },
new() { File = "sausage-survivors-music-drums-double-time.ogg", IntensityMin = 0.2f, IntensityMax = 0.55f },
new() { File = "sausage-survivors-music-drums-4x4.ogg", IntensityMin = 0.7f, IntensityMax = 1.00f },
},
} );
defs.Add( new MusicElementDef
{
FolderName = "shakers",
LayerName = "shakers",
BaseVolume = 1.0f,
Priority = 2,
IsCore = false,
IntensityMin = 0.25f,
IntensityMax = 1.0f,
Motif = "shared",
Clips = new List<MusicClip>
{
//new() { File = "sausage-survivors-music-shakers-lead-in.ogg", IntensityMin = 0.00f, IntensityMax = 0.55f, PlayOnce = true },
new() { File = "sausage-survivors-music-shakers-double-time.ogg", IntensityMin = 0.25f, IntensityMax = 1.00f },
},
} );
defs.Add( new MusicElementDef
{
FolderName = "bassline",
LayerName = "bassline",
BaseVolume = 0.9f,
Priority = 2,
IsCore = true,
IntensityMin = 0.0f,
IntensityMax = 1.0f,
Motif = "shared",
Clips = new List<MusicClip>
{
new() { File = "sausage-survivors-music-basslline-acoustic.ogg", IntensityMin = 0.00f, IntensityMax = 0.20f },
new() { File = "sausage-survivors-music-basslline-main.ogg", IntensityMin = 0.10f, IntensityMax = 0.55f },
new() { File = "sausage-survivors-music-basslline-double-time.ogg", IntensityMin = 0.45f, IntensityMax = 0.75f },
new() { File = "sausage-survivors-music-basslline-4x4.ogg", IntensityMin = 0.65f, IntensityMax = 1.00f },
},
} );
defs.Add( new MusicElementDef
{
FolderName = "alien-swag",
LayerName = "alien-swag",
BaseVolume = 0.9f,
Priority = 3,
IsCore = false,
IntensityMin = 0.15f,
IntensityMax = 1.0f,
Motif = "shared",
Clips = new List<MusicClip>
{
new() { File = "sausage-survivors-music-alien-swag-main.ogg", IntensityMin = 0.15f, IntensityMax = 0.45f },
new() { File = "sausage-survivors-music-alien-swag-higher.ogg", IntensityMin = 0.3f, IntensityMax = 0.70f },
new() { File = "sausage-survivors-music-alien-swag-dense.ogg", IntensityMin = 0.60f, IntensityMax = 1.00f },
},
} );
defs.Add( new MusicElementDef
{
FolderName = "supersaw",
LayerName = "supersaw",
BaseVolume = 0.9f,
Priority = 2,
IsCore = false,
IntensityMin = 0.1f,
IntensityMax = 1.0f,
Motif = "shared",
Clips = new List<MusicClip>
{
new() { File = "sausage-survivors-music-supersaw-lead-in.ogg", IntensityMin = 0.1f, IntensityMax = 0.40f, PlayOnce = true },
new() { File = "sausage-survivors-music-supersaw-main.ogg", IntensityMin = 0.30f, IntensityMax = 0.7f },
new() { File = "sausage-survivors-music-supersaw-4x4.ogg", IntensityMin = 0.65f, IntensityMax = 1.00f },
},
} );
defs.Add( new MusicElementDef
{
FolderName = "synth",
LayerName = "synth",
BaseVolume = 0.9f,
Priority = 2,
IsCore = false,
IntensityMin = 0.1f,
IntensityMax = 1.0f,
Motif = "shared",
// Single clip — auto-discovered from folder
} );
return defs;
}
/// <summary>
/// Element defs for the main-menu (lobby) song. The menu plays at a single fixed intensity, so each element
/// uses one always-eligible "main" loop (range 0-1) with no lead-ins — drums-main plays first, every time.
/// These are independent def objects: edit them (add tiers, swap clips, point at a dedicated folder/motif) to
/// customize the menu song without affecting the game song.
/// </summary>
static List<MusicElementDef> BuildMenuElementDefs()
{
var defs = new List<MusicElementDef>();
//defs.Add( new MusicElementDef
//{
// FolderName = "drums",
// LayerName = "drums",
// BaseVolume = 1.0f,
// Priority = 0,
// IsCore = true,
// Motif = "shared",
// Clips = new List<MusicClip>
// {
// new() { File = "sausage-survivors-music-drums-lead-in.ogg", },
// },
//} );
defs.Add( new MusicElementDef
{
FolderName = "bassline",
LayerName = "bassline",
BaseVolume = 0.9f,
Priority = 0,
IsCore = true,
Motif = "shared",
Clips = new List<MusicClip>
{
new() { File = "sausage-survivors-music-basslline-acoustic.ogg", IntensityMin = 0.00f, IntensityMax = 1.0f, PlayFirst = true, PlayOnce = true },
new() { File = "sausage-survivors-music-basslline-main.ogg", IntensityMin = 0.00f, IntensityMax = 1.0f },
},
} );
defs.Add( new MusicElementDef
{
FolderName = "shakers",
LayerName = "shakers",
BaseVolume = 1.0f,
Priority = 2,
IsCore = false,
Motif = "shared",
Clips = new List<MusicClip>
{
new() { File = "sausage-survivors-music-shakers-double-time.ogg" },
},
} );
//defs.Add( new MusicElementDef
//{
// FolderName = "supersaw",
// LayerName = "supersaw",
// BaseVolume = 0.9f,
// Priority = 2,
// IsCore = false,
// Motif = "shared",
// Clips = new List<MusicClip>
// {
// new() { File = "sausage-survivors-music-supersaw-main.ogg" },
// },
//} );
defs.Add( new MusicElementDef
{
FolderName = "alien-swag",
LayerName = "alien-swag",
BaseVolume = 0.9f,
Priority = 2,
IsCore = false,
Motif = "shared",
Clips = new List<MusicClip>
{
new() { File = "sausage-survivors-music-alien-swag-main.ogg" },
},
} );
defs.Add( new MusicElementDef
{
FolderName = "synth",
LayerName = "synth",
BaseVolume = 0.9f,
Priority = 2,
IsCore = false,
Motif = "shared",
// Single clip — auto-discovered from folder
} );
return defs;
}
/// <summary>
/// Builds the full clip registry by combining element defs with clips.
/// Tiered elements (Clips pre-populated in BuildElementDefs) get full paths constructed from their filenames.
/// Simple elements (empty Clips) are auto-discovered from sounds/music/{Motif}/{FolderName}/ and each clip inherits the element's intensity range.
/// Defs with no clips are excluded.
/// </summary>
static List<MusicElementDef> BuildClipRegistry( List<MusicElementDef> defs )
{
const string musicRoot = "sounds/music";
foreach ( var def in defs )
{
string folder = $"{musicRoot}/{def.Motif}/{def.FolderName}";
if ( def.Clips.Count > 0 )
{
// Tiered mode: clips were manually specified; construct full paths.
// Clips with unset intensity (0-1 defaults) inherit the element's range — same effect in practice
// since a clip can only play while its element is active.
foreach ( var clip in def.Clips )
{
clip.Path = $"{folder}/{clip.File}";
if ( clip.IntensityMin == 0f && clip.IntensityMax == 1f )
{
clip.IntensityMin = def.IntensityMin;
clip.IntensityMax = def.IntensityMax;
}
}
}
else
{
// Simple mode: auto-discover files; all clips inherit element intensity range
var files = FindClipsInFolder( folder );
def.Clips = files.Select( f => new MusicClip
{
File = f.Split( '/' ).Last(),
Path = f,
IntensityMin = def.IntensityMin,
IntensityMax = def.IntensityMax,
} ).ToList();
}
}
// Remove defs that have no clips
int removed = defs.RemoveAll( d => d.Clips.Count == 0 );
//if ( removed > 0 )
// Log.Warning( $"Music: {removed} element def(s) removed (no clips found in folder)" );
//foreach ( var def in defs )
// Log.Info( $"Music: [{def.FolderName}] ({def.Motif}/{def.LayerName}) - {def.Clips.Count} clip(s)" );
return defs;
}
static readonly string[] CLIP_EXTENSIONS = { "*.ogg", "*.wav", "*.mp3", "*.flac" };
static List<string> FindClipsInFolder( string folder )
{
try
{
var clips = CLIP_EXTENSIONS
.SelectMany( ext => FileSystem.Mounted.FindFile( folder, ext ) )
.Select( f => $"{folder}/{f}" )
.ToList();
if ( clips.Count > 0 )
Log.Info( $"Music: Found {clips.Count} clip(s) in [{folder}]: {string.Join( ", ", clips.Select( c => c.Split( '/' ).Last() ) )}" );
else
Log.Warning( $"Music: No clips found in [{folder}]" );
return clips;
}
catch ( Exception e )
{
Log.Warning( $"Music: Failed to scan [{folder}]: {e.Message}" );
return new List<string>();
}
}
// --- Intensity ---
float CalculateIntensity()
{
if ( _musicContext == MusicContext.Menu )
return MENU_INTENSITY;
float minutes = ElapsedTime / 60f;
if ( HasSpawnedBoss )
{
float postBossMinutes = minutes - BOSS_SPAWN_MINUTES_DEFAULT;
float oscillation = Utils.FastSin( postBossMinutes * 1.5f );
return Utils.Map( oscillation, -1f, 1f, 0.7f, 1.0f );
}
if ( minutes < 2f )
return Utils.Map( minutes, 0f, 2f, 0.0f, 0.15f, EasingType.SineOut );
if ( minutes < 5f )
return Utils.Map( minutes, 2f, 5f, 0.15f, 0.4f );
if ( minutes < 10f )
return Utils.Map( minutes, 5f, 10f, 0.4f, 0.7f, EasingType.SineIn );
return Utils.Map( minutes, 10f, BOSS_SPAWN_MINUTES_DEFAULT, 0.7f, 0.95f, EasingType.QuadIn );
}
// --- Conductor ---
void AdvancePatterns()
{
foreach ( var pair in _activeLayers )
{
if ( pair.Value.Draining )
continue;
int totalLen = pair.Value.PatternOn + pair.Value.PatternOff;
if ( totalLen > 0 )
pair.Value.PatternCycle = (pair.Value.PatternCycle + 1) % totalLen;
}
}
void ConductorTick()
{
// Increment cycle count for all active (non-draining) layers
foreach ( var pair in _activeLayers )
{
if ( !pair.Value.Draining )
pair.Value.CyclesSinceAdded++;
}
float intensity = CalculateIntensity();
int activeCount = _activeLayers.Values.Count( l => !l.Draining );
//Log.Info( $"Music: === ConductorTick at {ElapsedTime.Relative:F1}s | intensity:{intensity:F3} | active:{activeCount} ===" );
// Phase 1: Consider removing elements.
// Core elements are only pruned when 4+ layers are active (keep a full arrangement before culling).
// Non-core elements are always eligible for removal once MinStayCycles is met.
var layerKeys = _activeLayers.Keys.ToList();
foreach ( var key in layerKeys )
{
var active = _activeLayers[key];
if ( active.Draining )
continue;
var def = GetElementDef( active.ElementName );
if ( def == null )
continue;
// Core elements: skip removal when arrangement is sparse
if ( def.IsCore && activeCount < 4 )
{
//Log.Info( $"Music: Phase1 [{active.ElementName}] KEPT (core, only {activeCount} active)" );
continue;
}
bool minStayMet = active.CyclesSinceAdded >= active.MinStayCycles;
if ( !minStayMet )
{
int remaining = active.MinStayCycles - active.CyclesSinceAdded;
//Log.Info( $"Music: Phase1 [{active.ElementName}] KEPT (min stay not met, {remaining} cycle(s) left)" );
continue;
}
bool outOfRange = intensity < def.IntensityMin || intensity > def.IntensityMax;
if ( outOfRange )
{
float roll = Game.Random.Float( 0f, 1f );
if ( roll < OUT_OF_RANGE_REMOVE_CHANCE )
{
//Log.Info( $"Music: Phase1 [{active.ElementName}] DRAINING (out of range {def.IntensityMin:F2}-{def.IntensityMax:F2}, roll {roll:F2} < {OUT_OF_RANGE_REMOVE_CHANCE})" );
active.Draining = true;
}
else
{
//Log.Info( $"Music: Phase1 [{active.ElementName}] KEPT (out of range but roll {roll:F2} >= {OUT_OF_RANGE_REMOVE_CHANCE})" );
}
}
else
{
float stayChance = def.IsCore ? CORE_STAY_CHANCE : NONCORE_STAY_CHANCE;
float roll = Game.Random.Float( 0f, 1f );
if ( roll > stayChance )
{
//Log.Info( $"Music: Phase1 [{active.ElementName}] DRAINING (in range, roll {roll:F2} > stayChance {stayChance}, core:{def.IsCore})" );
active.Draining = true;
}
else
{
//Log.Info( $"Music: Phase1 [{active.ElementName}] KEPT (in range, roll {roll:F2} <= stayChance {stayChance})" );
}
}
}
// Phase 2: Consider adding elements to empty/draining layers
var allLayerNames = _runElementPool
.Select( d => d.LayerName )
.Distinct()
.ToList();
allLayerNames.Sort( ( a, b ) =>
{
int pa = _runElementPool.Where( d => d.LayerName == a ).Min( d => d.Priority );
int pb = _runElementPool.Where( d => d.LayerName == b ).Min( d => d.Priority );
return pa.CompareTo( pb );
} );
foreach ( var layerName in allLayerNames )
{
bool occupied = _activeLayers.ContainsKey( layerName ) && !_activeLayers[layerName].Draining;
if ( occupied )
{
//Log.Info( $"Music: Phase2 [{layerName}] SKIP (occupied by {_activeLayers[layerName].ElementName})" );
continue;
}
// Avoid re-adding the element that is currently draining on this layer
string drainingElement = null;
if ( _activeLayers.ContainsKey( layerName ) && _activeLayers[layerName].Draining )
drainingElement = _activeLayers[layerName].ElementName;
var eligible = _runElementPool
.Where( d => d.LayerName == layerName
&& d.FolderName != drainingElement
&& intensity >= d.IntensityMin
&& intensity <= d.IntensityMax )
.ToList();
if ( eligible.Count == 0 )
{
var allOnLayer = _runElementPool.Where( d => d.LayerName == layerName ).ToList();
string reason = allOnLayer.Count == 0 ? "no defs" : $"none in range (have: {string.Join( ", ", allOnLayer.Select( d => $"{d.FolderName} {d.IntensityMin:F2}-{d.IntensityMax:F2}" ) )})";
if ( drainingElement != null )
reason += $", excluding draining {drainingElement}";
//Log.Info( $"Music: Phase2 [{layerName}] SKIP ({reason})" );
continue;
}
bool hasCore = eligible.Any( d => d.IsCore );
float addChance = hasCore ? ADD_CHANCE_CORE : ADD_CHANCE_BASE;
float addRoll = Game.Random.Float( 0f, 1f );
if ( addRoll > addChance )
{
//Log.Info( $"Music: Phase2 [{layerName}] SKIP (roll {addRoll:F2} > addChance {addChance}, eligible: {string.Join( ", ", eligible.Select( d => d.FolderName ) )})" );
continue;
}
// Weight by closeness to intensity range center
var weights = new Dictionary<MusicElementDef, float>();
foreach ( var def in eligible )
{
float rangeMid = (def.IntensityMin + def.IntensityMax) / 2f;
float rangeHalf = (def.IntensityMax - def.IntensityMin) / 2f;
float closeness = rangeHalf > 0f
? 1f - MathF.Abs( intensity - rangeMid ) / rangeHalf
: 1f;
weights[def] = MathF.Max( 0.1f, closeness );
}
var chosen = Utils.GetWeightedRandom( weights );
if ( chosen == null )
continue;
//Log.Info( $"Music: Phase2 [{layerName}] ADDING {chosen.FolderName} (roll {addRoll:F2} <= {addChance}, weight {weights[chosen]:F2} from {eligible.Count} eligible)" );
ActivateElement( chosen, layerName );
break; // only add 1 element per tick
}
}
void ActivateElement( MusicElementDef def, string layerName )
{
string clipPath = PickClipForIntensity( def, CalculateIntensity() );
if ( clipPath == null ) return;
// If there's an existing player on this layer, move it to tails
if ( _activeLayers.ContainsKey( layerName ) )
{
var old = _activeLayers[layerName];
if ( old.Player != null )
_tailPlayers.Add( (old.Player, old.ClipPath) );
_activeLayers.Remove( layerName );
}
// Start the new clip immediately at full volume
var player = MusicPlayer.Play( FileSystem.Mounted, clipPath );
player.Repeat = false;
player.ListenLocal = true;
player.TargetMixer = Mixer.FindMixerByName( def.MixerName ) ?? MusicMixer;
player.Volume = def.BaseVolume * GLOBAL_VOLUME * MUSIC_VOLUME;
// Pick a pattern (if defined)
int patternOn = 1;
int patternOff = 0;
if ( def.Patterns != null && def.Patterns.Count > 0 )
{
var patternWeights = new Dictionary<MusicPattern, float>();
foreach ( var p in def.Patterns )
patternWeights[p] = p.Weight;
var chosen = Utils.GetWeightedRandom( patternWeights );
if ( chosen != null )
{
patternOn = chosen.On;
patternOff = chosen.Off;
}
}
var active = new ActiveMusicLayer
{
ElementName = def.FolderName,
LayerName = layerName,
ClipPath = clipPath,
Player = player,
CyclesSinceAdded = 0,
MinStayCycles = Game.Random.Int( MIN_STAY_CYCLES, MAX_STAY_CYCLES ),
PatternOn = patternOn,
PatternOff = patternOff,
PatternCycle = 0,
Draining = false,
};
_activeLayers[layerName] = active;
string patternLog = patternOff > 0 ? $" pattern:{patternOn}/{patternOff}" : "";
//Log.Info( $"Music: +[{def.FolderName}] on [{layerName}] clip:{clipPath}{patternLog}" );
}
// --- Volume ---
void UpdateLayerVolumes()
{
float pauseMult = IsPaused ? 0.5f : IsPausedForChoosing ? 0.8f : 1f;
// Fade the menu song in over MENU_FADE_IN_DURATION when arriving at the lobby.
float fadeMult = _musicContext == MusicContext.Menu
? MathX.Clamp( (float)_menuMusicStarted / MENU_FADE_IN_DURATION, 0f, 1f )
: 1f;
foreach ( var pair in _activeLayers )
{
var layer = pair.Value;
if ( layer.Player == null )
continue;
var def = GetElementDef( layer.ElementName );
float baseVol = def?.BaseVolume ?? 1f;
float patternMult = layer.IsPatternMuted ? 0f : 1f;
layer.Player.Volume = baseVol * GLOBAL_VOLUME * MUSIC_VOLUME * pauseMult * patternMult * fadeMult;
}
foreach ( var t in _tailPlayers )
{
if ( t.Player != null )
t.Player.Volume = GLOBAL_VOLUME * MUSIC_VOLUME * pauseMult * fadeMult;
}
}
// --- Helpers ---
/// <summary>
/// Picks a clip path from the element's clip list that matches the current intensity.
/// When <paramref name="exclude"/> is non-empty (exhausted play-once clips), only in-range non-excluded clips
/// are considered; returns null if none remain, signalling the element should drain.
/// Without exclusions, falls back to the closest clip by range midpoint if no clip covers the exact intensity.
/// </summary>
string PickClipForIntensity( MusicElementDef def, float intensity, HashSet<string> exclude = null )
{
var inRange = def.Clips
.Where( c => intensity >= c.IntensityMin && intensity <= c.IntensityMax )
.ToList();
// Fresh activation (exclude == null): if any in-range clips are marked PlayFirst, pick exclusively from those.
// This guarantees a lead-in clip plays before the main loop, even if they share an intensity range.
if ( exclude == null )
{
var firstPicks = inRange.Where( c => c.PlayFirst ).ToList();
if ( firstPicks.Count > 0 )
return firstPicks[Game.Random.Next( firstPicks.Count )].Path;
}
if ( exclude is { Count: > 0 } )
{
// Play-once mode: only pick from in-range clips that haven't been used yet.
// Return null if none remain — the element will drain.
var eligible = inRange.Where( c => !exclude.Contains( c.Path ) ).ToList();
if ( eligible.Count == 0 ) return null;
return eligible[Game.Random.Next( eligible.Count )].Path;
}
// Standard mode: fall back to closest clip if nothing is in range
if ( inRange.Count == 0 )
{
var fallback = def.Clips
.OrderBy( c => MathF.Abs( (c.IntensityMin + c.IntensityMax) / 2f - intensity ) )
.FirstOrDefault();
return fallback?.Path;
}
return inRange[Game.Random.Next( inRange.Count )].Path;
}
MusicElementDef GetElementDef( string name )
{
return _runElementPool.FirstOrDefault( d => d.FolderName == name );
}
}