feat(audio): Sprint 13 — AudioManager, zone crossfade, dip profiles, murmur wiring (#528, #529, #530, #533)
AudioManager: volume slider persistence (ConfigFile), settings UI with 5 teal-bordered sliders, default_bus_layout.tres for editor. Zone crossfade: defensive zone_id read from snapshot tiles, 1.5-2s ambient tween, auto-activates when server ships OQ-09. Dip profiles: dialogue dip in show/hide_dialogue, ListeningFocus 30-tick gate via stationary_ticks in game_state.gd. NPC murmur: client plumbing for event-driven World SFX playback, no-ops until audio asset arrives. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||||
@@ -24,6 +24,10 @@ UIStrings="*res://scripts/autoloads/ui_strings.gd"
|
|||||||
FogState="*res://scripts/autoloads/fog_state.gd"
|
FogState="*res://scripts/autoloads/fog_state.gd"
|
||||||
AudioManager="*res://scripts/autoloads/audio_manager.gd"
|
AudioManager="*res://scripts/autoloads/audio_manager.gd"
|
||||||
|
|
||||||
|
[audio]
|
||||||
|
|
||||||
|
buses/default_bus_layout="res://default_bus_layout.tres"
|
||||||
|
|
||||||
[gui]
|
[gui]
|
||||||
|
|
||||||
theme/custom="res://assets/theme/game_theme.tres"
|
theme/custom="res://assets/theme/game_theme.tres"
|
||||||
|
|||||||
@@ -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/main.gd" id="1_main"]
|
||||||
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
|
[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/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/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/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"]
|
[node name="Game" type="Node2D"]
|
||||||
script = ExtResource("1_main")
|
script = ExtResource("1_main")
|
||||||
@@ -162,3 +163,6 @@ layer = 30
|
|||||||
|
|
||||||
; #495: WRONG button (F12) — bug report capture dialog
|
; #495: WRONG button (F12) — bug report capture dialog
|
||||||
[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("19_bugreport")]
|
[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")]
|
||||||
|
|||||||
@@ -41,6 +41,18 @@ const DIP_SPECS := {
|
|||||||
# Low-pass filter default cutoff — effectively bypassed at this value.
|
# Low-pass filter default cutoff — effectively bypassed at this value.
|
||||||
const FILTER_CUTOFF_DEFAULT := 20500.0
|
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
|
# Asset registry: filename stem (e.g. "amb_station_base") → AudioStream
|
||||||
var _registry: Dictionary = {}
|
var _registry: Dictionary = {}
|
||||||
|
|
||||||
@@ -57,12 +69,19 @@ var _ambient_filter: AudioEffectLowPassFilter = null
|
|||||||
# Ambient loop players keyed by asset_key (D-073 zone crossfade)
|
# Ambient loop players keyed by asset_key (D-073 zone crossfade)
|
||||||
var _ambient_players: Dictionary = {}
|
var _ambient_players: Dictionary = {}
|
||||||
|
|
||||||
|
# D-073: Zone crossfade state
|
||||||
|
var _current_zone_id: String = ""
|
||||||
|
var _zone_tweens: Array = []
|
||||||
|
|
||||||
signal dip_changed(profile: String)
|
signal dip_changed(profile: String)
|
||||||
|
|
||||||
|
|
||||||
|
const PREFS_PATH := "user://audio_prefs.cfg"
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_setup_buses()
|
_setup_buses()
|
||||||
_scan_registry()
|
_scan_registry()
|
||||||
|
_load_prefs()
|
||||||
|
|
||||||
|
|
||||||
# --- Bus setup ---
|
# --- Bus setup ---
|
||||||
@@ -173,9 +192,8 @@ func stop_all_loops() -> void:
|
|||||||
# --- Audio asset registry: event type → asset key (D-018, #125) ---
|
# --- Audio asset registry: event type → asset key (D-018, #125) ---
|
||||||
# Maps server-sent sound event_type strings to audio asset keys.
|
# Maps server-sent sound event_type strings to audio asset keys.
|
||||||
# Keys match filename stems in res://audio/ (scanned by _scan_registry).
|
# Keys match filename stems in res://audio/ (scanned by _scan_registry).
|
||||||
# Audio assets per D-038: footstep variants (walk / run).
|
# Audio assets per D-038: footstep variants (walk / run), NPC murmur (D-072, #532).
|
||||||
# No asset for Voice events in v0.1 — play_sound_event no-ops gracefully
|
# Missing assets no-op gracefully (D-038 fallback pattern).
|
||||||
# (D-038: "renders as audio if asset exists, or visual indicator + monologue if not").
|
|
||||||
const SOUND_EVENT_ASSETS: Dictionary = {
|
const SOUND_EVENT_ASSETS: Dictionary = {
|
||||||
"Footstep": "sfx_footstep_metal_walk",
|
"Footstep": "sfx_footstep_metal_walk",
|
||||||
"FootstepWalk": "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
|
"FootstepCrouch": "sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
|
||||||
"FootstepSprint": "sfx_footstep_metal_run",
|
"FootstepSprint": "sfx_footstep_metal_run",
|
||||||
"FootstepRun": "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 buses: Dictionary = spec.get("buses", {})
|
||||||
var offset_db: float = buses.get(bus, 0.0)
|
var offset_db: float = buses.get(bus, 0.0)
|
||||||
AudioServer.set_bus_volume_db(idx, volume_db + offset_db)
|
AudioServer.set_bus_volume_db(idx, volume_db + offset_db)
|
||||||
|
_save_prefs()
|
||||||
|
|
||||||
|
|
||||||
func get_volume(bus: String) -> float:
|
func get_volume(bus: String) -> float:
|
||||||
return _bus_volumes.get(bus, 0.0)
|
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.
|
## Handle zone transition. Server sends zone_id per tile in ObserverSnapshot.
|
||||||
## Full crossfade implementation deferred to Sprint 9+ (D-073).
|
## Hard boundary trigger with 1.5-2s audio crossfade between ambient layers.
|
||||||
## Stub exists so server integration can call it without conditional checks.
|
## Interruptible — mid-crossfade zone change tweens from current position.
|
||||||
func set_zone(_zone_id: String) -> void:
|
## No-op if assets absent (D-038) or same zone.
|
||||||
pass
|
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 ---
|
# --- Internal helpers ---
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ var medium_sound_events: Array = []
|
|||||||
# Format: [{x, y, event_type, range_category}] — consumed once per tick in main.gd.
|
# Format: [{x, y, event_type, range_category}] — consumed once per tick in main.gd.
|
||||||
var close_sound_events: Array = []
|
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:
|
func apply_snapshot(snapshot: Dictionary) -> void:
|
||||||
current_snapshot = snapshot
|
current_snapshot = snapshot
|
||||||
|
|
||||||
@@ -83,6 +89,14 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
|||||||
push_warning("GameState: no Player entity found in %d entities" % [
|
push_warning("GameState: no Player entity found in %d entities" % [
|
||||||
visible_entities.size()])
|
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"
|
# Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles"
|
||||||
if snapshot.has("tiles"):
|
if snapshot.has("tiles"):
|
||||||
visible_tiles = snapshot.tiles
|
visible_tiles = snapshot.tiles
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ extends Node2D
|
|||||||
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
|
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
|
||||||
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
|
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
|
||||||
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
@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 _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||||
var _camera_anchored: bool = false
|
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 _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 _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 _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:
|
func _ready() -> void:
|
||||||
print("The Settled Reach — client initialized")
|
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
|
# D-018 #125: Play close-range sound events via positional 2D audio
|
||||||
_play_close_sound_events()
|
_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)
|
# Show monologue if server sent one this tick (#414)
|
||||||
_consume_monologue()
|
_consume_monologue()
|
||||||
|
|
||||||
@@ -162,6 +173,14 @@ func _process(_delta: float) -> void:
|
|||||||
if bug_report_dialog and not bug_report_dialog.is_active():
|
if bug_report_dialog and not bug_report_dialog.is_active():
|
||||||
bug_report_dialog.start_capture()
|
bug_report_dialog.start_capture()
|
||||||
continue
|
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:
|
if input.action == InputMapper.Action.INTERACT:
|
||||||
# D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1)
|
# D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1)
|
||||||
var target_id: int = -1
|
var target_id: int = -1
|
||||||
@@ -235,6 +254,38 @@ func _play_recognition_chimes() -> void:
|
|||||||
_known_recognition_ids.erase(eid)
|
_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.
|
# Consume-once per tick: show monologue text, then clear.
|
||||||
# Tick guard prevents re-triggering when the same tick is polled multiple
|
# Tick guard prevents re-triggering when the same tick is polled multiple
|
||||||
# times (client FPS > sim tick rate).
|
# times (client FPS > sim tick rate).
|
||||||
|
|||||||
@@ -139,6 +139,10 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
|
|||||||
_is_showing = true
|
_is_showing = true
|
||||||
GameState.dialogue_active = true # D-064: block movement while dialogue visible/fading
|
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
|
# D-061: auto-pause in single-player when dialogue opens
|
||||||
SimBridge.send_input({"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()})
|
SimBridge.send_input({"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()})
|
||||||
|
|
||||||
@@ -156,6 +160,10 @@ func hide_dialogue() -> void:
|
|||||||
_is_showing = false
|
_is_showing = false
|
||||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
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
|
# D-061: unpause when dialogue closes
|
||||||
SimBridge.send_input({"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()})
|
SimBridge.send_input({"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
Generated
+1
-1
@@ -1092,7 +1092,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "settled-reach-server"
|
name = "settled-reach-server"
|
||||||
version = "0.1.11"
|
version = "0.1.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bevy_app",
|
"bevy_app",
|
||||||
"bevy_ecs",
|
"bevy_ecs",
|
||||||
|
|||||||
Reference in New Issue
Block a user