A game audio system for the Basalt map that manages per-room soundscapes: room-specific DSP echo presets applied on the Game mixer, a looping room tone, and occasional placed ambient one-shot sounds positioned around the listener. It selects profiles by HUD room name, fades DSP processors in and out, places spatial sounds by casting/reaching into the world, and exposes console commands to inspect and tweak behaviour.
using Sandbox;
using Sandbox.Audio;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// BASALT'S ROOMS, HEARD — each named room with its own echo, its own tone under the bed, and sounds that happen somewhere in
/// it: lava breaking below the bridge, a stone settling in the wall, water dripping in the Reliquary, the engine's metal ticking.
/// Asked for as *"let's start with sound / i love / 8, 9, 10"* (2026-09-28): an echo per room, a tone per room, sounds with a
/// place. The sounds are made by `Tools/basalt_soundscape.py`.
///
/// ⛔ THE ECHO IS OFF (2026-09-28): it lies over everything under Game, the guns and the zombies too, and the user wants those
/// dry — *"i wanted the tone, drips creaks and lava gloops / i just did not want to affect the gun and zombie sounds etc with the
/// echo"*. The tones and the placed sounds play as before. `nz_room_echo auto` still auditions the room table's echo, over
/// everything, for the session. (The whole soundscape was taken out at 21:03 on a misreading of that, and put back at 21:40.)
///
/// ⛔ THE ECHO IS THE ENGINE'S OWN DSP, ON THE GAME MIXER, the way `DspVolumeGameSystem` applies a `DspVolume`'s: a
/// `DspProcessor` per preset, faded by its `Mix` (the mixer blends `dry × (1 − Mix) + processed × Mix`), added with
/// `Mixer.AddProcessor`, and taken off once it has faded out. A room change fades the new preset in while the old one fades out;
/// both sit on the mixer for that moment. A mixer's processors cover its whole subtree (`Mixer.ApplyProcessors`): weapons,
/// zombies, impacts, the voice chat, the world, and the footsteps the engine plays straight onto Game. Music, the announcer and
/// the UI are Game's siblings and stay dry.
///
/// ⚠️ NO VOLUMES IN THE SCENE, BECAUSE THE ROOMS ARE ALREADY KNOWN. `RoomNames.CurrentName` is the room this machine's player is
/// in: a drawn zone, or else the last door walked through. It is the name on the HUD, so the echo changes where the name does.
///
/// ⚠️ EACH MACHINE, FOR ITS OWN LISTENER. Nothing crosses the wire. The tone is 2D and a one-shot is placed around this machine's
/// ear, so two players in two rooms each hear their own.
///
/// ⚠️ BASALT ONLY, AND ONLY IN A GAME, by the bed's rule (`MapAmbience`): not in the lobby, not under the loading screen, not in
/// creative. `nz_soundscape` reports what it is doing.
/// </summary>
public sealed class RoomSoundscape : GameObjectSystem<RoomSoundscape>
{
public RoomSoundscape( Scene scene ) : base( scene )
{
Listen( Stage.StartUpdate, 0, Tick, "nz.roomsoundscape" );
}
/// <summary>
/// ⛔ THE PROCESSORS COME OFF THE MIXER WITH THE SCENE. The mixers are the project's, not the scene's, so an echo left on
/// Game would go on colouring every sound after play stops, including in the editor.
/// </summary>
public override void Dispose()
{
Silence();
base.Dispose();
}
// ══ tuning ═══════════════════════════════════════════════════════════════════════════════════════════════════════════════
//
// ⛔ NULLABLE-BACKED — INSTRUCTIONS.md §1: a static keeps its value through a hotload and its initialiser does not run again.
static bool? _on;
/// <summary>The whole soundscape. `nz_soundscape 0` turns it off for this session.</summary>
public static bool Enabled { get => _on ?? true; set => _on = value; }
static bool? _echoOn;
/// <summary>The rooms' echo — ⛔ OFF BY DEFAULT, see the class notes: it would reach the guns and the zombies. `nz_room_echo auto` / `off`.</summary>
public static bool EchoOn { get => _echoOn ?? false; set => _echoOn = value; }
static string _echoPreset;
/// <summary>A preset heard in every room instead of each room's own, to audition one (`nz_room_echo cavern.large`). Blank:
/// each room's own.</summary>
public static string EchoPreset { get => _echoPreset ?? ""; set => _echoPreset = value; }
static float? _echoMix;
/// <summary>A mix used in every room instead of each room's own; below 0, each room's own.</summary>
public static float EchoMix { get => _echoMix ?? -1f; set => _echoMix = value; }
static float? _echoScale;
/// <summary>Every room's echo, scaled. 1 = as the table sets it (`nz_room_echo_scale`).</summary>
public static float EchoScale { get => _echoScale ?? 1f; set => _echoScale = value; }
static float? _echoFade;
/// <summary>How long a room's echo takes to come in, and the last one to go, in seconds.</summary>
public static float EchoFadeSeconds { get => _echoFade ?? 1.5f; set => _echoFade = value; }
static float? _toneScale;
/// <summary>The room tones' volume. 1 = as the sound events set it; 0 = none (`nz_room_tone`).</summary>
public static float ToneScale { get => _toneScale ?? 1f; set => _toneScale = value; }
static float? _toneFade;
/// <summary>How long one room's tone takes to give way to the next, in seconds.</summary>
public static float ToneFadeSeconds { get => _toneFade ?? 3f; set => _toneFade = value; }
static float? _soundsScale;
/// <summary>How often the rooms' sounds come. 1 = as the table sets it, 2 = twice as often, 0 = none (`nz_room_sounds`).</summary>
public static float SoundsScale { get => _soundsScale ?? 1f; set => _soundsScale = value; }
static string _as;
/// <summary>Heard as if standing in this room, wherever the player is (`nz_room_as`). Blank: where the player is.</summary>
public static string HearAs { get => _as ?? ""; set => _as = value; }
/// <summary>A one-shot comes no nearer than this to the ear, in units. Closer, it would sound as if it were on the player.</summary>
const float MinDistance = 250f;
/// <summary>What a trace for a one-shot's place passes through.</summary>
static readonly string[] PassThrough = { "player", "zombie", "trigger", "corpse", "ragdoll" };
// ══ the rooms ════════════════════════════════════════════════════════════════════════════════════════════════════════════
/// <summary>Where a room's one-shot is placed.</summary>
public enum Where
{
/// <summary>At one of the config's placed lava loops, 300 to 2,200 units off; below the ear if there is none.</summary>
Lava,
/// <summary>Under the floor, near the ear: the Conduit's river.</summary>
Below,
/// <summary>In the stone of a wall, where a level-ish ray from the ear meets it.</summary>
Wall,
/// <summary>In the ceiling or high on a wall, where a rising ray meets it.</summary>
Ceiling,
/// <summary>On the floor, a few hundred units off.</summary>
Floor,
/// <summary>Far away, 1,800 to 2,600 units, through whatever rock lies between.</summary>
Far,
/// <summary>Behind the listener, a few hundred units off.</summary>
Behind,
/// <summary>In the open air of the room, 600 to 1,400 units off.</summary>
Air,
}
/// <summary>One room as heard: its echo and the mix of it, and what happens in it — every `GapMin`..`GapMax` seconds, one of
/// `Sounds` by weight, at its `Where`. `Key` names its tone (`nz.basalt.roomtone.` + the key); blank has none.</summary>
public sealed record Profile( string Key, string Echo, float Mix, float GapMin, float GapMax,
(string Kind, float Weight, Where At)[] Sounds );
/// <summary>
/// The room that a HUD name is.
///
/// ⚠️ A SWITCH THAT BUILDS, NOT A TABLE THAT HOLDS (INSTRUCTIONS.md §1): a static table keeps its old values through a hotload.
/// This one is asked when the name changes and once a second, so an edit here is heard within a second.
///
/// ⚠️ BOTH SPELLINGS OF A ROOM. The HUD shows a drawn zone's name where one is drawn, and a door's where none is. Two of basalt's
/// zones are named apart from their doors: "Lava Bridge" is the Causeway, and "Colapsed Stairway" (the config's spelling) is
/// the Ember Stair.
///
/// ⚠️ THE PRESETS ARE THE ENGINE'S (`Sound.DspNames`, `nz_room_echo_list`), chosen from what `DspFactory` builds each one of:
/// the caverns carry a real delay (150, 200 and 300 ms, with feedback) under their reverb, the chambers a modulated reverb,
/// concrete a diffuse one that darkens with size, `room.empty.huge` the longest tail there is. The mixes are first guesses,
/// meant to be set by ear with `nz_room_echo`.
/// </summary>
public static Profile ProfileFor( string name ) => (name ?? "").Trim().ToLowerInvariant() switch
{
"waygate" => new( "waygate", "room.empty.huge", 0.30f, 9f, 17f, new[]
{
("creak", 2f, Where.Wall), ("gust", 2f, Where.Air), ("rockfall", 1f, Where.Ceiling), ("rumble", 1f, Where.Far),
("ember", 1f, Where.Floor),
} ),
"crucible" => new( "crucible", "cavern.medium", 0.40f, 4f, 10f, new[]
{
("lava_gloop", 4f, Where.Lava), ("ember", 3f, Where.Floor), ("steam", 2f, Where.Wall), ("lava_surge", 1f, Where.Lava),
("rockfall", 1f, Where.Ceiling),
} ),
"causeway" or "lava bridge" or "lavabridge" => new( "causeway", "cavern.large", 0.50f, 4f, 10f, new[]
{
("lava_gloop", 3f, Where.Lava), ("lava_surge", 2f, Where.Lava), ("rumble", 2f, Where.Far), ("rockfall", 2f, Where.Ceiling),
("gust", 2f, Where.Air), ("steam", 1f, Where.Lava),
} ),
"nexus" => new( "nexus", "concrete.medium", 0.40f, 7f, 15f, new[]
{
("gust", 3f, Where.Air), ("creak", 2f, Where.Wall), ("rockfall", 1f, Where.Ceiling), ("rumble", 1f, Where.Far),
("drip", 1f, Where.Floor),
} ),
"ember stair" or "emberstair" or "colapsed stairway" or "collapsed stairway" => new( "emberstair", "chamber.medium", 0.40f, 5f, 11f, new[]
{
("rockfall", 4f, Where.Ceiling), ("creak", 2f, Where.Wall), ("ember", 2f, Where.Floor), ("gust", 1f, Where.Air),
} ),
"reliquary" => new( "reliquary", "concrete.large", 0.40f, 3f, 8f, new[]
{
("drip", 5f, Where.Floor), ("creak", 2f, Where.Wall), ("rockfall", 1f, Where.Ceiling), ("whisper", 1f, Where.Behind),
} ),
"magma engine" or "engine" => new( "engine", "metallic.medium", 0.35f, 4f, 9f, new[]
{
("clank", 4f, Where.Wall), ("steam", 3f, Where.Wall), ("creak", 1f, Where.Wall), ("lava_gloop", 1f, Where.Lava),
("arc", 1f, Where.Wall),
} ),
"conduit" => new( "conduit", "tunnel.medium", 0.40f, 5f, 11f, new[]
{
("lava_gloop", 2f, Where.Below), ("steam", 2f, Where.Wall), ("arc", 2f, Where.Wall), ("clank", 1f, Where.Wall),
("rumble", 1f, Where.Far),
} ),
"sanctum" => new( "sanctum", "chamber.large", 0.45f, 8f, 16f, new[]
{
("whisper", 3f, Where.Behind), ("creak", 2f, Where.Wall), ("drip", 1f, Where.Floor), ("rumble", 1f, Where.Far),
} ),
"relay" => new( "relay", "cavern.small", 0.40f, 6f, 12f, new[]
{
("arc", 3f, Where.Wall), ("clank", 2f, Where.Wall), ("creak", 1f, Where.Wall), ("rockfall", 1f, Where.Ceiling),
} ),
// ⚠️ A NAME THE TABLE DOES NOT KNOW — a room added later, or renamed: the mountain's echo and its settling stone, no tone
_ => new( "", "cavern.medium", 0.35f, 9f, 18f, new[]
{
("rumble", 1f, Where.Far), ("creak", 1f, Where.Wall), ("rockfall", 1f, Where.Ceiling),
} ),
};
// ══ state (this scene's) ═════════════════════════════════════════════════════════════════════════════════════════════════
/// <summary>The echo's processors on the Game mixer, newest last: the one coming in, and any still fading out.</summary>
readonly List<DspProcessor> _stages = new();
Mixer _mixer;
string _roomName;
Profile _room;
RealTimeSince _sinceRoom;
SoundHandle _tone;
string _toneCue;
float _toneLevel, _toneVol;
/// <summary>The tones fading out: the room before, and the one before that if the player crossed two rooms inside a fade.</summary>
readonly List<(SoundHandle Handle, string Cue, float Level, float Vol)> _fading = new();
RealTimeUntil _nextShot;
string _shotRoom;
int _shots;
string _lastShot = "";
bool _swept;
// ══ every frame ══════════════════════════════════════════════════════════════════════════════════════════════════════════
/// <summary>Is this the moment for the rooms: basalt, a game on, the lobby closed, no loading screen.</summary>
static bool Wanted => Enabled && NZGame.Mode == GameMode.Survival && LobbyState.IsOpen?.Invoke() != true
&& !MapLoading.Active && OnBasalt;
/// <summary>
/// Basalt or not, asked every two seconds. ⚠️ NOT EVERY FRAME: `NZMap.Current` finds the scene's `MapInstance` by searching
/// every component in the scene, each time it is asked.
/// </summary>
static bool OnBasalt
{
get
{
if ( _basaltAsked is not { } asked || asked > 2f )
{
_onBasalt = HexPlatforms.OnBasalt;
_basaltAsked = 0f;
}
return _onBasalt;
}
}
static bool _onBasalt;
static RealTimeSince? _basaltAsked;
void Tick()
{
if ( Scene is null || Scene.IsEditor ) return;
var on = Wanted;
if ( on && !_swept ) SweepStrays();
_swept = on;
var name = string.IsNullOrWhiteSpace( HearAs ) ? RoomNames.CurrentName : HearAs;
if ( _room is null || name != _roomName || _sinceRoom > 1f )
{
_roomName = name;
_room = ProfileFor( name );
_sinceRoom = 0f;
}
TickEcho( on ? _room : null );
TickTone( on ? _room : null );
if ( on ) TickShots( _room );
else _shotRoom = null;
}
// ── the echo ─────────────────────────────────────────────────────────────────────────────────────────────────────────────
void TickEcho( Profile room )
{
// ⚠️ LOOKED UP EVERY FRAME, NOT HELD (`MixerBus.Find`): a mixer outlives its graph when the project settings reload, and a
// stale one takes processors that nothing hears. A new Game mixer takes the stages over from the old one.
var m = MixerBus.Find( MixerBus.Game );
if ( !ReferenceEquals( m, _mixer ) )
{
if ( _mixer is not null ) foreach ( var p in _stages ) Detach( _mixer, p );
_mixer = m;
if ( m is not null ) foreach ( var p in _stages ) m.AddProcessor( p );
}
if ( m is null ) return;
string want = null;
var mix = 0f;
if ( room is not null && EchoOn )
{
want = string.IsNullOrWhiteSpace( EchoPreset ) ? room.Echo : EchoPreset.Trim();
mix = Math.Clamp( (EchoMix >= 0f ? EchoMix : room.Mix) * EchoScale, 0f, 1f );
}
DspProcessor current = null;
if ( !string.IsNullOrEmpty( want ) && mix > 0f )
{
// ⚠️ A STAGE STILL FADING OUT IS TAKEN BACK, not doubled: stepping back over a doorway fades the same echo up again
current = _stages.LastOrDefault( p => p.Effect.Name == want );
if ( current is null )
{
current = new DspProcessor( want ) { Mix = 0f };
_stages.Add( current );
m.AddProcessor( current );
}
}
// ⚠️ EVERY FADE TAKES `EchoFadeSeconds`, WHATEVER THE MIX: the step is a share of the larger of the two mixes
var step = RealTime.Delta / MathF.Max( 0.05f, EchoFadeSeconds );
for ( var i = _stages.Count - 1; i >= 0; i-- )
{
var p = _stages[i];
var target = ReferenceEquals( p, current ) ? mix : 0f;
p.Mix = Toward( p.Mix, target, step * MathF.Max( 0.2f, MathF.Max( mix, p.Mix ) ) );
if ( !ReferenceEquals( p, current ) && p.Mix <= 0f )
{
Detach( m, p );
_stages.RemoveAt( i );
}
}
}
static void Detach( Mixer m, DspProcessor p )
{
try { m.RemoveProcessor( p ); }
catch ( Exception e ) { Log.Warning( $"[nz-soundscape] could not take '{p.Effect.Name}' off {m.Name}: {e.Message}" ); }
}
/// <summary>
/// ⚠️ ONCE, AS THE ROOMS COME ON: any DSP left on Game by a scene that never disposed (a crash, a hotload that lost this
/// system's list) comes off, so an old echo cannot sit under the new one for the rest of the session. Left alone if the scene
/// has a `DspVolume`, since that DSP is the engine's own.
/// </summary>
void SweepStrays()
{
var m = MixerBus.Find( MixerBus.Game );
if ( m is null ) return;
if ( Scene.GetAllComponents<DspVolume>().Any() ) return;
foreach ( var p in m.GetProcessors().OfType<DspProcessor>().ToArray() )
{
if ( _stages.Contains( p ) ) continue;
Detach( m, p );
Log.Info( $"[nz-soundscape] took a stray '{p.Effect.Name}' echo off Game" );
}
}
// ── the tone ─────────────────────────────────────────────────────────────────────────────────────────────────────────────
void TickTone( Profile room )
{
var want = room is not null && !string.IsNullOrEmpty( room.Key ) && ToneScale > 0f ? "nz.basalt.roomtone." + room.Key : null;
if ( want != _toneCue )
{
// the tone playing now fades out on its own; ⚠️ ONE STILL FADING THAT IS WANTED AGAIN IS TAKEN BACK where its fade has
// got to — stepping back over a doorway brings the room's tone back up, it does not start it again from silence
if ( _tone.IsValid() ) _fading.Add( (_tone, _toneCue, _toneLevel, _toneVol) );
_tone = default;
_toneCue = want;
_toneLevel = 0f;
var back = _fading.FindLastIndex( f => f.Cue == want );
if ( back >= 0 )
{
(_tone, _, _toneLevel, _toneVol) = _fading[back];
_fading.RemoveAt( back );
}
}
var step = RealTime.Delta / MathF.Max( 0.05f, ToneFadeSeconds );
for ( var i = _fading.Count - 1; i >= 0; i-- )
{
var f = _fading[i];
f.Level = Toward( f.Level, 0f, step );
if ( f.Level <= 0f || !f.Handle.IsValid() || !f.Handle.IsPlaying )
{
var h = f.Handle;
Stop( ref h );
_fading.RemoveAt( i );
continue;
}
f.Handle.Volume = f.Vol * f.Level * ToneScale * NZSound.VolumeOf( f.Cue );
_fading[i] = f;
}
if ( want is null )
{
Stop( ref _tone );
return;
}
_toneLevel = Toward( _toneLevel, 1f, step );
// ⚠️ IT RESTARTS ITSELF WHEN IT ENDS, as the bed does (there is no loop flag). The file is made to meet its own start.
if ( !_tone.IsValid() || !_tone.IsPlaying )
{
if ( !NZSound.Enabled || !NZSound.Exists( want ) ) return;
_tone = Sound.Play( want );
if ( !_tone.IsValid() ) return;
_toneVol = _tone.Volume;
}
_tone.Volume = _toneVol * _toneLevel * ToneScale * NZSound.VolumeOf( want );
}
static void Stop( ref SoundHandle h )
{
if ( h.IsValid() ) h.Stop();
h = default;
}
// ── the sounds with a place ──────────────────────────────────────────────────────────────────────────────────────────────
void TickShots( Profile room )
{
if ( SoundsScale <= 0f || room.Sounds.Length == 0 ) return;
// ⚠️ A NEW ROOM IS HEARD SOON: the first of its sounds comes within a few seconds of walking in, not after a full gap
if ( room.Key != _shotRoom )
{
_shotRoom = room.Key;
_nextShot = Game.Random.Float( 2f, 5f ) / SoundsScale;
return;
}
if ( _nextShot > 0f ) return;
_nextShot = Game.Random.Float( room.GapMin, room.GapMax ) / SoundsScale;
PlayOne( room, null );
}
/// <summary>One of the room's sounds — by weight, or the kind named — at its place. What played and where, for the console.</summary>
public string PlayOne( Profile room, string kind )
{
(string Kind, float Weight, Where At) pick;
if ( !string.IsNullOrWhiteSpace( kind ) )
{
kind = kind.Trim().ToLowerInvariant();
pick = room.Sounds.FirstOrDefault( s => s.Kind == kind );
if ( pick.Kind is null ) pick = (kind, 1f, Where.Wall);
}
else pick = ByWeight( room.Sounds );
var cue = "nz.basalt.sfx." + pick.Kind;
if ( !NZSound.Exists( cue ) ) return $"no asset for '{cue}' (Tools/basalt_soundscape.py makes them)";
var at = PlaceFor( pick.At );
if ( at is null ) return $"nowhere to put '{cue}'";
NZSound.Play( cue, at.Value );
_shots++;
_lastShot = $"{pick.Kind} ({pick.At}) {at.Value.Distance( NZSound.Ear ):0}u away";
return _lastShot;
}
static (string Kind, float Weight, Where At) ByWeight( (string Kind, float Weight, Where At)[] list )
{
var total = 0f;
foreach ( var s in list ) total += MathF.Max( 0f, s.Weight );
var r = Game.Random.Float( 0f, total );
foreach ( var s in list )
{
r -= MathF.Max( 0f, s.Weight );
if ( r <= 0f ) return s;
}
return list[^1];
}
Vector3? PlaceFor( Where where )
{
var ear = Sound.Listener.Position;
switch ( where )
{
case Where.Lava:
{
// ⚠️ THE CONFIG'S OWN LAVA LOOPS SAY WHERE THE LAVA IS — basalt places them over it, 79 of them
var spots = ActiveConfig.Current?.Sounds;
if ( spots is not null )
{
var near = new List<Vector3>();
foreach ( var s in spots )
{
if ( s is null || !(s.Sound ?? "").Contains( "lava", StringComparison.OrdinalIgnoreCase ) ) continue;
var d = s.Position.Distance( ear );
if ( d > 300f && d < 2200f ) near.Add( s.Position );
}
if ( near.Count > 0 ) return near[Game.Random.Next( near.Count )] + Around( 0f, 150f );
}
return ear + Around( 600f, 1400f ) + Vector3.Down * 250f;
}
case Where.Below:
return ear + Around( 60f, 300f ) + Vector3.Down * 200f;
case Where.Wall:
return Cast( ear, -0.05f, 0.3f, 1500f ) ?? Reach( ear, ear + Around( 700f, 1100f ) );
case Where.Ceiling:
return Cast( ear, 0.45f, 0.9f, 1400f ) ?? Reach( ear, ear + Around( 200f, 600f ) + Vector3.Up * 400f );
case Where.Floor:
{
var p = Reach( ear, ear + Around( 300f, 900f ) );
var tr = Scene.Trace.Ray( p, p + Vector3.Down * 700f ).WithoutTags( PassThrough ).Run();
return tr.Hit ? tr.HitPosition + Vector3.Up * 8f : p + Vector3.Down * 150f;
}
case Where.Far:
return ear + Around( 1800f, 2600f ) + Vector3.Up * Game.Random.Float( -100f, 300f );
case Where.Behind:
{
var fwd = Sound.Listener.Rotation.Forward.WithZ( 0f );
fwd = fwd.Length < 0.01f ? Vector3.Forward : fwd.Normal;
var right = new Vector3( fwd.y, -fwd.x, 0f );
var p = ear - fwd * Game.Random.Float( 250f, 450f ) + right * Game.Random.Float( -180f, 180f );
return Reach( ear, p );
}
default:
return Reach( ear, ear + Around( 600f, 1400f ) + Vector3.Up * Game.Random.Float( 50f, 200f ) );
}
}
/// <summary>
/// Where a ray out from the ear, at a random bearing and a rise between `zMin` and `zMax`, meets stone within `reach`: pulled
/// back 24 units off the face, so the sound is IN the room and not inside the rock. Four tries; null if every ray found open
/// air, or stone nearer than `MinDistance`.
/// </summary>
Vector3? Cast( Vector3 ear, float zMin, float zMax, float reach )
{
for ( var i = 0; i < 4; i++ )
{
var yaw = Game.Random.Float( 0f, MathF.PI * 2f );
var dir = new Vector3( MathF.Cos( yaw ), MathF.Sin( yaw ), Game.Random.Float( zMin, zMax ) ).Normal;
var tr = Scene.Trace.Ray( ear, ear + dir * reach ).WithoutTags( PassThrough ).Run();
if ( !tr.Hit || tr.Distance < MinDistance ) continue;
return tr.HitPosition - dir * 24f;
}
return null;
}
/// <summary>
/// `to`, or where the way there is first blocked (32 units short of it), so a place does not end inside a wall. ⚠️ NEVER NEARER
/// THAN `MinDistance`: a wall close by leaves the sound beyond it, where the occlusion muffles it as rock would.
/// </summary>
Vector3 Reach( Vector3 from, Vector3 to )
{
var tr = Scene.Trace.Ray( from, to ).WithoutTags( PassThrough ).Run();
if ( !tr.Hit ) return to;
var dir = (to - from).Normal;
var d = MathF.Max( tr.Distance - 32f, MathF.Min( MinDistance, (to - from).Length ) );
return from + dir * d;
}
/// <summary>A level offset at a random bearing, between `min` and `max` long.</summary>
static Vector3 Around( float min, float max )
{
var yaw = Game.Random.Float( 0f, MathF.PI * 2f );
var d = Game.Random.Float( min, max );
return new Vector3( MathF.Cos( yaw ) * d, MathF.Sin( yaw ) * d, 0f );
}
static float Toward( float v, float target, float step )
=> v < target ? MathF.Min( target, v + step ) : MathF.Max( target, v - step );
/// <summary>Everything off, now: the echo off the mixer, the tones stopped.</summary>
void Silence()
{
var m = _mixer ?? MixerBus.Find( MixerBus.Game );
if ( m is not null ) foreach ( var p in _stages ) Detach( m, p );
_stages.Clear();
_mixer = null;
Stop( ref _tone );
foreach ( var f in _fading )
{
var h = f.Handle;
Stop( ref h );
}
_fading.Clear();
_toneCue = null;
_toneLevel = 0f;
}
// ══ the console ══════════════════════════════════════════════════════════════════════════════════════════════════════════
/// <summary>`nz_soundscape [0|1]` — the rooms on or off for this session; bare, what they are doing.</summary>
[ConCmd( "nz_soundscape" )]
public static void Cmd( int on = -1 )
{
if ( on >= 0 ) Enabled = on != 0;
var s = Current;
if ( s is null ) { Log.Info( "[nz-soundscape] no scene" ); return; }
var room = s._room ?? ProfileFor( RoomNames.CurrentName );
Log.Info( $"[nz-soundscape] {(Enabled ? "on" : "OFF")} · {(Wanted ? "playing" : "idle (basalt, in a game only)")}"
+ $" · room '{s._roomName}' -> {(string.IsNullOrEmpty( room.Key ) ? "(default)" : room.Key)}"
+ (string.IsNullOrWhiteSpace( HearAs ) ? "" : $" (heard as '{HearAs}': nz_room_as auto to stop)") );
var m = MixerBus.Find( MixerBus.Game );
var stages = s._stages.Count == 0 ? "none" : string.Join( ", ", s._stages.Select( p => $"{p.Effect.Name} at {p.Mix:0.00}" ) );
Log.Info( $"[nz-soundscape] echo {(EchoOn ? "on" : "OFF")} · {stages} · Game mixer has {m?.ProcessorCount ?? -1} processor(s)"
+ $" · room's own {room.Echo} at {room.Mix:0.00}, scale {EchoScale:0.00}"
+ (string.IsNullOrWhiteSpace( EchoPreset ) ? "" : $", FORCED {EchoPreset}") + (EchoMix >= 0f ? $", mix forced {EchoMix:0.00}" : "") );
Log.Info( $"[nz-soundscape] tone {(s._tone.IsValid() && s._tone.IsPlaying ? $"'{s._toneCue}' at {s._toneLevel:0.00}" : "none")}"
+ $" · x{ToneScale:0.00}" + string.Concat( s._fading.Select( f => $" · '{f.Cue}' fading at {f.Level:0.00}" ) ) );
Log.Info( $"[nz-soundscape] sounds x{SoundsScale:0.00} · every {room.GapMin:0}-{room.GapMax:0} s · next in {MathF.Max( 0f, s._nextShot ):0.0} s"
+ $" · {s._shots} played · last: {(s._lastShot.Length == 0 ? "-" : s._lastShot)}" );
}
/// <summary>`nz_room_echo [preset|auto|off|on] [mix]` — audition an echo in every room, or go back to each room's own.</summary>
[ConCmd( "nz_room_echo" )]
public static void CmdEcho( string preset = "", float mix = -1f )
{
switch ( (preset ?? "").Trim().ToLowerInvariant() )
{
case "": break;
case "off" or "0": EchoOn = false; break;
case "on" or "1": EchoOn = true; break;
case "auto": EchoOn = true; EchoPreset = ""; EchoMix = -1f; break;
default:
if ( !Sound.DspNames.Contains( preset.Trim() ) )
{
Log.Warning( $"[nz-soundscape] no preset '{preset}' — nz_room_echo_list lists them" );
return;
}
EchoOn = true;
EchoPreset = preset.Trim();
break;
}
if ( mix >= 0f ) EchoMix = Math.Clamp( mix, 0f, 1f );
Cmd();
}
/// <summary>`nz_room_echo_scale <mult>` — every room's echo mix, scaled (0.5 halves it, 1 is the table's).</summary>
[ConCmd( "nz_room_echo_scale" )]
public static void CmdEchoScale( float mult = 1f )
{
EchoScale = MathF.Max( 0f, mult );
Cmd();
}
/// <summary>`nz_room_echo_list` — the engine's DSP presets, and which room uses which.</summary>
[ConCmd( "nz_room_echo_list" )]
public static void CmdEchoList()
{
Log.Info( $"[nz-soundscape] presets: {string.Join( ", ", Sound.DspNames )}" );
foreach ( var n in new[] { "Waygate", "Crucible", "Causeway", "Nexus", "Ember Stair", "Reliquary", "Magma Engine", "Conduit", "Sanctum", "Relay", "" } )
{
var p = ProfileFor( n );
Log.Info( $"[nz-soundscape] {(n.Length == 0 ? "(any other)" : n),-14} {p.Echo,-16} {p.Mix:0.00} tone {(p.Key.Length == 0 ? "-" : p.Key),-11}"
+ $" sounds every {p.GapMin:0}-{p.GapMax:0} s: {string.Join( ", ", p.Sounds.Select( s => $"{s.Kind} {s.Weight:0}" ) )}" );
}
}
/// <summary>`nz_room_tone <mult>` — the room tones' volume (0 = none, 1 = as authored).</summary>
[ConCmd( "nz_room_tone" )]
public static void CmdTone( float mult = 1f )
{
ToneScale = MathF.Max( 0f, mult );
Cmd();
}
/// <summary>`nz_room_sounds <mult>` — how often the rooms' sounds come (0 = none, 2 = twice as often).</summary>
[ConCmd( "nz_room_sounds" )]
public static void CmdSounds( float mult = 1f )
{
SoundsScale = MathF.Max( 0f, mult );
Cmd();
}
/// <summary>`nz_room_sound [kind]` — one of this room's sounds now, or the kind named (lava_gloop, lava_surge, steam, rockfall,
/// creak, rumble, drip, clank, arc, ember, whisper, gust).</summary>
[ConCmd( "nz_room_sound" )]
public static void CmdSound( string kind = "" )
{
var s = Current;
if ( s is null ) { Log.Info( "[nz-soundscape] no scene" ); return; }
var room = s._room ?? ProfileFor( RoomNames.CurrentName );
Log.Info( $"[nz-soundscape] {s.PlayOne( room, kind )}" );
}
/// <summary>`nz_room_as <room|auto>` — hear the rooms as if standing in that one ("Sanctum", "Lava Bridge"...), to compare
/// them without walking; `auto` goes back to where the player is.</summary>
[ConCmd( "nz_room_as" )]
public static void CmdAs( string room = "auto" )
{
HearAs = (room ?? "").Trim().Equals( "auto", StringComparison.OrdinalIgnoreCase ) ? "" : room.Trim();
Cmd();
}
}