Skip to content

Commit 685ce08

Browse files
authored
Merge pull request #578 from ikostan/SFX
Implement a dedicated slider.wav sound effect that triggers only during manual slider adjustments
2 parents 7eb7e8c + 12c21c3 commit 685ce08

15 files changed

Lines changed: 852 additions & 38 deletions

.all-contributorsrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@
66
"files": ["README.md"],
77
"imageSize": 100,
88
"contributorsPerLine": 7,
9+
"contributors": [],
910
"badgeTemplate": "[<image-card alt=\"All Contributors\" src=\"https://img.shields.io/github/all-contributors/<%= projectOwner %>/<%= projectName %>?color=ee8449&style=flat-square\" ></image-card>](<%= contributorsUrl %>)"
1011
}

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ You can play this game on [Itch.io](https://ikostan.itch.io/sky-lock-assault)
7777
- [Release Drafter](https://github.com/release-drafter/release-drafter?tab=readme-ov-file#readme)
7878
- [Close Stale Issues and PRs](https://github.com/actions/stale)
7979
- [AllContributors GitHub App](https://allcontributors.org/docs/en/bot/installation)
80+
- [DeepSource](https://github.com/deepsource)
8081
9. [Free Web Browser Game Deployment Platforms](files/docs/Platforms_for_Web_Deployment_Guide.md)
8182
<!-- markdownlint-enable line-length -->
8283

@@ -145,6 +146,21 @@ these GPL requirements, a separate license is available upon request.
145146
- Observer-based Settings System: Centralized GameSettingsResource that handles
146147
automatic persistence and UI synchronization through signals.
147148

149+
150+
### Project Structure (`scripts/`)
151+
152+
Post-Refactor Phase 4 (PR `#582`), the root `scripts/` directory has been fully
153+
reorganised into purpose-specific sub-directories:
154+
<!-- markdownlint-disable MD013 -->
155+
| Directory | Contents |
156+
|----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
157+
| `scripts/core/` | Foundational systems: `game_paths.gd` (centralized path registry), `globals.gd`, `main_scene.gd`, `settings.gd` |
158+
| `scripts/resources/` | Data containers & configuration: `game_settings_resource.gd`, `audio_constants.gd` |
159+
| `scripts/entities/` | Game objects: `player.gd`, `bullet.gd`, `weapon.gd` |
160+
| `scripts/system/` | Platform wrappers & integrations: `audio_web_bridge.gd`, `JavaScriptBridgeWrapper.gd`, `OSWrapper.gd` |
161+
| `scripts/managers/` | Game-loop managers: `audio_manager.gd`, `parallax_manager.gd`, `resource_preloader.gd` |
162+
| `scripts/ui/` | Interface layer: `hud.gd`; sub-dirs `menus/` (main, pause, options, audio, gameplay, key-mapping, advanced), `screens/` (splash, loading), `components/` (volume slider, input remap button) |
163+
<!-- markdownlint-enable MD013 -->
148164
---
149165

150166
## 🟢 Current Development Status
@@ -289,6 +305,19 @@ to user input devices:
289305
- Modifier-aware remapping requires explicit key+modifier press for
290306
unique bindings.
291307

308+
### Milestone 14
309+
310+
**Status:** Stable gameplay loop with synced UI systems and GUT-based
311+
unit testing.
312+
**Active Focus:** Gameplay expansion (AI enemies, multiplayer, levels).
313+
**Version:** v0.9.18
314+
315+
### Milestone 16
316+
317+
**Status:** Stable gameplay loop with fully refactored script architecture,
318+
synced UI systems, and GUT-based unit testing.
319+
**Active Focus:** Gameplay expansion (AI enemies, multiplayer, levels).
320+
292321
Track progress via [Milestones](https://github.com/ikostan/SkyLockAssault/milestones).
293322

294323
---

files/sounds/sfx/slider.wav

39.9 KB
Binary file not shown.

files/sounds/sfx/slider.wav.import

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[remap]
2+
3+
importer="wav"
4+
type="AudioStreamWAV"
5+
uid="uid://6asmf6p6ftp5"
6+
path="res://.godot/imported/slider.wav-562467666e90bfee2364ab6985972073.sample"
7+
8+
[deps]
9+
10+
source_file="res://files/sounds/sfx/slider.wav"
11+
dest_files=["res://.godot/imported/slider.wav-562467666e90bfee2364ab6985972073.sample"]
12+
13+
[params]
14+
15+
force/8_bit=false
16+
force/mono=false
17+
force/max_rate=false
18+
force/max_rate_hz=44100
19+
edit/trim=false
20+
edit/normalize=false
21+
edit/loop_mode=0
22+
edit/loop_begin=0
23+
edit/loop_end=-1
24+
compress/mode=2

scripts/core/globals.gd

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,8 @@ static func set_game_version_for_tests(value: String) -> void:
377377
## Use _input instead of _unhandled_input to catch events BEFORE the UI consumes them.
378378
func _input(_event: InputEvent) -> void:
379379
# The Ultimate Menu Check: Does a UI element currently have keyboard/gamepad focus?
380-
var ui_has_focus: bool = is_instance_valid(get_viewport().gui_get_focus_owner())
380+
var focus_owner: Control = get_viewport().gui_get_focus_owner()
381+
var ui_has_focus: bool = is_instance_valid(focus_owner)
381382

382383
# Gate 1: Only play UI sounds if a UI element is focused OR we are in a known menu state
383384
var is_menu_context: bool = (
@@ -392,6 +393,11 @@ func _input(_event: InputEvent) -> void:
392393
# We use the global Input singleton here because it perfectly handles
393394
# analog joystick deadzone debouncing, which event.is_echo() misses.
394395
if Input.is_action_just_pressed(action):
396+
# NEW: Prevent double-audio when adjusting sliders.
397+
# If a slider has focus, left/right adjusts the value instead of navigating.
398+
if focus_owner is Slider and (action == "ui_left" or action == "ui_right"):
399+
return
400+
395401
_play_ui_navigation_sfx()
396402
return # Exit once sound is triggered to avoid double-plays
397403

scripts/managers/audio_manager.gd

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@ signal volume_changed(bus_name: String, volume: float)
1212
signal 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

3343
var 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

3656
func _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()

scripts/resources/audio_constants.gd

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,19 @@
77

88
extends Node
99

10+
# --- Audio Bus Names ---
1011
const BUS_MASTER: String = "Master"
1112
const BUS_MUSIC: String = "Music"
1213
const BUS_SFX: String = "SFX"
1314
const BUS_SFX_ROTORS: String = "SFX_Rotors"
1415
const BUS_SFX_WEAPON: String = "SFX_Weapon"
1516
const BUS_SFX_MENU: String = "SFX_Menu"
1617

18+
# --- SFX Asset IDs ---
19+
const SFX_SLIDER: String = "slider"
20+
const SFX_MUTE_TOGGLE: String = "mute_toggle" # For future CheckButton task
21+
const SFX_UI_NAVIGATION: String = "ui_navigation"
22+
1723
# Centralized config with defaults and var mappings
1824
const BUS_CONFIG: Dictionary = {
1925
BUS_MASTER:

0 commit comments

Comments
 (0)