using Sandbox.Audio; namespace Sandbox; /// /// Plays a soundscape when the listener enters the trigger area. /// [Expose] [Title( "Soundscape Trigger" )] [Category( "Rendering" )] [Icon( "surround_sound" )] [EditorHandle( "materials/gizmo/soundscape.png" )] [Tint( EditorTint.Green )] public class SoundscapeTrigger : Component { public enum TriggerType { /// /// Can be heard from anywhere. /// [Icon( "zoom_out_map" )] Point, /// /// Can be heard within a radius. /// [Icon( "radio_button_unchecked" )] Sphere, /// /// Can be heard within the bounds of a box. /// [Icon( "check_box_outline_blank" )] Box } /// /// Determines when/where the soundscape can be heard. /// [Property] public TriggerType Type { get; set; } [Property] public Soundscape Soundscape { get; set; } MixerHandle _targetMixer; /// /// The mixer that the soundscape will play on. /// [Property] public MixerHandle TargetMixer { get => _targetMixer; set { if ( value == _targetMixer ) return; foreach ( var entry in activeEntries ) { entry.TargetMixer = value; } _targetMixer = value; } } /// /// When true the soundscape will keep playing after exiting the area, and will /// only stop playing once another soundscape takes over. /// [Property] public bool StayActiveOnExit { get; set; } = true; float _volume = 1.0f; [Property] public float Volume { get => _volume; set { if ( value == _volume ) return; foreach ( var entry in activeEntries ) { entry.Volume = value; } _volume = value; } } /// /// The radius of the Soundscape when is set to . /// [Property] [ShowIf( nameof( Type ), TriggerType.Sphere )] public float Radius { get; set; } = 500.0f; Vector3 _scale = 50; /// /// The size of the Soundscape when is set to . /// [Property] [ShowIf( nameof( Type ), TriggerType.Box )] public Vector3 BoxSize { get => _scale; set { if ( _scale == value ) return; _scale = value; } } protected override void DrawGizmos() { if ( Type == TriggerType.Point ) { // nothing } else if ( Type == TriggerType.Sphere ) { if ( Gizmo.IsSelected ) { Gizmo.Draw.Color = Playing ? Gizmo.Colors.Active : Gizmo.Colors.Blue; Gizmo.Draw.LineSphere( 0, Radius ); } } else if ( Type == TriggerType.Box ) { if ( Gizmo.IsSelected ) { Gizmo.Draw.Color = Playing ? Gizmo.Colors.Active : Gizmo.Colors.Blue; Gizmo.Draw.LineBBox( new BBox( -BoxSize, BoxSize ) ); } } } public bool Playing { get; internal set; } bool wasPlaying; readonly List activeEntries = new(); readonly List removalList = new(); protected override void OnUpdate() { if ( Playing && !wasPlaying && Soundscape is not null ) { StartSoundscape( Soundscape ); } wasPlaying = Playing; if ( activeEntries.Count == 0 && removalList.Count == 0 ) return; UpdateEntries( Sound.Listener ); } protected override void OnDisabled() { Stop(); } protected override void OnDestroy() { Stop(); } private void Stop() { foreach ( var entry in activeEntries ) { entry.Dispose(); } activeEntries.Clear(); removalList.Clear(); Playing = false; wasPlaying = false; } void UpdateEntries( Transform head ) { foreach ( var e in activeEntries ) { e.Frame( head ); if ( !Playing ) e.Finished = true; if ( e.IsDead ) removalList.Add( e ); } foreach ( var e in removalList ) { e.Dispose(); activeEntries.Remove( e ); } } /// /// Return true if they should hear this soundscape when in this position /// public bool TestListenerPosition( Vector3 position ) { if ( Type == TriggerType.Sphere ) { return (position - WorldPosition).LengthSquared < (Radius * Radius); } else if ( Type == TriggerType.Box ) { return new BBox( -BoxSize, BoxSize ).Contains( WorldTransform.PointToLocal( position ) ); } return true; } /// /// Load and start this soundscape.. /// void StartSoundscape( Soundscape scape ) { foreach ( var e in activeEntries ) { e.Finished = true; } foreach ( var loop in scape.LoopedSounds ) StartLoopedSound( loop, scape.MasterVolume.GetValue(), Volume ); foreach ( var loop in scape.StingSounds ) StartStingSound( loop, scape.MasterVolume.GetValue(), Volume ); } void StartLoopedSound( Soundscape.LoopedSound sound, float internalVolume, float volume ) { if ( sound?.SoundFile == null ) return; foreach ( var entry in activeEntries.OfType() ) { if ( entry.TryUpdateFrom( sound, internalVolume, volume ) ) return; } var e = new LoopedSoundEntry( sound, internalVolume, volume ); e.TargetMixer = TargetMixer; activeEntries.Add( e ); } void StartStingSound( Soundscape.StingSound sound, float internalVolume, float volume ) { if ( sound.SoundFile == null ) return; for ( int i = 0; i < sound.InstanceCount; i++ ) { var e = new StingSoundEntry( sound, internalVolume, volume ); e.TargetMixer = TargetMixer; activeEntries.Add( e ); } } class PlayingSound : System.IDisposable { public MixerHandle TargetMixer; public float Volume = 1.0f; protected SoundHandle handle; protected float internalVolume = 1.0f; /// /// True if this sound has finished, can be removed /// internal virtual bool IsDead => !handle.IsValid() || (!handle.IsPlaying && Finished); /// /// Gets set when it's time to fade this out /// public bool Finished { get; set; } public virtual void Frame( in Transform head ) { } public virtual void Dispose() { handle?.Stop( 0.1f ); handle = default; } } sealed class LoopedSoundEntry : PlayingSound { /// /// We store the current volume so we can seamlessly fade in and out /// public float currentVolume = 0.0f; /// /// Consider us dead if the soundscape system thinks we're finished and our volume is low /// internal override bool IsDead => currentVolume <= 0.001f && Finished; Soundscape.LoopedSound source; float sourceVolume; float soundVelocity = 0.0f; public LoopedSoundEntry( Soundscape.LoopedSound sound, float internalVolume, float volume ) { currentVolume = 0.0f; this.internalVolume = internalVolume; Volume = volume; handle = Sound.PlayFile( sound.SoundFile ); UpdateFrom( sound ); } public override void Frame( in Transform head ) { if ( source?.SoundFile?.IsValid() == false ) { Finished = true; return; } var targetVolume = sourceVolume * internalVolume * Volume; if ( Finished ) targetVolume = 0.0f; currentVolume = MathX.SmoothDamp( currentVolume, targetVolume, ref soundVelocity, 5.0f, Time.Delta ); handle.Volume = currentVolume; handle.Position = head.Position; handle.TargetMixer = TargetMixer.GetOrDefault(); } public override string ToString() => $"Looped - Finished:{Finished} volume:{currentVolume:n0.00} - {source}"; /// /// If we're using the same sound file as this incoming sound, and we're on our way out.. then /// let it replace us instead. This is much nicer. /// public bool TryUpdateFrom( Soundscape.LoopedSound sound, float internalVolume, float volume ) { if ( !Finished ) return false; if ( sound.SoundFile != source.SoundFile ) return false; this.internalVolume = internalVolume; Volume = volume; UpdateFrom( sound ); return true; } void UpdateFrom( Soundscape.LoopedSound sound ) { source = sound; sourceVolume = sound.Volume.GetValue(); Finished = false; } } sealed class StingSoundEntry : PlayingSound { readonly Soundscape.StingSound source; TimeUntil timeUntilNextShot; internal override bool IsDead => Finished; public StingSoundEntry( Soundscape.StingSound sound, float internalVolume, float volume ) { source = sound; timeUntilNextShot = sound.RepeatTime.GetValue(); this.internalVolume = internalVolume; Volume = volume; } public override void Frame( in Transform head ) { if ( Finished ) return; if ( timeUntilNextShot > 0 ) return; if ( source?.SoundFile?.IsValid() == false ) { Finished = true; return; } timeUntilNextShot = source.RepeatTime.GetValue(); handle?.Stop( 0.1f ); handle = Sound.Play( source.SoundFile.ResourcePath ); handle.TargetMixer = TargetMixer.GetOrDefault(); // we'll make this shape more configurable, but right now bias x/y rather than up and down var randomOffset = new Vector3( Game.Random.Float( -10, 10 ), Game.Random.Float( -10, 10 ), Game.Random.Float( -1, 1 ) ); randomOffset = randomOffset.Normal * source.Distance.GetValue(); handle.Position = head.Position + randomOffset; handle.Volume *= internalVolume * Volume; } } }