diff --git a/Game/scripts/audio_manager.gd b/Game/scripts/audio_manager.gd index f9d10797..dde30860 100644 --- a/Game/scripts/audio_manager.gd +++ b/Game/scripts/audio_manager.gd @@ -7,6 +7,7 @@ const SAMPLE_RATE := 44100 const MAX_INTENSITY := 1.0 var enabled := true +var _engine_player: AudioStreamPlayer func bind_tree_buttons(root: Node) -> void: @@ -47,6 +48,35 @@ func play_goal() -> void: _play_tone(783.99, 0.30, 0.18) +func set_engine_state(thrust: float, turbo: bool) -> void: + var amount := clamp_intensity(thrust) + if not enabled or amount <= 0.01: + stop_engine() + return + if _engine_player == null or not is_instance_valid(_engine_player): + _engine_player = AudioStreamPlayer.new() + _engine_player.stream = _engine_stream() + add_child(_engine_player) + _engine_player.play() + _engine_player.pitch_scale = engine_pitch(amount, turbo) + _engine_player.volume_db = linear_to_db(engine_volume(amount, turbo)) + + +func stop_engine() -> void: + if _engine_player != null and is_instance_valid(_engine_player): + _engine_player.stop() + + +static func engine_pitch(thrust: float, turbo: bool) -> float: + var amount := clamp_intensity(thrust) + return 0.75 + amount * 0.55 + (0.30 if turbo and amount > 0.01 else 0.0) + + +static func engine_volume(thrust: float, turbo: bool) -> float: + var amount := clamp_intensity(thrust) + return clampf(0.015 + amount * 0.045 + (0.025 if turbo and amount > 0.01 else 0.0), 0.0, 0.1) + + static func clamp_intensity(value: float) -> float: if not is_finite(value): return 0.0 @@ -72,6 +102,17 @@ func _play_tone(frequency: float, duration: float, volume: float) -> void: player.play() +func _engine_stream() -> AudioStreamWAV: + var stream := AudioStreamWAV.new() + stream.format = AudioStreamWAV.FORMAT_16_BITS + stream.mix_rate = SAMPLE_RATE + stream.stereo = false + stream.loop_mode = AudioStreamWAV.LOOP_FORWARD + stream.data = _tone_data(92.0, 1.0, 0.65) + stream.loop_end = SAMPLE_RATE + return stream + + func _tone_data(frequency: float, duration: float, volume: float) -> PackedByteArray: var frames := maxi(1, int(duration * SAMPLE_RATE)) var data := PackedByteArray() diff --git a/Game/scripts/ship_camera.gd b/Game/scripts/ship_camera.gd index 1533a7b0..9f81d810 100644 --- a/Game/scripts/ship_camera.gd +++ b/Game/scripts/ship_camera.gd @@ -100,6 +100,7 @@ func _connect_target() -> void: func _exit_tree() -> void: + AudioManager.stop_engine() if is_instance_valid(target) and target.ball_contact.is_connected(_on_target_ball_contact): target.ball_contact.disconnect(_on_target_ball_contact) @@ -218,6 +219,8 @@ func _smooth_look_at(point: Vector3, delta: float) -> void: func _update_speed_feel(delta: float) -> void: var feel_t := 1.0 - exp(-feel_smoothing * delta) var turbo_target := 1.0 if target.is_turbo_active() else 0.0 + var engine_action := target.get_current_action_copy() + AudioManager.set_engine_state(maxf(engine_action.thrust.z, 0.0), turbo_target > 0.5) _turbo_blend = lerpf(_turbo_blend, turbo_target, feel_t) _speed_blend = lerpf(_speed_blend, target.get_speed_ratio(), feel_t) var target_fov := base_fov + _speed_blend * speed_fov_add + _turbo_blend * turbo_fov_kick diff --git a/Game/tests/cases/test_audio_manager.gd b/Game/tests/cases/test_audio_manager.gd index ca3d93aa..53be9884 100644 --- a/Game/tests/cases/test_audio_manager.gd +++ b/Game/tests/cases/test_audio_manager.gd @@ -25,3 +25,10 @@ func test_button_binding_is_idempotent() -> void: assert_eq(button.pressed.get_connections().size(), 1, "UI click hook is not duplicated") button.free() manager.free() + + +func test_engine_mix_is_bounded_and_turbo_is_audible() -> void: + assert_eq(AudioManager.engine_pitch(-1.0, false), 0.75, "negative thrust uses the idle pitch") + assert_true(AudioManager.engine_pitch(1.0, true) > AudioManager.engine_pitch(1.0, false), "turbo raises engine pitch") + assert_true(AudioManager.engine_volume(1.0, true) > AudioManager.engine_volume(1.0, false), "turbo raises engine volume") + assert_true(AudioManager.engine_volume(100.0, true) <= 0.1, "engine volume remains bounded") diff --git a/TODO.md b/TODO.md index 2b3aa020..39fe474a 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 — 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. +- [ ] **Audio — authored sound design remains open.** A dependency-free procedural `AudioManager` now provides safe UI/countdown/impact/goal hooks, an engine tone pitched/levelled from local thrust and turbo state, and is wired into kickoff, goal, ball-contact, and menu events; replace the placeholder tones with authored engine/turbo/impact/wall/goal/crowd/music assets after selecting distributable files 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 3cdfb872..562d0e21 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1627,4 +1627,4 @@ Signed MatchNet claims now also require exact JSON string/integer types for ever 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, camera-reported ball impacts, goals, and UI clicks without adding binary assets; authored sound design, engine telemetry mixing, and production audio QA remain open. +The audio TODO now has a runtime foundation: `AudioManager` generates bounded placeholder tones for kickoff countdowns, camera-reported ball impacts, goals, UI clicks, and a local thrust/turbo-pitched engine loop without adding binary assets; authored sound design and production audio QA remain open.