diff --git a/client/default_bus_layout.tres b/client/default_bus_layout.tres new file mode 100644 index 000000000..ebe4e8c59 --- /dev/null +++ b/client/default_bus_layout.tres @@ -0,0 +1,43 @@ +[gd_resource type="AudioBusLayout" format=3] + +; D-068: 5-bus audio architecture — Music, Ambient, WorldSFX, PlayerActions, UISounds. +; All buses route to Master. Volumes managed at runtime by AudioManager autoload. +; AudioManager._setup_buses() creates any missing buses on startup (no-op if present). + +[resource] +bus/0/name = "Master" +bus/0/solo = false +bus/0/mute = false +bus/0/bypass_fx = false +bus/0/volume_db = 0.0 +bus/0/send = &"" +bus/1/name = "Music" +bus/1/solo = false +bus/1/mute = false +bus/1/bypass_fx = false +bus/1/volume_db = 0.0 +bus/1/send = &"Master" +bus/2/name = "Ambient" +bus/2/solo = false +bus/2/mute = false +bus/2/bypass_fx = false +bus/2/volume_db = 0.0 +bus/2/send = &"Master" +bus/3/name = "WorldSFX" +bus/3/solo = false +bus/3/mute = false +bus/3/bypass_fx = false +bus/3/volume_db = 0.0 +bus/3/send = &"Master" +bus/4/name = "PlayerActions" +bus/4/solo = false +bus/4/mute = false +bus/4/bypass_fx = false +bus/4/volume_db = 0.0 +bus/4/send = &"Master" +bus/5/name = "UISounds" +bus/5/solo = false +bus/5/mute = false +bus/5/bypass_fx = false +bus/5/volume_db = 0.0 +bus/5/send = &"Master" diff --git a/client/project.godot b/client/project.godot index af0fb088e..d87dd633c 100644 --- a/client/project.godot +++ b/client/project.godot @@ -24,6 +24,10 @@ UIStrings="*res://scripts/autoloads/ui_strings.gd" FogState="*res://scripts/autoloads/fog_state.gd" AudioManager="*res://scripts/autoloads/audio_manager.gd" +[audio] + +buses/default_bus_layout="res://default_bus_layout.tres" + [gui] theme/custom="res://assets/theme/game_theme.tres" diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index fa52fcf7d..c77b4e6b7 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=21 format=3 uid="uid://bswrmh7w8dbgm"] +[gd_scene load_steps=22 format=3 uid="uid://bswrmh7w8dbgm"] [ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] [ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"] @@ -20,6 +20,7 @@ [ext_resource type="PackedScene" path="res://ui/gauntlet_hud.tscn" id="17_gauntlet"] [ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"] [ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"] +[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"] [node name="Game" type="Node2D"] script = ExtResource("1_main") @@ -162,3 +163,6 @@ layer = 30 ; #495: WRONG button (F12) — bug report capture dialog [node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("19_bugreport")] + +; #528: Audio settings dialog — 5-bus volume sliders, ESC/OPEN_MENU to toggle +[node name="SettingsDialog" parent="ModalLayer" instance=ExtResource("21_settings")] diff --git a/client/scripts/autoloads/audio_manager.gd b/client/scripts/autoloads/audio_manager.gd index 3fc0f64be..ba35c5b20 100644 --- a/client/scripts/autoloads/audio_manager.gd +++ b/client/scripts/autoloads/audio_manager.gd @@ -41,6 +41,18 @@ const DIP_SPECS := { # Low-pass filter default cutoff — effectively bypassed at this value. const FILTER_CUTOFF_DEFAULT := 20500.0 +# --- D-073: Zone crossfade constants --- +const CROSSFADE_DURATION := 1.8 # D-073: 1.5-2s spec, mid-range + +# Maps server zone_id strings to ambient asset keys (filenames in res://audio/). +# Hub and Workplace share the same ambient layer — same location type. +const ZONE_ASSETS: Dictionary = { + "hub": "amb_hub_layer", + "workplace": "amb_hub_layer", + "bar": "amb_bar_layer", + "corridor": "amb_corridor_layer", +} + # Asset registry: filename stem (e.g. "amb_station_base") → AudioStream var _registry: Dictionary = {} @@ -57,12 +69,19 @@ var _ambient_filter: AudioEffectLowPassFilter = null # Ambient loop players keyed by asset_key (D-073 zone crossfade) var _ambient_players: Dictionary = {} +# D-073: Zone crossfade state +var _current_zone_id: String = "" +var _zone_tweens: Array = [] + signal dip_changed(profile: String) +const PREFS_PATH := "user://audio_prefs.cfg" + func _ready() -> void: _setup_buses() _scan_registry() + _load_prefs() # --- Bus setup --- @@ -173,9 +192,8 @@ func stop_all_loops() -> void: # --- Audio asset registry: event type → asset key (D-018, #125) --- # Maps server-sent sound event_type strings to audio asset keys. # Keys match filename stems in res://audio/ (scanned by _scan_registry). -# Audio assets per D-038: footstep variants (walk / run). -# No asset for Voice events in v0.1 — play_sound_event no-ops gracefully -# (D-038: "renders as audio if asset exists, or visual indicator + monologue if not"). +# Audio assets per D-038: footstep variants (walk / run), NPC murmur (D-072, #532). +# Missing assets no-op gracefully (D-038 fallback pattern). const SOUND_EVENT_ASSETS: Dictionary = { "Footstep": "sfx_footstep_metal_walk", "FootstepWalk": "sfx_footstep_metal_walk", @@ -183,6 +201,10 @@ const SOUND_EVENT_ASSETS: Dictionary = { "FootstepCrouch": "sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands "FootstepSprint": "sfx_footstep_metal_run", "FootstepRun": "sfx_footstep_metal_run", + # D-072 (#533): NPC conversation murmur — single universal asset, World SFX bus. + # Zone ambient provides natural conspicuousness (bar buries it, corridor exposes it). + "Voice": "sfx_npc_murmur", + "VoiceConversation": "sfx_npc_murmur", } @@ -304,19 +326,85 @@ func set_volume(bus: String, volume_db: float) -> void: var buses: Dictionary = spec.get("buses", {}) var offset_db: float = buses.get(bus, 0.0) AudioServer.set_bus_volume_db(idx, volume_db + offset_db) + _save_prefs() func get_volume(bus: String) -> float: return _bus_volumes.get(bus, 0.0) -# --- Zone crossfade (D-073 stub) --- +# --- Volume persistence (user://audio_prefs.cfg) --- + +func _load_prefs() -> void: + var cfg := ConfigFile.new() + if cfg.load(PREFS_PATH) != OK: + return + for bus_name in BUSES: + if cfg.has_section_key("audio", bus_name): + var db: float = cfg.get_value("audio", bus_name, 0.0) + # Apply directly: bypass _save_prefs() on initial load. + _bus_volumes[bus_name] = db + var idx := AudioServer.get_bus_index(bus_name) + if idx >= 0: + AudioServer.set_bus_volume_db(idx, db) + + +func _save_prefs() -> void: + var cfg := ConfigFile.new() + for bus_name in BUSES: + cfg.set_value("audio", bus_name, _bus_volumes.get(bus_name, 0.0)) + var err := cfg.save(PREFS_PATH) + if err != OK: + push_warning("AudioManager: failed to save prefs to %s (error %d)" % [PREFS_PATH, err]) + + +# --- Zone crossfade (D-073) --- ## Handle zone transition. Server sends zone_id per tile in ObserverSnapshot. -## Full crossfade implementation deferred to Sprint 9+ (D-073). -## Stub exists so server integration can call it without conditional checks. -func set_zone(_zone_id: String) -> void: - pass +## Hard boundary trigger with 1.5-2s audio crossfade between ambient layers. +## Interruptible — mid-crossfade zone change tweens from current position. +## No-op if assets absent (D-038) or same zone. +func set_zone(zone_id: String) -> void: + if zone_id == _current_zone_id: + return + var new_asset: String = ZONE_ASSETS.get(zone_id, "") + var old_asset: String = ZONE_ASSETS.get(_current_zone_id, "") + _current_zone_id = zone_id + _kill_zone_tweens() + + # Fade out old ambient layer (if different asset from incoming zone) + if not old_asset.is_empty() and old_asset != new_asset: + if _ambient_players.has(old_asset): + var old_player: AudioStreamPlayer = _ambient_players[old_asset] + if is_instance_valid(old_player): + var tween := create_tween() + tween.tween_property(old_player, "volume_db", -80.0, CROSSFADE_DURATION) + tween.tween_callback(stop_loop.bind(old_asset)) + _zone_tweens.append(tween) + + # Fade in new ambient layer + if not new_asset.is_empty(): + if _ambient_players.has(new_asset): + # Already playing (interrupted reverse crossfade) — tween from current volume + var existing: AudioStreamPlayer = _ambient_players[new_asset] + if is_instance_valid(existing): + var tween := create_tween() + tween.tween_property(existing, "volume_db", 0.0, CROSSFADE_DURATION) + _zone_tweens.append(tween) + elif has_asset(new_asset): + var new_player := play_loop(new_asset, BUS_AMBIENT) + if new_player: + new_player.volume_db = -80.0 + var tween := create_tween() + tween.tween_property(new_player, "volume_db", 0.0, CROSSFADE_DURATION) + _zone_tweens.append(tween) + + +func _kill_zone_tweens() -> void: + for tween in _zone_tweens: + if tween != null and tween.is_valid(): + tween.kill() + _zone_tweens.clear() # --- Internal helpers --- diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 4f3045158..2b912d2e5 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -62,6 +62,12 @@ var medium_sound_events: Array = [] # Format: [{x, y, event_type, range_category}] — consumed once per tick in main.gd. var close_sound_events: Array = [] +# D-071 (#530): Consecutive ticks without player position change. +# Incremented per snapshot in apply_snapshot(). Reset to 0 on movement. +# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip). +var stationary_ticks: int = 0 +var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position + func apply_snapshot(snapshot: Dictionary) -> void: current_snapshot = snapshot @@ -83,6 +89,14 @@ func apply_snapshot(snapshot: Dictionary) -> void: push_warning("GameState: no Player entity found in %d entities" % [ visible_entities.size()]) + # D-071 (#530): Track consecutive stationary ticks for ListeningFocus boost. + # Compares current player_position against previous snapshot's position. + if player_position == _prev_player_position: + stationary_ticks += 1 + else: + stationary_ticks = 0 + _prev_player_position = player_position + # Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles" if snapshot.has("tiles"): visible_tiles = snapshot.tiles diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 677c7ebf4..16b4eb2dd 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -15,6 +15,7 @@ extends Node2D @onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests @onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress @onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button +@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU) var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input var _camera_anchored: bool = false @@ -24,6 +25,9 @@ var _known_recognition_ids: Dictionary = {} # D-067: entity_ids that have alrea var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber) var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival +var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades + +const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates func _ready() -> void: print("The Settled Reach — client initialized") @@ -128,6 +132,13 @@ func _process(_delta: float) -> void: # D-018 #125: Play close-range sound events via positional 2D audio _play_close_sound_events() + # D-073 (#529): Zone ambient crossfade — detect player tile zone, trigger set_zone on change. + _update_zone() + + # D-071 (#530): ListeningFocus boost — stationary 30+ ticks boosts WorldSFX. + # Only activates when no dialogue/confrontation dip is active (D-070). + _update_listening_focus() + # Show monologue if server sent one this tick (#414) _consume_monologue() @@ -162,6 +173,14 @@ func _process(_delta: float) -> void: if bug_report_dialog and not bug_report_dialog.is_active(): bug_report_dialog.start_capture() continue + # #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog + if input.action == InputMapper.Action.OPEN_MENU: + if settings_dialog: + if settings_dialog.is_open(): + settings_dialog.close() + else: + settings_dialog.open() + continue if input.action == InputMapper.Action.INTERACT: # D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1) var target_id: int = -1 @@ -235,6 +254,38 @@ func _play_recognition_chimes() -> void: _known_recognition_ids.erase(eid) +# D-073 (#529): Zone ambient crossfade — read zone_id from player's current tile. +# Calls AudioManager.set_zone() when zone changes (AudioManager handles crossfade). +# zone_id field is server-authoritative; missing field treated as empty (no zone). +func _update_zone() -> void: + var px := int(GameState.player_position.x) + var py := int(GameState.player_position.y) + var zone: String = "" + for tile in GameState.visible_tiles: + if not tile is Dictionary: + continue + if tile.get("x") == px and tile.get("y") == py: + zone = tile.get("zone_id", "") + break + if zone != _current_zone: + _current_zone = zone + AudioManager.set_zone(zone) + + +# D-071 (#530): ListeningFocus boost — World SFX +2.5dB when stationary 30+ ticks. +# Uses AudioManager.get_active_dip() as single source of truth (no separate flag). +# Only activates when no other dip (dialogue/confrontation) is running. +# Only deactivates its own dip — never touches dialogue/confrontation. +# D-070: no UI indicator — the boost is "felt, not computed." +func _update_listening_focus() -> void: + var current_dip := AudioManager.get_active_dip() + var threshold_met := GameState.stationary_ticks >= LISTENING_FOCUS_TICKS + if threshold_met and current_dip == "": + AudioManager.apply_dip("listening_focus") + elif not threshold_met and current_dip == "listening_focus": + AudioManager.clear_dip() + + # Consume-once per tick: show monologue text, then clear. # Tick guard prevents re-triggering when the same tick is polled multiple # times (client FPS > sim tick rate). diff --git a/client/ui/dialogue_box.gd b/client/ui/dialogue_box.gd index 62c71b53b..e64363929 100644 --- a/client/ui/dialogue_box.gd +++ b/client/ui/dialogue_box.gd @@ -139,6 +139,10 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi _is_showing = true GameState.dialogue_active = true # D-064: block movement while dialogue visible/fading + # D-069: Dialogue dip — reduce ambient noise to foreground conversation. + # Confrontation options override this with apply_dip("confrontation") in _start_confrontation_beat(). + AudioManager.apply_dip("dialogue") + # D-061: auto-pause in single-player when dialogue opens SimBridge.send_input({"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()}) @@ -156,6 +160,10 @@ func hide_dialogue() -> void: _is_showing = false mouse_filter = Control.MOUSE_FILTER_IGNORE + # D-069: Clear dialogue/confrontation dip — restore bus volumes to player slider settings. + # Safe to call even if confrontation beat already cleared the dip (no-op when empty). + AudioManager.clear_dip() + # D-061: unpause when dialogue closes SimBridge.send_input({"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()}) diff --git a/client/ui/settings_dialog.gd b/client/ui/settings_dialog.gd new file mode 100644 index 000000000..588e9b4eb --- /dev/null +++ b/client/ui/settings_dialog.gd @@ -0,0 +1,159 @@ +extends Control + +## #528: Audio settings dialog — 5-bus volume sliders. +## Opens on OPEN_MENU (ESC) from main.gd. Closes on OPEN_MENU again or CLOSE button. +## Volumes persist via AudioManager._save_prefs() on each slider change. + +const BG_COLOR := Color(0.05, 0.05, 0.08, 0.90) +const BORDER_COLOR := Color("#4a9ebb") +const TEXT_COLOR := Color(0.878, 0.969, 0.98, 1) +const TITLE_COLOR := Color("#4a9ebb") +const FONT_SIZE := 14 + +const BOX_WIDTH := 460 +const BOX_HEIGHT := 340 +const PADDING := 20 +const ROW_HEIGHT := 36 + +# Bus display labels → bus name strings (must match AudioManager BUS_* constants) +const BUS_ROWS: Array = [ + ["Music", "Music"], + ["Ambient", "Ambient"], + ["World SFX", "WorldSFX"], + ["Player Actions", "PlayerActions"], + ["UI Sounds", "UISounds"], +] + +var _active: bool = false +var _container: VBoxContainer = null + +signal closed + + +func _ready() -> void: + visible = false + mouse_filter = Control.MOUSE_FILTER_STOP + + +func open() -> void: + if _active: + return + _active = true + visible = true + _build_ui() + queue_redraw() + + +func close() -> void: + if not _active: + return + _active = false + visible = false + _destroy_ui() + queue_redraw() + closed.emit() + + +func is_open() -> bool: + return _active + + +func _build_ui() -> void: + var vp_size := get_viewport_rect().size + var box_pos := Vector2( + (vp_size.x - BOX_WIDTH) / 2.0, + (vp_size.y - BOX_HEIGHT) / 2.0 + ) + + _container = VBoxContainer.new() + _container.position = box_pos + Vector2(PADDING, PADDING + 28) + _container.custom_minimum_size = Vector2(BOX_WIDTH - PADDING * 2, 0) + _container.add_theme_constant_override("separation", 4) + add_child(_container) + + for row_data in BUS_ROWS: + var label_text: String = row_data[0] + var bus_name: String = row_data[1] + + var hbox := HBoxContainer.new() + hbox.custom_minimum_size = Vector2(0, ROW_HEIGHT) + _container.add_child(hbox) + + var label := Label.new() + label.text = label_text + label.custom_minimum_size = Vector2(150, 0) + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + label.add_theme_font_size_override("font_size", FONT_SIZE) + label.add_theme_color_override("font_color", TEXT_COLOR) + hbox.add_child(label) + + var slider := HSlider.new() + slider.min_value = -40.0 + slider.max_value = 0.0 + slider.step = 0.5 + slider.value = AudioManager.get_volume(bus_name) + slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL + hbox.add_child(slider) + + var db_label := Label.new() + db_label.text = _format_db(slider.value) + db_label.custom_minimum_size = Vector2(70, 0) + db_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + db_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + db_label.add_theme_font_size_override("font_size", FONT_SIZE) + db_label.add_theme_color_override("font_color", TEXT_COLOR) + hbox.add_child(db_label) + + slider.value_changed.connect(func(value: float) -> void: + AudioManager.set_volume(bus_name, value) + db_label.text = _format_db(value) + ) + + # Spacer + var spacer := Control.new() + spacer.custom_minimum_size = Vector2(0, 8) + _container.add_child(spacer) + + # Close button + var close_btn := Button.new() + close_btn.text = "CLOSE" + close_btn.add_theme_font_size_override("font_size", FONT_SIZE) + close_btn.pressed.connect(close) + _container.add_child(close_btn) + + +func _destroy_ui() -> void: + if _container: + _container.queue_free() + _container = null + + +func _draw() -> void: + if not _active: + return + var viewport_size := get_viewport_rect().size + + # Dim overlay + draw_rect(Rect2(Vector2.ZERO, viewport_size), BG_COLOR) + + # Dialog box + var box_pos := Vector2( + (viewport_size.x - BOX_WIDTH) / 2.0, + (viewport_size.y - BOX_HEIGHT) / 2.0 + ) + var box_rect := Rect2(box_pos, Vector2(BOX_WIDTH, BOX_HEIGHT)) + draw_rect(box_rect, Color(0.08, 0.08, 0.12, 0.95)) + draw_rect(box_rect, BORDER_COLOR, false, 1.0) + + # Title + var font := ThemeDB.fallback_font + draw_string(font, + box_pos + Vector2(PADDING, PADDING + 18), + "AUDIO SETTINGS", + HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR) + + +static func _format_db(db: float) -> String: + if db <= -40.0: + return "mute" + return "%d dB" % int(db) diff --git a/client/ui/settings_dialog.tscn b/client/ui/settings_dialog.tscn new file mode 100644 index 000000000..77cf97319 --- /dev/null +++ b/client/ui/settings_dialog.tscn @@ -0,0 +1,14 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://ui/settings_dialog.gd" id="1_settings"] + +; #528: Audio settings dialog — 5-bus volume sliders, OPEN_MENU (ESC) to toggle +[node name="SettingsDialog" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +script = ExtResource("1_settings") diff --git a/server/Cargo.lock b/server/Cargo.lock index 21c88b2a2..ea1dcf2fe 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1092,7 +1092,7 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.11" +version = "0.1.12" dependencies = [ "bevy_app", "bevy_ecs",