@@ -12,6 +12,16 @@ signal volume_changed(bus_name: String, volume: float)
1212signal mute_toggled (bus_name : String , is_muted : bool )
1313# --------------------------------------------
1414
15+ # --- NEW: SFX CACHING & MANAGEMENT ---
16+ ## Base path for all UI sound effects.
17+ const SFX_DIR_PATH : String = "res://files/sounds/sfx/"
18+
19+ ## Hard cap for cached SFX streams to prevent unbounded memory growth.
20+ const MAX_SFX_CACHE_SIZE : int = 20
21+
22+ ## Number of reusable AudioStreamPlayers to keep in memory for UI sounds.
23+ const SFX_POOL_SIZE : int = 8
24+
1525@export_category ("Master Volume" )
1626@export var master_volume : float
1727@export var master_muted : bool
@@ -32,14 +42,29 @@ signal mute_toggled(bus_name: String, is_muted: bool)
3242
3343var current_config_path : String = Settings .CONFIG_PATH
3444
45+ # --- SFX CACHE STATE ---
46+ ## Dictionary to store preloaded AudioStreams to prevent disk I/O stutter.
47+ var _sfx_cache : Dictionary = {}
48+
49+ ## Dictionary acting as a set to track missing SFX and prevent repeated load attempts/log spam.
50+ var _missing_sfx_cache : Dictionary = {}
51+
52+ ## Array of pre-instantiated AudioStreamPlayers to prevent node instantiation churn.
53+ var _sfx_pool : Array [AudioStreamPlayer ] = []
54+
3555
3656func _ready () -> void :
3757 ## Initializes to defaults and loads/applies volumes.
38- ## :rtype: void
3958 _init_to_defaults () # Set to defaults from AudioConstants
4059 load_volumes () # Load persisted volumes (overrides defaults if saved)
4160 apply_all_volumes () # Apply to AudioServer buses
4261
62+ # Initialize the SFX object pool
63+ for i in range (SFX_POOL_SIZE ):
64+ var p := AudioStreamPlayer .new ()
65+ add_child (p )
66+ _sfx_pool .append (p )
67+
4368
4469## Initialize all volumes and mutes to defaults from AudioConstants
4570## :rtype: void
@@ -333,3 +358,81 @@ func reset_volumes() -> void:
333358 apply_all_volumes ()
334359 save_volumes ()
335360 Globals .log_message ("Audio volumes reset to defaults." , Globals .LogLevel .DEBUG )
361+
362+
363+ ## Centralized SFX Playback API (Issue #565)
364+ ## Handles non-positional audio with LRU caching and auto-cleanup.
365+ ## :param sfx_name: The filename without extension (e.g., "slider").
366+ ## :param bus_name: Target audio bus (defaults to SFX_Menu).
367+ ## :param pitch_scale: Pitch override for variety.
368+ ## :param volume_db: Volume offset in decibels.
369+ func play_sfx (
370+ sfx_name : String ,
371+ bus_name : String = AudioConstants .BUS_SFX_MENU ,
372+ pitch_scale : float = 1.0 ,
373+ volume_db : float = 0.0
374+ ) -> void :
375+ if sfx_name .is_empty ():
376+ return
377+
378+ # Short-circuit: If we already know this file is missing, do not attempt to load it again.
379+ if _missing_sfx_cache .has (sfx_name ):
380+ return
381+
382+ # 1. Resolve and Cache the AudioStream (with LRU Eviction)
383+ if not _sfx_cache .has (sfx_name ):
384+ var full_path : String = SFX_DIR_PATH + sfx_name + ".wav"
385+ var stream : AudioStream = load (full_path )
386+
387+ if stream :
388+ # Eviction strategy: If cache is full, remove the oldest (first) entry
389+ if _sfx_cache .size () >= MAX_SFX_CACHE_SIZE :
390+ var oldest_key : String = _sfx_cache .keys ()[0 ]
391+ _sfx_cache .erase (oldest_key )
392+ Globals .log_message (
393+ "SFX cache full. Evicted: " + oldest_key , Globals .LogLevel .DEBUG
394+ )
395+
396+ _sfx_cache [sfx_name ] = stream
397+ else :
398+ Globals .log_message (
399+ "SFX file not found or failed to load: " + full_path , Globals .LogLevel .WARNING
400+ )
401+ # Cache the failure so we don't spam the disk and logs on subsequent requests
402+ _missing_sfx_cache [sfx_name ] = true
403+ return
404+ else :
405+ # LRU Update: Godot 4 Dictionaries preserve insertion order.
406+ # By erasing and re-inserting, we push this active sound to the "newest" end of the dictionary.
407+ var stream : AudioStream = _sfx_cache [sfx_name ]
408+ _sfx_cache .erase (sfx_name )
409+ _sfx_cache [sfx_name ] = stream
410+
411+ # 2. Grab an available player from the object pool
412+ var player : AudioStreamPlayer = null
413+ for p : AudioStreamPlayer in _sfx_pool :
414+ if not p .playing :
415+ player = p
416+ break
417+
418+ # Fallback: If all players are busy, hijack the first one in the pool
419+ # to prevent dropping the new sound entirely.
420+ if player == null :
421+ player = _sfx_pool [0 ]
422+
423+ player .stream = _sfx_cache [sfx_name ]
424+ player .pitch_scale = pitch_scale
425+ player .volume_db = volume_db
426+
427+ # 3. Bus Validation & Routing
428+ if AudioServer .get_bus_index (bus_name ) == - 1 :
429+ Globals .log_message (
430+ "Invalid bus '%s ' requested for SFX. Falling back to SFX_Menu." % bus_name ,
431+ Globals .LogLevel .WARNING
432+ )
433+ player .bus = AudioConstants .BUS_SFX_MENU
434+ else :
435+ player .bus = bus_name
436+
437+ # 4. Play (No queue_free needed since we reuse the nodes)
438+ player .play ()
0 commit comments