From b883338396a6d012e3309b12f86ca91fdab3c0a8 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:18:48 +0100 Subject: [PATCH] feat(audio): add procedural gameplay sound foundation --- Game/project.godot | 1 + Game/scripts/audio_manager.gd | 71 ++++++++++++++++++++++++++ Game/scripts/game_mode.gd | 1 + Game/scripts/match_mode.gd | 2 + Game/tests/cases/test_audio_manager.gd | 17 ++++++ TODO.md | 2 +- multiplayer-next.md | 2 + 7 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 Game/scripts/audio_manager.gd create mode 100644 Game/tests/cases/test_audio_manager.gd diff --git a/Game/project.godot b/Game/project.godot index 2dc4cb94..f06d3780 100644 --- a/Game/project.godot +++ b/Game/project.godot @@ -35,6 +35,7 @@ NetworkManager="*res://scripts/network_manager.gd" MatchNet="*res://scripts/match_net.gd" MatchSim="*res://scripts/match_sim.gd" NetDebugOverlay="*res://scripts/net_debug_overlay.gd" +AudioManager="*res://scripts/audio_manager.gd" [display] diff --git a/Game/scripts/audio_manager.gd b/Game/scripts/audio_manager.gd new file mode 100644 index 00000000..cdd312dd --- /dev/null +++ b/Game/scripts/audio_manager.gd @@ -0,0 +1,71 @@ +extends Node + +# Dependency-free audio foundation. Authored assets can replace these tones +# later without changing gameplay call sites or the multiplayer event flow. + +const SAMPLE_RATE := 44100 +const MAX_INTENSITY := 1.0 + +var enabled := true + + +func play_ui_click() -> void: + _play_tone(880.0, 0.045, 0.10) + + +func play_countdown(count: int) -> void: + if count <= 0: + _play_tone(1046.5, 0.12, 0.18) + else: + _play_tone(countdown_frequency(count), 0.08, 0.14) + + +func play_impact(intensity: float) -> void: + var amount := clamp_intensity(intensity) + if amount <= 0.0: + return + _play_tone(150.0 + 180.0 * amount, 0.06 + 0.08 * amount, 0.08 + 0.18 * amount) + + +func play_goal() -> void: + _play_tone(523.25, 0.22, 0.22) + _play_tone(783.99, 0.30, 0.18) + + +static func clamp_intensity(value: float) -> float: + if not is_finite(value): + return 0.0 + return clampf(value, 0.0, MAX_INTENSITY) + + +static func countdown_frequency(count: int) -> float: + return 440.0 + float(clampi(count, 1, 9)) * 55.0 + + +func _play_tone(frequency: float, duration: float, volume: float) -> void: + if not enabled or frequency <= 0.0 or duration <= 0.0 or volume <= 0.0: + return + var stream := AudioStreamWAV.new() + stream.format = AudioStreamWAV.FORMAT_16_BITS + stream.mix_rate = SAMPLE_RATE + stream.stereo = false + stream.data = _tone_data(frequency, duration, volume) + var player := AudioStreamPlayer.new() + player.stream = stream + add_child(player) + player.finished.connect(player.queue_free) + player.play() + + +func _tone_data(frequency: float, duration: float, volume: float) -> PackedByteArray: + var frames := maxi(1, int(duration * SAMPLE_RATE)) + var data := PackedByteArray() + data.resize(frames * 2) + for index in frames: + var envelope := minf(1.0, float(index) / 256.0) * minf(1.0, float(frames - index) / 1024.0) + var sample := int(sin(TAU * frequency * float(index) / SAMPLE_RATE) * volume * envelope * 32767.0) + if sample < 0: + sample += 65536 + data[index * 2] = sample & 0xff + data[index * 2 + 1] = (sample >> 8) & 0xff + return data diff --git a/Game/scripts/game_mode.gd b/Game/scripts/game_mode.gd index 2b8a9986..33a4c578 100644 --- a/Game/scripts/game_mode.gd +++ b/Game/scripts/game_mode.gd @@ -136,6 +136,7 @@ func _play_goal_celebration(scoring_team: int, conceding_team: int) -> void: # real-time presentation delay between episodes. if DisplayServer.get_name() == "headless" or not is_instance_valid(_camera_rig): return + AudioManager.play_goal() var goal_position := Vector3.ZERO for goal in arena.get_goals(): if goal.team == conceding_team: diff --git a/Game/scripts/match_mode.gd b/Game/scripts/match_mode.gd index 13465e32..1ad59216 100644 --- a/Game/scripts/match_mode.gd +++ b/Game/scripts/match_mode.gd @@ -122,6 +122,7 @@ func _run_kickoff_countdown() -> void: _set_frozen(true) for count in range(KICKOFF_COUNTDOWN_SECONDS, 0, -1): kickoff_countdown.emit(count) + AudioManager.play_countdown(count) # process_always=false: if full-time fires mid-countdown (see # _on_match_timer_timeout's get_tree().paused = true), this stalls # harmlessly in lockstep with the pause instead of ticking a @@ -132,6 +133,7 @@ func _run_kickoff_countdown() -> void: if _match_over: return kickoff_countdown.emit(0) + AudioManager.play_countdown(0) _set_frozen(false) diff --git a/Game/tests/cases/test_audio_manager.gd b/Game/tests/cases/test_audio_manager.gd new file mode 100644 index 00000000..f1b3f3c2 --- /dev/null +++ b/Game/tests/cases/test_audio_manager.gd @@ -0,0 +1,17 @@ +extends "res://tests/test_case.gd" + +const AudioManager = preload("res://scripts/audio_manager.gd") + + +func test_audio_intensity_fails_closed_and_clamps() -> void: + assert_eq(AudioManager.clamp_intensity(-1.0), 0.0, "negative impact is silent") + assert_eq(AudioManager.clamp_intensity(INF), 0.0, "infinite impact is silent") + assert_eq(AudioManager.clamp_intensity(0.5), 0.5, "normal impact is retained") + assert_eq(AudioManager.clamp_intensity(4.0), 1.0, "oversized impact is capped") + + +func test_countdown_frequency_has_bounded_monotonic_mapping() -> void: + assert_eq(AudioManager.countdown_frequency(0), 495.0, "zero uses the first safe tone") + assert_eq(AudioManager.countdown_frequency(3), 605.0, "countdown tone is deterministic") + assert_eq(AudioManager.countdown_frequency(99), 935.0, "large countdown values are capped") + assert_true(AudioManager.countdown_frequency(2) < AudioManager.countdown_frequency(3), "countdown tones rise predictably") diff --git a/TODO.md b/TODO.md index 8b79f2b5..2b3aa020 100644 --- a/TODO.md +++ b/TODO.md @@ -14,7 +14,7 @@ The training pipeline is built — see `TRAINING.md` (self-play PPO via the vend The largest gap between this and a AAA-feeling product is presentation, not code. Sequenced after the above for pragmatic reasons, but this is the highest impact per hour. -- [ ] **Audio — there is none.** Zero sound files, zero `AudioStreamPlayer` nodes, no bus layout. Needs: engine hum pitched to throttle, turbo whoosh, ball impacts scaled by collision impulse, wall scrapes, goal explosion, crowd bed, UI clicks, countdown beeps, music. Can be driven off `Ship`'s existing telemetry signals. +- [ ] **Audio — authored sound design remains open.** A dependency-free procedural `AudioManager` now provides safe UI/countdown/impact/goal hooks and is wired into kickoff and goal events; replace the placeholder tones with engine hum pitched to throttle, turbo whoosh, impulse-scaled ball impacts, wall scrapes, goal explosion, crowd bed, UI clicks, and music after selecting distributable assets and mixing them on real hardware. - [ ] Custom font + a real `Theme` resource for the HUD. `ThemeDB.fallback_font` at 10-13 px reads as a debug overlay. - [ ] **Video settings menu is missing graphics presets, vsync, and resolution scaling.** `video_settings.gd` currently exposes only AA, glow, and brightness, while SDFGI, SSIL, SSAO, and five shadow-casting lights are on by default and unreachable by the player. Blocked on the same profiling gate as the multiplayer section's 0.16–0.28 tasks below (0.17/0.17b/0.17c/0.17d) — needs a human at the editor with real hardware, not further code changes on its own. diff --git a/multiplayer-next.md b/multiplayer-next.md index fd9313c0..3e082a8d 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1626,3 +1626,5 @@ Matchmaking now explains queue progress (including bounded skill widening while Signed MatchNet claims now also require exact JSON string/integer types for every identity, protocol, expiry, slot, team, and generation field; string-number coercion is rejected before canonical signature verification. Presentation progress: a shared `Game/themes/cosmic_clash_theme.tres` now gives the menu, lobby, matchmaking, and settings surfaces consistent button, input, option, and label styling. The custom-font portion of `TODO.md` remains open until a distributable font asset is selected. + +The audio TODO now has a runtime foundation: `AudioManager` generates bounded placeholder tones for kickoff countdowns, impacts, goals, and future UI hooks without adding binary assets; authored sound design, engine telemetry mixing, and production audio QA remain open.