Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
6c1c65e
Remove singleton pattern from Services
valkyrienyanko Dec 2, 2025
be0af0d
Remove singleton pattern from AudioManager
valkyrienyanko Dec 2, 2025
11874f0
Remove singleton pattern from SceneManager
valkyrienyanko Dec 2, 2025
cd7d7c8
Remove singleton pattern from GameConsole
valkyrienyanko Dec 2, 2025
2bfcac8
Remove singleton pattern from MetricsOverlay
valkyrienyanko Dec 2, 2025
064a778
Remove singleton pattern from Profiler
valkyrienyanko Dec 2, 2025
5f57763
Adjust all references to refactored managers
valkyrienyanko Dec 2, 2025
9c4c419
Add static Game class for global managers
valkyrienyanko Dec 2, 2025
1df36bb
Refactor namespaces from GodotUtils to __TEMPLATE__
valkyrienyanko Dec 2, 2025
9f685a4
Improve SFX player cleanup in AudioManager
valkyrienyanko Dec 2, 2025
cee6516
Updated documentation for AudioManager.cs
valkyrienyanko Dec 2, 2025
3aedf8d
Refactor scene switching to use dedicated methods
valkyrienyanko Dec 2, 2025
395fd3a
Improve event cleanup on Button
valkyrienyanko Dec 2, 2025
50d2691
Update GodotUtils.dll
valkyrienyanko Dec 3, 2025
2c60df8
Create GodotUtils.xml
valkyrienyanko Dec 3, 2025
c591f87
Pool sfx players in audio manager for performance
valkyrienyanko Dec 3, 2025
a8f463e
Prefer [Export] over unique lookup with %
valkyrienyanko Dec 3, 2025
4301af4
Fire and forget on exit game
valkyrienyanko Dec 3, 2025
edf2202
Catch PreQuit subscriber exceptions
valkyrienyanko Dec 3, 2025
d11f2e1
Prefer [Export] over GetNode<T>(...)
valkyrienyanko Dec 3, 2025
de63340
Update GodotUtils.dll
valkyrienyanko Dec 3, 2025
8866296
Remove singleton pattern from Logger and more
valkyrienyanko Dec 3, 2025
197160a
Separate BBColor in its own file from Logger.cs
valkyrienyanko Dec 3, 2025
6d99181
Remove unused code in Logger
valkyrienyanko Dec 3, 2025
4441dc3
Improve readability of Logger by removing lines
valkyrienyanko Dec 3, 2025
51727c5
Create BBColor.cs.uid
valkyrienyanko Dec 3, 2025
5ac9b49
Call Quit() after Editor.Restart()
valkyrienyanko Dec 3, 2025
93b9a62
Search window title instead of process name
valkyrienyanko Dec 3, 2025
1b262b3
Revert from [Export] to GetNode due to Godot Bug
valkyrienyanko Dec 3, 2025
da1f81d
Fix crash: Assign popup menu console in _Ready()
valkyrienyanko Dec 3, 2025
fa8c97b
Remove unused code in PopupMenu and improve readability
valkyrienyanko Dec 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 85 additions & 67 deletions Framework/Autoloads/AudioManager.cs
Original file line number Diff line number Diff line change
@@ -1,122 +1,154 @@
using Godot;
using GodotUtils.UI;
using GodotUtils;
using System;
using System.Collections.Generic;
using System.Diagnostics;

namespace GodotUtils;
namespace __TEMPLATE__;

public class AudioManager : IDisposable
{
private const float MinDefaultRandomPitch = 0.8f;
private const float MaxDefaultRandomPitch = 1.2f;
private const float RandomPitchThreshold = 0.1f;
private const int MutedVolume = -80;
private const int MutedVolumeNormalized = -40;
private const float MinDefaultRandomPitch = 0.8f; // Default minimum pitch value for SFX.
private const float MaxDefaultRandomPitch = 1.2f; // Default maximum pitch value for SFX.
private const float RandomPitchThreshold = 0.1f; // Minimum difference in pitch between repeated sounds.
private const int MutedVolume = -80; // dB value representing mute.
private const int MutedVolumeNormalized = -40; // Normalized muted volume for volume mapping.

private static AudioManager _instance;
private AudioStreamPlayer _musicPlayer;
private ResourceOptions _options;
private Autoloads _autoloads;
private float _lastPitch;

private List<AudioStreamPlayer2D> _activeSfxPlayers = [];
private GodotNodePool<AudioStreamPlayer2D> _sfxPool;

/// <summary>
/// Initializes the AudioManager by attaching a music player to the given autoload node.
/// </summary>
public AudioManager(Autoloads autoloads)
{
if (_instance != null)
throw new InvalidOperationException($"{nameof(AudioManager)} was initialized already");

_instance = this;
_autoloads = autoloads;
_options = OptionsManager.GetOptions();
_options = Game.Options.GetOptions();

_musicPlayer = new AudioStreamPlayer();
autoloads.AddChild(_musicPlayer);

_sfxPool = new GodotNodePool<AudioStreamPlayer2D>(autoloads,
() => new AudioStreamPlayer2D());
}

/// <summary>
/// Frees all managed players and clears references for cleanup.
/// </summary>
public void Dispose()
{
_musicPlayer.QueueFree();
_activeSfxPlayers.Clear();

_instance = null;
_sfxPool.Clear();
}

public static void PlayMusic(AudioStream song, bool instant = true, double fadeOut = 1.5, double fadeIn = 0.5)
/// <summary>
/// Plays a music track, instantly or with optional fade between tracks. Music volume is in config scale (0-100).
/// </summary>
public void PlayMusic(AudioStream song, bool instant = true, double fadeOut = 1.5, double fadeIn = 0.5)
{
if (!instant && _instance._musicPlayer.Playing)
if (!instant && _musicPlayer.Playing)
{
// Slowly transition to the new song
PlayAudioCrossfade(_instance._musicPlayer, song, _instance._options.MusicVolume, fadeOut, fadeIn);
PlayAudioCrossfade(_musicPlayer, song, _options.MusicVolume, fadeOut, fadeIn);
}
else
{
// Instantly switch to the new song
PlayAudio(_instance._musicPlayer, song, _instance._options.MusicVolume);
PlayAudio(_musicPlayer, song, _options.MusicVolume);
}
}

/// <summary>
/// Plays a <paramref name="sound"/> at <paramref name="position"/>.
/// Plays a sound effect at the specified global position with randomized pitch to reduce repetition. Volume is normalized (0-100).
/// </summary>
/// <param name="parent"></param>
/// <param name="sound"></param>
public static void PlaySFX(AudioStream sound, Vector2 position, float minPitch = MinDefaultRandomPitch, float maxPitch = MaxDefaultRandomPitch)
public void PlaySFX(AudioStream sound, Vector2 position, float minPitch = MinDefaultRandomPitch, float maxPitch = MaxDefaultRandomPitch)
{
AudioStreamPlayer2D sfxPlayer = new()
{
Stream = sound,
VolumeDb = NormalizeConfigVolume(_instance._options.SFXVolume),
PitchScale = GetRandomPitch(minPitch, maxPitch)
};

sfxPlayer.Finished += () =>
{
sfxPlayer.QueueFree();
_instance._activeSfxPlayers.Remove(sfxPlayer);
};

_instance._autoloads.AddChild(sfxPlayer);
_instance._activeSfxPlayers.Add(sfxPlayer);
AudioStreamPlayer2D sfxPlayer = _sfxPool.Get();

sfxPlayer.GlobalPosition = position;
sfxPlayer.Stream = sound;
sfxPlayer.VolumeDb = NormalizeConfigVolume(_options.SFXVolume);
sfxPlayer.PitchScale = GetRandomPitch(minPitch, maxPitch);
sfxPlayer.Finished += OnFinished;
sfxPlayer.Play();

void OnFinished()
{
sfxPlayer.Finished -= OnFinished;
_sfxPool.Release(sfxPlayer);
}
}

public static void FadeOutSFX(double fadeTime = 1)
/// <summary>
/// Fades out all currently playing sound effects over the specified duration in seconds.
/// </summary>
public void FadeOutSFX(double fadeTime = 1)
{
foreach (AudioStreamPlayer2D sfxPlayer in _instance._activeSfxPlayers)
foreach (AudioStreamPlayer2D sfxPlayer in _sfxPool.ActiveNodes)
{
new GodotTween(sfxPlayer).Animate(AudioStreamPlayer.PropertyName.VolumeDb, MutedVolume, fadeTime);
}
}

public static void SetMusicVolume(float volume)
/// <summary>
/// Sets the music volume, affecting current playback. Volume is in config scale (0-100).
/// </summary>
public void SetMusicVolume(float volume)
{
_instance._musicPlayer.VolumeDb = NormalizeConfigVolume(volume);
_instance._options.MusicVolume = volume;
_musicPlayer.VolumeDb = NormalizeConfigVolume(volume);
_options.MusicVolume = volume;
}

public static void SetSFXVolume(float volume)
/// <summary>
/// Sets the SFX volume for all active sound effect players. Volume is in config scale (0-100).
/// </summary>
public void SetSFXVolume(float volume)
{
_instance._options.SFXVolume = volume;
_options.SFXVolume = volume;

float mappedVolume = NormalizeConfigVolume(volume);

foreach (AudioStreamPlayer2D sfxPlayer in _instance._activeSfxPlayers)
foreach (AudioStreamPlayer2D sfxPlayer in _sfxPool.ActiveNodes)
{
sfxPlayer.VolumeDb = mappedVolume;
}
}

/// <summary>
/// Generates a random pitch between min and max, avoiding values too similar to the previous sound.
/// </summary>
private float GetRandomPitch(float min, float max)
{
RandomNumberGenerator rng = new();
rng.Randomize();

float pitch = rng.RandfRange(min, max);

while (Mathf.Abs(pitch - _lastPitch) < RandomPitchThreshold)
{
rng.Randomize();
pitch = rng.RandfRange(min, max);
}

_lastPitch = pitch;
return pitch;
}

/// <summary>
/// Instantly plays the given audio stream with the specified player and volume.
/// </summary>
private static void PlayAudio(AudioStreamPlayer player, AudioStream song, float volume)
{
player.Stream = song;
player.VolumeDb = NormalizeConfigVolume(volume);
player.Play();
}

/// <summary>
/// Smoothly crossfades between songs by fading out the current and fading in the new one. Volume is in config scale (0-100).
/// </summary>
private static void PlayAudioCrossfade(AudioStreamPlayer player, AudioStream song, float volume, double fadeOut, double fadeIn)
{
new GodotTween(player)
Expand All @@ -126,25 +158,11 @@ private static void PlayAudioCrossfade(AudioStreamPlayer player, AudioStream son
.AnimateProp(NormalizeConfigVolume(volume), fadeIn).EaseIn();
}

/// <summary>
/// Maps a config volume value (0-100) to an AudioStreamPlayer VolumeDb value, returning mute if zero.
/// </summary>
private static float NormalizeConfigVolume(float volume)
{
return volume == 0 ? MutedVolume : volume.Remap(0, 100, MutedVolumeNormalized, 0);
}

private static float GetRandomPitch(float min, float max)
{
RandomNumberGenerator rng = new();
rng.Randomize();

float pitch = rng.RandfRange(min, max);

while (Mathf.Abs(pitch - _instance._lastPitch) < RandomPitchThreshold)
{
rng.Randomize();
pitch = rng.RandfRange(min, max);
}

_instance._lastPitch = pitch;
return pitch;
}
}
57 changes: 31 additions & 26 deletions Framework/Autoloads/Autoloads.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
using __TEMPLATE__.Debugging;
using __TEMPLATE__.UI;
using __TEMPLATE__.UI.Console;
using Godot;
using GodotUtils.UI;
using GodotUtils.UI.Console;
using GodotUtils;
using System;
using System.Threading.Tasks;

#if DEBUG
using GodotUtils.Debugging;
#endif
using System;
using System.Threading.Tasks;

namespace GodotUtils;
namespace __TEMPLATE__;

// Autoload
// Access this with GetNode<Autoloads>("/root/Autoloads")
Expand All @@ -19,17 +22,17 @@ public partial class Autoloads : Node

public static Autoloads Instance { get; private set; }

// Game developers should be able to access each individual manager
public ComponentManager ComponentManager { get; private set; }
public ComponentManager ComponentManager { get; private set; } // Cannot use [Export] here because Godot will bug out and unlink export path in editor after setup completes and restarts the editor
public GameConsole GameConsole { get; private set; } // Cannot use [Export] here because Godot will bug out and unlink export path in editor after setup completes and restarts the editor
public AudioManager AudioManager { get; private set; }
public OptionsManager OptionsManager { get; private set; }
public Services Services { get; private set; }
public MetricsOverlay MetricsOverlay { get; private set; }
public SceneManager SceneManager { get; private set; }
public GameConsole GameConsole { get; private set; }
public Profiler Profiler { get; private set; }

#if NETCODE_ENABLED
private Logger _logger;
public Logger Logger { get; private set; }
#endif

#if DEBUG
Expand All @@ -43,13 +46,14 @@ public override void _EnterTree()

Instance = this;
ComponentManager = GetNode<ComponentManager>("ComponentManager");
GameConsole = GetNode<GameConsole>("%Console");
SceneManager = new SceneManager(this, _scenes);
Services = new Services(this);
MetricsOverlay = new MetricsOverlay();
Profiler = new Profiler();
GameConsole = GetNode<GameConsole>("%Console");

#if NETCODE_ENABLED
_logger = new Logger(GameConsole);
Logger = new Logger(GameConsole);
#endif
}

Expand All @@ -76,7 +80,7 @@ public override void _Process(double delta)
#endif

#if NETCODE_ENABLED
_logger.Update();
Logger.Update();
#endif
}

Expand All @@ -85,11 +89,11 @@ public override void _PhysicsProcess(double delta)
MetricsOverlay.UpdatePhysics();
}

public override async void _Notification(int what)
public override void _Notification(int what)
{
if (what == NotificationWMCloseRequest)
{
await QuitAndCleanup();
ExitGame().FireAndForget();
}
}

Expand All @@ -98,32 +102,27 @@ public override void _ExitTree()
AudioManager.Dispose();
OptionsManager.Dispose();
SceneManager.Dispose();
Services.Dispose();
MetricsOverlay.Dispose();

#if DEBUG
_visualizeAutoload.Dispose();
#endif

#if NETCODE_ENABLED
_logger.Dispose();
Logger.Dispose();
#endif

Profiler.Dispose();

Instance = null;
}

// Using deferred is always complicated...
// I'm pretty sure Deferred must be called from a script that extends from Node
public void DeferredSwitchSceneProxy(string rawName, Variant transTypeVariant)
{
if (SceneManager.Instance == null)
return;

SceneManager.Instance.DeferredSwitchScene(rawName, transTypeVariant);
SceneManager.DeferredSwitchScene(rawName, transTypeVariant);
}

public async Task QuitAndCleanup()
public async Task ExitGame()
{
GetTree().AutoAcceptQuit = false;

Expand All @@ -132,10 +131,16 @@ public async Task QuitAndCleanup()
{
// Since the PreQuit event contains a Task only the first subscriber will be invoked
// with await PreQuit?.Invoke(); so need to ensure all subs are invoked.
Delegate[] invocationList = PreQuit.GetInvocationList();
foreach (Func<Task> subscriber in invocationList)
foreach (Func<Task> subscriber in PreQuit.GetInvocationList())
{
await subscriber();
try
{
await subscriber();
}
catch (Exception ex)
{
GD.PrintErr($"PreQuit subscriber failed: {ex}");
}
}
}

Expand Down
4 changes: 3 additions & 1 deletion Framework/Autoloads/Autoloads.tscn
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ Options = ExtResource("5_vm4c5")
Credits = ExtResource("2_xdmsn")
metadata/_custom_type_script = "uid://17yxgcswri77"

[node name="Autoloads" type="Node"]
[node name="Autoloads" type="Node" node_paths=PackedStringArray("GameConsole", "ComponentManager")]
process_mode = 3
script = ExtResource("1_00yjk")
_scenes = SubResource("Resource_xdmsn")
GameConsole = NodePath("Debug/Console")
ComponentManager = NodePath("ComponentManager")

[node name="ComponentManager" type="Node" parent="."]
process_mode = 1
Expand Down
Loading