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:
@@ -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 ---
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user