Merge remote-tracking branch 'origin/client'
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"
|
||||
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"
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -41,6 +41,23 @@ 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 intentionally share the same ambient layer (amb_hub_layer) —
|
||||
# they are the same location type, so hub→workplace transition is a same-asset no-op
|
||||
# (old_asset != new_asset guard skips the fade-out). Sprint brief consolidates
|
||||
# D-038's "amb_workplace_layer" to "amb_hub_layer" for v0.1.
|
||||
# Note: amb_station_base (D-038 global base hum) plays globally via play_loop()
|
||||
# at startup — it is not zone-dependent and has no ZONE_ASSETS entry.
|
||||
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 +74,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 ---
|
||||
@@ -110,6 +134,7 @@ func _scan_dir(path: String) -> void:
|
||||
if stream:
|
||||
_registry[file_name.get_basename()] = stream
|
||||
file_name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
|
||||
|
||||
func has_asset(asset_key: String) -> bool:
|
||||
@@ -173,9 +198,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",
|
||||
@@ -304,19 +328,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,17 @@ 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
|
||||
|
||||
# D-073 (#529): Server-authoritative zone_id from the player's current tile.
|
||||
# Extracted in apply_snapshot() — avoids O(N) tile scan in main.gd per Tyre review.
|
||||
# Empty string when zone_id field absent (server hasn't shipped OQ-09 yet).
|
||||
var current_zone_id: String = ""
|
||||
|
||||
func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
current_snapshot = snapshot
|
||||
|
||||
@@ -83,6 +94,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
|
||||
@@ -183,6 +202,17 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
medium_sound_events = []
|
||||
close_sound_events = []
|
||||
|
||||
# D-073 (#529): Extract zone_id from the player's current tile (server-authoritative).
|
||||
# O(1) via visible_positions dict would be ideal, but tiles are arrays without
|
||||
# positional indexing — use the same tile iteration below instead.
|
||||
current_zone_id = ""
|
||||
var _px := int(player_position.x)
|
||||
var _py := int(player_position.y)
|
||||
for _ztile in visible_tiles:
|
||||
if _ztile is Dictionary and _ztile.get("x") == _px and _ztile.get("y") == _py:
|
||||
current_zone_id = _ztile.get("zone_id", "")
|
||||
break
|
||||
|
||||
# v2: visible_tiles with visibility sectors
|
||||
# Derives visible_positions when not explicitly provided (real server mode)
|
||||
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
|
||||
|
||||
@@ -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,30 @@ func _play_recognition_chimes() -> void:
|
||||
_known_recognition_ids.erase(eid)
|
||||
|
||||
|
||||
# D-073 (#529): Zone ambient crossfade — reads zone_id from GameState.current_zone_id
|
||||
# (extracted in apply_snapshot(), server-authoritative per D-020).
|
||||
# Calls AudioManager.set_zone() when zone changes (AudioManager handles crossfade).
|
||||
func _update_zone() -> void:
|
||||
var zone := GameState.current_zone_id
|
||||
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).
|
||||
|
||||
@@ -15,10 +15,16 @@ extends GdUnitTestSuite
|
||||
|
||||
func before_test() -> void:
|
||||
AudioManager.clear_dip()
|
||||
for bus in AudioManager.BUSES:
|
||||
AudioManager.set_volume(bus, 0.0)
|
||||
GameState.stationary_ticks = 0
|
||||
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
AudioManager.clear_dip()
|
||||
GameState.stationary_ticks = 0
|
||||
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -171,22 +177,25 @@ func test_audio_manager_apply_unknown_dip_leaves_state_unchanged() -> void:
|
||||
|
||||
func test_audio_manager_apply_dip_emits_dip_changed_signal() -> void:
|
||||
## apply_dip() must emit dip_changed(profile) synchronously.
|
||||
var received_profile := ""
|
||||
var conn := func(p: String) -> void: received_profile = p
|
||||
## Array wrapper used for lambda capture — GDScript 4 captures String locals by value,
|
||||
## so a mutable reference type is required to observe signal argument inside the closure.
|
||||
var received := [""]
|
||||
var conn := func(p: String) -> void: received[0] = p
|
||||
AudioManager.dip_changed.connect(conn)
|
||||
AudioManager.apply_dip("dialogue")
|
||||
AudioManager.dip_changed.disconnect(conn)
|
||||
assert_that(received_profile).is_equal("dialogue")
|
||||
assert_that(received[0]).is_equal("dialogue")
|
||||
|
||||
func test_audio_manager_clear_dip_emits_dip_changed_empty() -> void:
|
||||
## clear_dip() must emit dip_changed("") to signal audio restored.
|
||||
## Array wrapper used for lambda capture — same reason as apply_dip signal test above.
|
||||
AudioManager.apply_dip("dialogue")
|
||||
var received_profile := "sentinel"
|
||||
var conn := func(p: String) -> void: received_profile = p
|
||||
var received := ["sentinel"]
|
||||
var conn := func(p: String) -> void: received[0] = p
|
||||
AudioManager.dip_changed.connect(conn)
|
||||
AudioManager.clear_dip()
|
||||
AudioManager.dip_changed.disconnect(conn)
|
||||
assert_that(received_profile).is_equal("")
|
||||
assert_that(received[0]).is_equal("")
|
||||
|
||||
func test_audio_manager_apply_dip_interrupts_previous() -> void:
|
||||
## Switching profiles mid-dip: active profile must update to the new one.
|
||||
@@ -196,13 +205,14 @@ func test_audio_manager_apply_dip_interrupts_previous() -> void:
|
||||
|
||||
func test_audio_manager_dip_changed_fires_on_profile_switch() -> void:
|
||||
## Switching from dialogue to confrontation emits dip_changed("confrontation").
|
||||
## Array wrapper used for lambda capture — same reason as apply_dip signal test above.
|
||||
AudioManager.apply_dip("dialogue")
|
||||
var received_profile := ""
|
||||
var conn := func(p: String) -> void: received_profile = p
|
||||
var received := [""]
|
||||
var conn := func(p: String) -> void: received[0] = p
|
||||
AudioManager.dip_changed.connect(conn)
|
||||
AudioManager.apply_dip("confrontation")
|
||||
AudioManager.dip_changed.disconnect(conn)
|
||||
assert_that(received_profile).is_equal("confrontation")
|
||||
assert_that(received[0]).is_equal("confrontation")
|
||||
|
||||
func test_audio_manager_has_asset_false_for_unknown_key() -> void:
|
||||
## has_asset() must return false for a key that was never registered.
|
||||
@@ -291,3 +301,57 @@ func test_audio_manager_play_sound_event_noop_for_empty_type() -> void:
|
||||
## #125: Empty event type string must be a no-op.
|
||||
AudioManager.play_sound_event("", Vector2(5.0, 5.0))
|
||||
# No assertion needed — absence of crash is the test.
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 6: D-071 (#530) — Stationary tick tracking for ListeningFocus
|
||||
# ==============================================================================
|
||||
|
||||
func test_d071_stationary_ticks_increments_when_position_unchanged() -> void:
|
||||
## D-071: stationary_ticks must increment on each snapshot where player doesn't move.
|
||||
var player_entity := {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [player_entity]})
|
||||
GameState.apply_snapshot({"tick": 2, "entities": [player_entity]})
|
||||
GameState.apply_snapshot({"tick": 3, "entities": [player_entity]})
|
||||
assert_that(GameState.stationary_ticks).is_equal(2) # 2 ticks of no movement (tick 2 and 3)
|
||||
|
||||
func test_d071_stationary_ticks_resets_on_movement() -> void:
|
||||
## D-071: stationary_ticks must reset to 0 when the player position changes.
|
||||
var pos_a := {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
var pos_b := {"entity_id": 1, "x": 11.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [pos_a]})
|
||||
GameState.apply_snapshot({"tick": 2, "entities": [pos_a]})
|
||||
assert_that(GameState.stationary_ticks).is_equal(1)
|
||||
GameState.apply_snapshot({"tick": 3, "entities": [pos_b]})
|
||||
assert_that(GameState.stationary_ticks).is_equal(0)
|
||||
|
||||
func test_d071_stationary_ticks_reaches_threshold() -> void:
|
||||
## D-071: stationary_ticks must be able to reach 30+ for ListeningFocus activation.
|
||||
var player_entity := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
for tick in range(31):
|
||||
GameState.apply_snapshot({"tick": tick, "entities": [player_entity]})
|
||||
assert_that(GameState.stationary_ticks >= 30).is_true()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 7: D-069 (#530) — Dialogue dip profile interaction
|
||||
# ==============================================================================
|
||||
|
||||
func test_d069_dialogue_dip_overridden_by_confrontation() -> void:
|
||||
## D-069: Confrontation dip must override dialogue dip (apply_dip interrupts).
|
||||
AudioManager.apply_dip("dialogue")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
|
||||
AudioManager.apply_dip("confrontation")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("confrontation")
|
||||
|
||||
func test_d069_listening_focus_cleared_by_dialogue() -> void:
|
||||
## D-069: Dialogue dip must override listening_focus (higher priority focus state).
|
||||
AudioManager.apply_dip("listening_focus")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("listening_focus")
|
||||
AudioManager.apply_dip("dialogue")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
|
||||
|
||||
func test_d069_clear_dip_noop_when_empty() -> void:
|
||||
## D-069: clear_dip() when no dip active must be a safe no-op.
|
||||
AudioManager.clear_dip() # Should not crash
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("")
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
## Test suite for Sprint 13 audio tickets (D-067, D-068, D-069, D-071, D-072, D-073).
|
||||
##
|
||||
## Spec refs:
|
||||
## D-067: Recognition chime fires at ONSET of cognitive delay, not at completion.
|
||||
## D-068: 5-bus audio architecture (Music, Ambient, WorldSFX, PlayerActions, UISounds).
|
||||
## D-069: Audio dip profiles — timing values, dB offsets, interruptibility.
|
||||
## D-071: ListeningFocus boost (30+ tick gate, caller's responsibility).
|
||||
## D-072: Universal NPC conversation murmur on WorldSFX — no zone-specific variants.
|
||||
## D-073: Zone crossfade — hard boundary trigger, 1.5-2s tween, interruptible.
|
||||
##
|
||||
## Tickets covered:
|
||||
## #529 — Zone crossfade implementation (set_zone body + tween timing)
|
||||
## #530 — Dip profile call sites (dialogue/confrontation/ListeningFocus wiring)
|
||||
## #531 — Recognition chime fires at cognitive delay onset
|
||||
## #533 — NPC conversation murmur wired to WorldSFX bus
|
||||
##
|
||||
## Test layers:
|
||||
## 1. Zone crossfade spec and API (D-073 / #529)
|
||||
## 2. D-069 dip timing and dB spec values (supplementing test_audio_bus_routing)
|
||||
## 3. Volume slider proportional dip (D-068 / D-069)
|
||||
## 4. Dip call site wiring via GameState snapshot (D-069 / D-070 / #530)
|
||||
## 5. Recognition chime onset verification (D-067 / #531)
|
||||
## 6. NPC murmur routing (D-072 / #533)
|
||||
## 7. AudioManager no-op fallback sanity (D-068 / D-038)
|
||||
class_name TestAudioSprint13
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
AudioManager.clear_dip()
|
||||
AudioManager.stop_all_loops()
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, 0.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, 0.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_PLAYER_ACTIONS, 0.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_MUSIC, 0.0)
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
AudioManager.clear_dip()
|
||||
AudioManager.stop_all_loops()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 1: Zone Crossfade — D-073 / #529
|
||||
# set_zone() stub exists now; full implementation lands in #529.
|
||||
# ==============================================================================
|
||||
|
||||
func test_d073_set_zone_method_exists() -> void:
|
||||
## D-073 / #529: AudioManager must expose set_zone(zone_id: String).
|
||||
assert_that(AudioManager.has_method("set_zone")).is_true()
|
||||
|
||||
|
||||
func test_d073_set_zone_hub_does_not_crash() -> void:
|
||||
## D-073: set_zone("hub") is a safe call. No crash even before #529 implementation.
|
||||
AudioManager.set_zone("hub")
|
||||
|
||||
|
||||
func test_d073_set_zone_bar_does_not_crash() -> void:
|
||||
## D-073: set_zone("bar") is a safe call.
|
||||
AudioManager.set_zone("bar")
|
||||
|
||||
|
||||
func test_d073_set_zone_corridor_does_not_crash() -> void:
|
||||
## D-073: set_zone("corridor") is a safe call.
|
||||
AudioManager.set_zone("corridor")
|
||||
|
||||
|
||||
func test_d073_set_zone_empty_string_does_not_crash() -> void:
|
||||
## D-073: set_zone("") edge case — no zone ID. Must not crash.
|
||||
AudioManager.set_zone("")
|
||||
|
||||
|
||||
func test_d073_set_zone_unknown_zone_does_not_crash() -> void:
|
||||
## D-073: Unmapped zone ID (no matching asset) — no crash, graceful no-op.
|
||||
AudioManager.set_zone("nonexistent_zone_xyz")
|
||||
|
||||
|
||||
func test_d073_zone_asset_hub_key_matches_filename_convention() -> void:
|
||||
## D-073 / #529: v0.1 zone-to-asset mapping per sprint brief.
|
||||
## hub/workplace → amb_hub_layer (must match filename stem in res://audio/).
|
||||
## Test verifies naming convention is documentable. Activates once #529 adds the map.
|
||||
if not "ZONE_ASSETS" in AudioManager:
|
||||
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
|
||||
return
|
||||
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
|
||||
assert_that(zone_assets.has("hub")).is_true()
|
||||
assert_that(zone_assets["hub"]).is_equal("amb_hub_layer")
|
||||
|
||||
|
||||
func test_d073_zone_asset_bar_key_matches_filename_convention() -> void:
|
||||
## D-073 / #529: bar → amb_bar_layer
|
||||
if not "ZONE_ASSETS" in AudioManager:
|
||||
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
|
||||
return
|
||||
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
|
||||
assert_that(zone_assets.has("bar")).is_true()
|
||||
assert_that(zone_assets["bar"]).is_equal("amb_bar_layer")
|
||||
|
||||
|
||||
func test_d073_zone_asset_corridor_key_matches_filename_convention() -> void:
|
||||
## D-073 / #529: smuggling corridor → amb_corridor_layer
|
||||
if not "ZONE_ASSETS" in AudioManager:
|
||||
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
|
||||
return
|
||||
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
|
||||
assert_that(zone_assets.has("corridor")).is_true()
|
||||
assert_that(zone_assets["corridor"]).is_equal("amb_corridor_layer")
|
||||
|
||||
|
||||
func test_d073_game_state_extracts_zone_id_from_player_tile() -> void:
|
||||
## D-073 / Tyre review: zone_id is extracted in GameState.apply_snapshot() as
|
||||
## a first-class field (like player_facing, player_stance), avoiding O(N) tile
|
||||
## scan in main.gd per D-020 server-authoritative state.
|
||||
var player := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [player],
|
||||
"tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "bar"},
|
||||
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "hub"},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).is_equal("bar")
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
|
||||
|
||||
func test_d073_game_state_zone_id_empty_when_field_absent() -> void:
|
||||
## D-073: Defensive — zone_id missing from tile data (server hasn't shipped OQ-09).
|
||||
var player := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [player],
|
||||
"tiles": [{"x": 5, "y": 5, "z": 0, "type": "floor"}],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).is_equal("")
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
|
||||
|
||||
func test_d073_crossfade_duration_in_1_5_to_2_0s_range() -> void:
|
||||
## D-073: Crossfade tween duration must be 1.5-2.0s.
|
||||
## Activates once #529 defines the duration constant.
|
||||
if not "CROSSFADE_DURATION" in AudioManager:
|
||||
push_warning("TestAudioSprint13: CROSSFADE_DURATION not yet defined (#529 pending) — skip duration test")
|
||||
return
|
||||
var duration: float = AudioManager.CROSSFADE_DURATION
|
||||
assert_that(duration >= 1.5 and duration <= 2.0).is_true()
|
||||
|
||||
|
||||
func test_d073_set_zone_same_zone_repeated_is_noop() -> void:
|
||||
## D-073: Crossing back to the current zone should not restart a crossfade.
|
||||
## (No audio pops when zone boundary is ambiguous.) Activates post-#529.
|
||||
if not "ZONE_ASSETS" in AudioManager:
|
||||
push_warning("TestAudioSprint13: set_zone body not yet implemented (#529) — skip no-op test")
|
||||
return
|
||||
AudioManager.set_zone("hub")
|
||||
AudioManager.set_zone("hub")
|
||||
## Expect exactly one or zero ambient players after same-zone calls (no stacked tweens).
|
||||
## Stub: assert no crash and ambient_players size is 0 or 1, not 2.
|
||||
assert_that(AudioManager._ambient_players.size() <= 1).is_true()
|
||||
|
||||
|
||||
func test_d073_rapid_zone_crossing_interruptible() -> void:
|
||||
## D-073: Rapid back-and-forth zone crossing (interruptible crossfade).
|
||||
## When _kill_zone_tweens() fires mid-fade, old player stays at intermediate
|
||||
## volume — new tween starts from current position. No stacked tweens, no crash.
|
||||
if not "ZONE_ASSETS" in AudioManager:
|
||||
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined — skip rapid crossing test")
|
||||
return
|
||||
# Cross hub → bar → hub rapidly (simulates player walking back and forth)
|
||||
AudioManager.set_zone("hub")
|
||||
AudioManager.set_zone("bar") # Interrupts hub fade-in mid-tween
|
||||
AudioManager.set_zone("hub") # Interrupts bar fade-in mid-tween
|
||||
AudioManager.set_zone("corridor") # Interrupts hub fade-in mid-tween
|
||||
## After rapid crossing: at most 2 ambient players (outgoing fade-out + incoming fade-in).
|
||||
## No stacked tweens — _kill_zone_tweens clears previous tweens each time.
|
||||
assert_that(AudioManager._ambient_players.size() <= 2).is_true()
|
||||
## Zone tweens array should only contain active tweens from the last set_zone call.
|
||||
assert_that(AudioManager._zone_tweens.size() <= 2).is_true()
|
||||
AudioManager.stop_all_loops()
|
||||
|
||||
|
||||
func test_d068_load_prefs_persistence_roundtrip() -> void:
|
||||
## #528: Volume persistence — save/load roundtrip via ConfigFile.
|
||||
## Sets non-default volumes, saves, reloads, and verifies values match.
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -8.5)
|
||||
AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, -3.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, -12.0)
|
||||
## _save_prefs() fires inside set_volume() — prefs file is written.
|
||||
## Reload prefs by calling _load_prefs() directly.
|
||||
AudioManager._load_prefs()
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-8.5, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_WORLD_SFX)).is_equal_approx(-3.0, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_UI_SOUNDS)).is_equal_approx(-12.0, 0.01)
|
||||
## Reset to defaults for subsequent tests.
|
||||
for bus in AudioManager.BUSES:
|
||||
AudioManager.set_volume(bus, 0.0)
|
||||
|
||||
|
||||
func test_d068_load_prefs_handles_missing_config_gracefully() -> void:
|
||||
## #528: _load_prefs() with a missing/corrupted config file must not crash.
|
||||
## The config path is user://audio_prefs.cfg — if absent, _load_prefs returns early.
|
||||
## This test documents the graceful fallback behavior.
|
||||
AudioManager._load_prefs()
|
||||
## No assertion — absence of crash is the test.
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 2: D-069 Dip Timing and dB Spec Values
|
||||
# These tests supplement test_audio_bus_routing.gd with timing and dB precision.
|
||||
# ==============================================================================
|
||||
|
||||
func test_d069_dialogue_ease_in_is_300ms() -> void:
|
||||
## D-069: Dialogue dip ease-in = 300ms (0.3s).
|
||||
var ease_in: float = AudioManager.DIP_SPECS["dialogue"]["ease_in"]
|
||||
assert_that(ease_in).is_equal_approx(0.3, 0.05)
|
||||
|
||||
|
||||
func test_d069_dialogue_ease_out_is_500ms() -> void:
|
||||
## D-069: Dialogue dip ease-out = 500ms (0.5s).
|
||||
var ease_out: float = AudioManager.DIP_SPECS["dialogue"]["ease_out"]
|
||||
assert_that(ease_out).is_equal_approx(0.5, 0.05)
|
||||
|
||||
|
||||
func test_d069_confrontation_ease_in_is_500ms() -> void:
|
||||
## D-069: Confrontation dip ease-in = 500ms (0.5s).
|
||||
var ease_in: float = AudioManager.DIP_SPECS["confrontation"]["ease_in"]
|
||||
assert_that(ease_in).is_equal_approx(0.5, 0.05)
|
||||
|
||||
|
||||
func test_d069_confrontation_ease_out_is_1000ms() -> void:
|
||||
## D-069: Confrontation dip ease-out = 1000ms (1.0s) — longer exit for immersion.
|
||||
var ease_out: float = AudioManager.DIP_SPECS["confrontation"]["ease_out"]
|
||||
assert_that(ease_out).is_equal_approx(1.0, 0.05)
|
||||
|
||||
|
||||
func test_d069_dialogue_ambient_dip_within_6_to_8_db() -> void:
|
||||
## D-069: Dialogue dip: Ambient -6 to -8dB. Mid-range value (-7) used.
|
||||
var dip: float = AudioManager.DIP_SPECS["dialogue"]["buses"]["Ambient"]
|
||||
assert_that(dip >= -8.0 and dip <= -6.0).is_true()
|
||||
|
||||
|
||||
func test_d069_confrontation_ambient_dip_within_10_to_12_db() -> void:
|
||||
## D-069: Confrontation dip: Ambient -10 to -12dB. Mid-range value (-11) used.
|
||||
var dip: float = AudioManager.DIP_SPECS["confrontation"]["buses"]["Ambient"]
|
||||
assert_that(dip >= -12.0 and dip <= -10.0).is_true()
|
||||
|
||||
|
||||
func test_d069_confrontation_world_sfx_dip_within_4_to_6_db() -> void:
|
||||
## D-069: Confrontation dip: WorldSFX -4 to -6dB (graduated — loud events break through).
|
||||
var dip: float = AudioManager.DIP_SPECS["confrontation"]["buses"]["WorldSFX"]
|
||||
assert_that(dip >= -6.0 and dip <= -4.0).is_true()
|
||||
|
||||
|
||||
func test_d069_listening_focus_world_sfx_boost_within_2_to_3_db() -> void:
|
||||
## D-069 / D-071: ListeningFocus boosts WorldSFX +2 to +3dB (eavesdrop bonus).
|
||||
var boost: float = AudioManager.DIP_SPECS["listening_focus"]["buses"]["WorldSFX"]
|
||||
assert_that(boost >= 2.0 and boost <= 3.0).is_true()
|
||||
|
||||
|
||||
func test_d069_dialogue_spec_only_affects_ambient_bus() -> void:
|
||||
## D-069: Dialogue dip touches ONLY Ambient. WorldSFX, PlayerActions, UISounds, Music
|
||||
## must NOT appear in the spec — world events remain audible during conversation.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["dialogue"]["buses"]
|
||||
assert_that(buses.has("Ambient")).is_true()
|
||||
assert_that(buses.has("WorldSFX")).is_false()
|
||||
assert_that(buses.has("PlayerActions")).is_false()
|
||||
assert_that(buses.has("UISounds")).is_false()
|
||||
assert_that(buses.has("Music")).is_false()
|
||||
|
||||
|
||||
func test_d069_confrontation_spec_does_not_affect_player_actions() -> void:
|
||||
## D-069: Confrontation dip leaves PlayerActions at 0 — player sounds are NOT muffled.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
|
||||
assert_that(buses.has("PlayerActions")).is_false()
|
||||
|
||||
|
||||
func test_d069_confrontation_spec_does_not_affect_ui_sounds() -> void:
|
||||
## D-069: Confrontation dip leaves UISounds at 0 — chimes and UI remain audible.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
|
||||
assert_that(buses.has("UISounds")).is_false()
|
||||
|
||||
|
||||
func test_d069_listening_focus_spec_does_not_affect_ambient() -> void:
|
||||
## D-071: ListeningFocus boost is ONLY on WorldSFX. Ambient is NOT modified.
|
||||
## D-071: Eavesdropping requires MORE ambient awareness, not less — no ambient dip.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["listening_focus"]["buses"]
|
||||
assert_that(buses.has("Ambient")).is_false()
|
||||
|
||||
|
||||
func test_d069_filter_cutoff_default_is_approx_20khz() -> void:
|
||||
## D-069: Default low-pass filter cutoff is ~20kHz — effectively bypassed.
|
||||
## Confrontation dip sweeps it down to 800Hz. Default must be >= 20000Hz.
|
||||
assert_that(AudioManager.FILTER_CUTOFF_DEFAULT >= 20000.0).is_true()
|
||||
|
||||
|
||||
func test_d069_confrontation_filter_hz_is_800hz() -> void:
|
||||
## D-069: Confrontation sweeps low-pass filter to ~800Hz for muffled feel (D-070).
|
||||
var filter_hz: float = AudioManager.DIP_SPECS["confrontation"]["filter_hz"]
|
||||
assert_that(filter_hz).is_equal_approx(800.0, 50.0)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 3: Volume Slider Proportional Dip — D-068 / D-069
|
||||
# ==============================================================================
|
||||
|
||||
func test_d068_set_volume_get_volume_roundtrip() -> void:
|
||||
## D-068: set_volume / get_volume roundtrip preserves the slider value.
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -6.0)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-6.0, 0.01)
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
|
||||
|
||||
|
||||
func test_d068_set_volume_persists_across_all_buses() -> void:
|
||||
## D-068: Each of the 5 buses has an independent volume setting.
|
||||
AudioManager.set_volume(AudioManager.BUS_MUSIC, -10.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -5.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, -3.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_PLAYER_ACTIONS, -2.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, -1.0)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_MUSIC)).is_equal_approx(-10.0, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-5.0, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_WORLD_SFX)).is_equal_approx(-3.0, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_PLAYER_ACTIONS)).is_equal_approx(-2.0, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_UI_SOUNDS)).is_equal_approx(-1.0, 0.01)
|
||||
|
||||
|
||||
func test_d069_get_volume_returns_slider_base_not_effective_volume() -> void:
|
||||
## D-069: get_volume() always returns the player slider setting (base).
|
||||
## The effective AudioServer volume during a dip = base + offset_db.
|
||||
## Callers storing the slider value must always read get_volume(), not AudioServer directly.
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
|
||||
AudioManager.apply_dip("dialogue")
|
||||
## Even during dip, get_volume returns the base (not base + dip offset).
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(0.0, 0.01)
|
||||
|
||||
|
||||
func test_d069_set_volume_during_active_dip_updates_base() -> void:
|
||||
## D-069: Changing slider mid-dip must update the base so the proportional
|
||||
## calculation uses the new slider value (not the pre-dip value).
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
|
||||
AudioManager.apply_dip("dialogue")
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -3.0)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-3.0, 0.01)
|
||||
|
||||
|
||||
func test_d069_clear_dip_does_not_change_stored_slider_value() -> void:
|
||||
## D-069: clear_dip() restores AudioServer volumes to base, but get_volume()
|
||||
## must still reflect the player slider setting (not the dipped value).
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -5.0)
|
||||
AudioManager.apply_dip("dialogue")
|
||||
AudioManager.clear_dip()
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-5.0, 0.01)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 4: Dip Call Site Wiring — D-069 / D-070 / D-071 / #530
|
||||
#
|
||||
# These tests verify GameState has the snapshot fields needed for #530 wiring.
|
||||
# The assertions on AudioManager dip activation are stubbed pending implementation.
|
||||
# ==============================================================================
|
||||
|
||||
func test_d069_game_state_current_dialogue_field_exists() -> void:
|
||||
## #530 precondition: GameState.current_dialogue is the trigger for dialogue dip.
|
||||
## Field must exist so #530 wiring can check it.
|
||||
assert_that("current_dialogue" in GameState).is_true()
|
||||
|
||||
|
||||
func test_d069_snapshot_with_dialogue_sets_current_dialogue() -> void:
|
||||
## #530 precondition: apply_snapshot() with current_dialogue populates GameState correctly.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_dialogue": {
|
||||
"npc_name": "Kael", "npc_entity_id": 2,
|
||||
"speech": "Haven't seen you around.", "options": [],
|
||||
},
|
||||
})
|
||||
assert_that(GameState.current_dialogue != null).is_true()
|
||||
assert_that(GameState.current_dialogue is Dictionary).is_true()
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
|
||||
|
||||
func test_d069_snapshot_without_dialogue_clears_current_dialogue() -> void:
|
||||
## #530 precondition: Snapshot without current_dialogue → current_dialogue is null.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []},
|
||||
})
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
assert_that(GameState.current_dialogue == null).is_true()
|
||||
|
||||
|
||||
func test_d069_dialogue_dip_wired_to_game_state_dialogue_activation() -> void:
|
||||
## D-069 / #530: When current_dialogue becomes active, apply_dip("dialogue") fires.
|
||||
## TODO(#530): Uncomment the assertion once call site is wired in sim_bridge/game_state.
|
||||
AudioManager.clear_dip()
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []},
|
||||
})
|
||||
## Precondition: dialogue IS active in GameState.
|
||||
assert_that(GameState.current_dialogue != null).is_true()
|
||||
## ASSERTION (activate once #530 is implemented):
|
||||
## assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
AudioManager.clear_dip()
|
||||
|
||||
|
||||
func test_d069_dialogue_dip_cleared_when_dialogue_ends() -> void:
|
||||
## D-069 / #530: When current_dialogue returns to null, clear_dip() fires.
|
||||
## TODO(#530): Uncomment the assertion once call site is wired.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []},
|
||||
})
|
||||
GameState.apply_snapshot({"tick": 2}) # dialogue ends
|
||||
## ASSERTION (activate once #530 is implemented):
|
||||
## assert_that(AudioManager.get_active_dip()).is_equal("")
|
||||
assert_that(GameState.current_dialogue == null).is_true()
|
||||
|
||||
|
||||
func test_d071_listening_focus_gate_is_30_ticks() -> void:
|
||||
## D-069 / D-071: ListeningFocus boost activates after 30+ stationary ticks.
|
||||
## The tick gate is the CALLER's responsibility per AudioManager comment.
|
||||
## This test documents the threshold so it doesn't silently drift.
|
||||
## Wiring via sim_bridge.gd tracking stationary_ticks lands in #530.
|
||||
const LISTENING_FOCUS_TICK_GATE := 30
|
||||
## Stub: verify the spec value is documented.
|
||||
assert_that(LISTENING_FOCUS_TICK_GATE).is_equal(30)
|
||||
|
||||
|
||||
func test_d070_no_ui_indicator_means_no_signal_named_confrontation_ui() -> void:
|
||||
## D-070: Confrontation muffling is felt, not announced. No UI indicator.
|
||||
## Verify AudioManager does not expose a confrontation_ui_shown signal.
|
||||
var signals: Array = AudioManager.get_signal_list().map(
|
||||
func(s: Dictionary) -> String: return s.name
|
||||
)
|
||||
assert_that(signals.has("confrontation_ui_shown")).is_false()
|
||||
assert_that(signals.has("listening_focus_shown")).is_false()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 5: Recognition Chime Onset — D-067 / #531
|
||||
#
|
||||
# Chime fires at ONSET of cognitive delay (when entity FIRST appears in
|
||||
# pending_recognitions), NOT at completion (when it leaves).
|
||||
# ==============================================================================
|
||||
|
||||
func test_d067_chime_recognition_constant_defined() -> void:
|
||||
## D-067 / D-038: AudioManager must expose CHIME_RECOGNITION asset key constant.
|
||||
assert_that("CHIME_RECOGNITION" in AudioManager).is_true()
|
||||
|
||||
|
||||
func test_d067_chime_recognition_matches_d038_asset_key() -> void:
|
||||
## D-067 / D-038: sfx_monologue_chime = "neural lattice firing" feel.
|
||||
## Key must match filename stem in res://audio/.
|
||||
assert_that(AudioManager.CHIME_RECOGNITION).is_equal("sfx_monologue_chime")
|
||||
|
||||
|
||||
func test_d067_play_routes_to_ui_sounds_bus_by_default() -> void:
|
||||
## D-067: play(CHIME_RECOGNITION) routes to BUS_UI_SOUNDS by default.
|
||||
## Chime is a cognitive/UI signal, not a world sound — must NOT go on WorldSFX.
|
||||
## Verify play() default bus is UISounds (the chime caller uses the default).
|
||||
## Edge: BUS_WORLD_SFX and BUS_UI_SOUNDS must be distinct.
|
||||
assert_that(AudioManager.BUS_WORLD_SFX).is_not_equal(AudioManager.BUS_UI_SOUNDS)
|
||||
|
||||
|
||||
func test_d067_play_chime_recognition_noop_when_asset_absent() -> void:
|
||||
## D-067 / D-038: play(CHIME_RECOGNITION) is a silent no-op if asset file is absent.
|
||||
## Client must not crash when audio branch has not yet provided the .ogg file.
|
||||
AudioManager.play(AudioManager.CHIME_RECOGNITION)
|
||||
## No assertion needed — absence of crash is the test.
|
||||
|
||||
|
||||
func test_d067_onset_is_when_remaining_equals_total_delay_ticks() -> void:
|
||||
## D-067: "Onset" of cognitive delay = first frame an entity appears in
|
||||
## pending_recognitions, at remaining_ticks == total_delay_ticks.
|
||||
## This is the moment the chime must fire.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"pending_recognitions": [
|
||||
{"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0,
|
||||
"remaining_ticks": 6, "total_delay_ticks": 6},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.pending_recognitions.size()).is_equal(1)
|
||||
var rec: Dictionary = GameState.pending_recognitions[0]
|
||||
## Onset condition: remaining == total (delay just started)
|
||||
assert_that(rec.remaining_ticks).is_equal(rec.total_delay_ticks)
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
|
||||
|
||||
func test_d067_completion_is_when_entity_absent_from_pending() -> void:
|
||||
## D-067: Recognition COMPLETES (blob transitions) when entity leaves pending_recognitions.
|
||||
## The chime must NOT fire at this point — it fired at onset.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"pending_recognitions": [
|
||||
{"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0,
|
||||
"remaining_ticks": 1, "total_delay_ticks": 6},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.pending_recognitions.size()).is_equal(1)
|
||||
## Completion: entity removed from array
|
||||
GameState.apply_snapshot({"tick": 2, "pending_recognitions": []})
|
||||
assert_that(GameState.pending_recognitions.size()).is_equal(0)
|
||||
## Chime state: AudioManager must not have a dip triggered by recognition.
|
||||
## (Chime is a play() call, not a dip — this verifies no side effects on dip state.)
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("")
|
||||
|
||||
|
||||
func test_d067_chime_fires_at_fog_entity_spawn_not_removal() -> void:
|
||||
## D-067 / #531: The chime call site in fog_entities.gd / entity_renderer.gd
|
||||
## must be inside the "new entity" branch (not entity.has(eid)), NOT the cleanup loop.
|
||||
## This test verifies FogEntities correctly identifies the onset condition.
|
||||
## An entity with remaining_ticks == total_delay_ticks is a NEW entity entering delay.
|
||||
var onset_tick := {"entity_id": 5, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"remaining_ticks": 6, "total_delay_ticks": 6}
|
||||
var mid_tick := {"entity_id": 5, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"remaining_ticks": 3, "total_delay_ticks": 6}
|
||||
## Frame 1: entity APPEARS (onset — chime should fire here)
|
||||
GameState.apply_snapshot({"tick": 1, "pending_recognitions": [onset_tick]})
|
||||
assert_that(GameState.pending_recognitions[0].remaining_ticks
|
||||
== GameState.pending_recognitions[0].total_delay_ticks).is_true()
|
||||
## Frame 2: entity mid-progress (chime must NOT re-fire)
|
||||
GameState.apply_snapshot({"tick": 2, "pending_recognitions": [mid_tick]})
|
||||
assert_that(GameState.pending_recognitions[0].remaining_ticks).is_equal(3)
|
||||
## Frame 3: entity completes (chime must NOT fire)
|
||||
GameState.apply_snapshot({"tick": 3, "pending_recognitions": []})
|
||||
assert_that(GameState.pending_recognitions.size()).is_equal(0)
|
||||
|
||||
|
||||
func test_d067_chime_duration_spec_is_300_to_400ms() -> void:
|
||||
## D-067: Chime duration is 300-400ms per spec. The asset (.ogg) carries this duration.
|
||||
## This test documents the spec range so asset authoring can be validated.
|
||||
## When sfx_monologue_chime.ogg is present, its AudioStream.get_length() should
|
||||
## return a value in this range.
|
||||
const CHIME_MIN_DURATION := 0.3
|
||||
const CHIME_MAX_DURATION := 0.4
|
||||
if not AudioManager.has_asset(AudioManager.CHIME_RECOGNITION):
|
||||
push_warning("TestAudioSprint13: sfx_monologue_chime asset absent — skip duration test")
|
||||
return
|
||||
var stream: AudioStream = AudioManager._registry.get(AudioManager.CHIME_RECOGNITION)
|
||||
if stream == null:
|
||||
push_warning("TestAudioSprint13: could not retrieve chime stream from registry")
|
||||
return
|
||||
assert_that(stream.get_length() >= CHIME_MIN_DURATION
|
||||
and stream.get_length() <= CHIME_MAX_DURATION).is_true()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 6: NPC Murmur Routing — D-072 / #533
|
||||
# Single universal asset, WorldSFX bus, no zone-specific variants.
|
||||
# ==============================================================================
|
||||
|
||||
func test_d072_world_sfx_bus_is_correct_for_murmur() -> void:
|
||||
## D-072 / #533: NPC murmur routes to WorldSFX bus per D-068 architecture.
|
||||
## BUS_WORLD_SFX must be "WorldSFX" — zone ambient conspicuousness is determined
|
||||
## by that bus's noise floor relative to the murmur volume.
|
||||
assert_that(AudioManager.BUS_WORLD_SFX).is_equal("WorldSFX")
|
||||
|
||||
|
||||
func test_d072_play_at_method_accepts_bus_parameter() -> void:
|
||||
## D-072 / #533: play_at() must accept an optional bus string parameter.
|
||||
## Proximity murmur uses play_at(asset_key, world_pos, BUS_WORLD_SFX).
|
||||
assert_that(AudioManager.has_method("play_at")).is_true()
|
||||
|
||||
|
||||
func test_d072_play_at_noop_when_murmur_asset_absent() -> void:
|
||||
## D-072 / D-038: sfx_npc_murmur.ogg arrives from audio branch (#532).
|
||||
## Until then, play_at("sfx_npc_murmur", ...) must be a silent no-op.
|
||||
AudioManager.play_at("sfx_npc_murmur", Vector2(100.0, 100.0), AudioManager.BUS_WORLD_SFX)
|
||||
## No assertion — absence of crash is the test.
|
||||
|
||||
|
||||
func test_d072_play_noop_when_murmur_asset_absent() -> void:
|
||||
## D-072 / D-038: play("sfx_npc_murmur", BUS_WORLD_SFX) also no-ops gracefully.
|
||||
AudioManager.play("sfx_npc_murmur", AudioManager.BUS_WORLD_SFX)
|
||||
## No assertion — absence of crash is the test.
|
||||
|
||||
|
||||
func test_d072_no_zone_specific_murmur_variants() -> void:
|
||||
## D-072: SINGLE universal murmur asset — no zone-specific variants.
|
||||
## "One murmur asset + zone-dependent conspicuousness creates the signal/noise
|
||||
## dynamic naturally." Zone-specific variants MUST NOT exist in the registry.
|
||||
assert_that(AudioManager.has_asset("sfx_npc_murmur_bar")).is_false()
|
||||
assert_that(AudioManager.has_asset("sfx_npc_murmur_corridor")).is_false()
|
||||
assert_that(AudioManager.has_asset("sfx_npc_murmur_hub")).is_false()
|
||||
assert_that(AudioManager.has_asset("sfx_npc_murmur_workplace")).is_false()
|
||||
|
||||
|
||||
func test_d072_murmur_does_not_use_ambient_bus() -> void:
|
||||
## D-072: Bar ambient murmur is baked into amb_bar_layer (continuous background).
|
||||
## The NPC proximity murmur is a SEPARATE event-driven asset on WorldSFX, not Ambient.
|
||||
## Verify bus constant distinction.
|
||||
assert_that(AudioManager.BUS_AMBIENT).is_not_equal(AudioManager.BUS_WORLD_SFX)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 7: AudioManager No-Op Fallback Sanity — D-068 / D-038
|
||||
# ==============================================================================
|
||||
|
||||
func test_d068_registry_size_is_non_negative() -> void:
|
||||
## D-068: Registry is empty when res://audio/ is absent, non-negative always.
|
||||
assert_that(AudioManager.get_registry_size() >= 0).is_true()
|
||||
|
||||
|
||||
func test_d068_play_loop_returns_null_for_missing_asset() -> void:
|
||||
## D-068 / D-038: play_loop() with unregistered asset key returns null (no crash).
|
||||
var result: Variant = AudioManager.play_loop("nonexistent_ambient_xyzabc")
|
||||
assert_that(result == null).is_true()
|
||||
|
||||
|
||||
func test_d068_stop_loop_noop_for_unknown_key() -> void:
|
||||
## D-068: stop_loop() on a key never started — no crash, no error.
|
||||
AudioManager.stop_loop("nonexistent_key_xyzabc")
|
||||
|
||||
|
||||
func test_d068_stop_all_loops_when_none_playing() -> void:
|
||||
## D-068: stop_all_loops() with no active ambient players — no crash.
|
||||
AudioManager.stop_all_loops()
|
||||
|
||||
|
||||
func test_d068_has_asset_returns_false_for_unknown_key() -> void:
|
||||
## D-068 / D-038: has_asset() guards all play methods. Verify false for unknown key.
|
||||
assert_that(AudioManager.has_asset("totally_unknown_asset_key_abc123")).is_false()
|
||||
|
||||
|
||||
func test_d068_play_noop_does_not_change_dip_state() -> void:
|
||||
## D-068: play() on a missing asset must not modify the dip state machine.
|
||||
## Verifies no-op fallback has zero side effects.
|
||||
AudioManager.apply_dip("dialogue")
|
||||
AudioManager.play("nonexistent_asset_xyzabc")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
|
||||
@@ -139,6 +139,12 @@ 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 REPLACE (not nest) this dip via apply_dip("confrontation")
|
||||
# in _start_confrontation_beat(). hide_dialogue()'s clear_dip() restores to base
|
||||
# volumes regardless of which profile was last active — intentional replacement semantics.
|
||||
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 +162,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()})
|
||||
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user