class_name UIAudioFeedback extends Node ## Presentation-only feedback. No Runtime snapshot/RNG/command mutation. signal cue_requested(role: StringName, correlation: Dictionary) const UI_BUS := &"TheTreeUI" const SETTINGS_VERSION := 1 const DEFAULT_SETTINGS := {&"master_volume": 1.0, &"ui_volume": 0.5, &"muted": false} const CUES := { &"click": "res://assets/audio/ui/click_001.ogg", &"success": "res://assets/audio/ui/confirmation_001.ogg", &"refusal": "res://assets/audio/ui/error_008.ogg", &"notification": "res://assets/audio/ui/bong_001.ogg", } const MAX_POLYPHONY_PER_CUE := 2 const UI_LIMITER_CEILING_DB := -1.0 var playback_enabled := true var settings: Dictionary = DEFAULT_SETTINGS.duplicate(true) var settings_writable := true var settings_reason: StringName = &"" var _settings_path := "" var _settings_fingerprint := "" var _settings_config := ConfigFile.new() var _runtime: Variant var _epoch := 0 var _nonce := 0 var _active_submissions: Array[Dictionary] = [] var _armed: Dictionary = {} var _seen_foregrounds: Dictionary = {} var _players: Dictionary = {} var _owner_shutting_down := false func configure(runtime: Variant, settings_path := "", enable_playback := true) -> void: bind_runtime(runtime) playback_enabled = enable_playback _settings_path = settings_path read_settings() if is_inside_tree(): _apply_bus_settings() func _ready() -> void: process_mode = Node.PROCESS_MODE_ALWAYS _ensure_ui_bus() for role: StringName in CUES: if not ResourceLoader.exists(CUES[role], "AudioStream"): continue var stream := load(CUES[role]) as AudioStreamOggVorbis if stream == null: continue stream = stream.duplicate() as AudioStreamOggVorbis stream.loop = false var player := AudioStreamPlayer.new() player.name = "UICue_%s" % role player.stream = stream player.bus = UI_BUS player.max_polyphony = MAX_POLYPHONY_PER_CUE player.process_mode = Node.PROCESS_MODE_ALWAYS add_child(player) _players[role] = player _apply_bus_settings() func _exit_tree() -> void: _disconnect_runtime() _armed.clear() _active_submissions.clear() func prepare_owner_shutdown() -> void: _owner_shutting_down = true _disconnect_runtime() _armed.clear() _active_submissions.clear() _seen_foregrounds.clear() for player: AudioStreamPlayer in _players.values(): player.stop() player.stream = null _players.clear() func bind_runtime(runtime: Variant) -> void: _disconnect_runtime() _runtime = runtime begin_loaded_epoch() if is_instance_valid(_runtime) and _runtime.has_signal(&"command_completed"): if not _runtime.is_connected(&"command_completed", _on_command_completed): _runtime.connect(&"command_completed", _on_command_completed) func _disconnect_runtime() -> void: if is_instance_valid(_runtime) and _runtime.has_signal(&"command_completed"): if _runtime.is_connected(&"command_completed", _on_command_completed): _runtime.disconnect(&"command_completed", _on_command_completed) func begin_loaded_epoch(current_foreground: Variant = null) -> void: _epoch += 1 _armed.clear() _active_submissions.clear() _seen_foregrounds.clear() for player: AudioStreamPlayer in _players.values(): player.stop() observe_foreground(current_foreground, true) func submit_command( command_type: StringName, payload: Dictionary = {}, actor_id: StringName = &"player", target_id: StringName = &"", priority := 0, execute_at := -1, idempotency_key: StringName = &"" ) -> Variant: if not is_instance_valid(_runtime) or not _runtime.has_method(&"submit_command"): return null _nonce += 1 var intent := {&"epoch": _epoch, &"nonce": _nonce, &"consumed": false, &"buffer": {}} # Paused Runtime emits completion before returning. Buffer before the call. _active_submissions.append(intent) var result: Variant = _runtime.call(&"submit_command", command_type, payload, actor_id, target_id, priority, execute_at, idempotency_key) _active_submissions.erase(intent) if not result is CommandResult or result.is_replay or int(intent[&"epoch"]) != _epoch: return result # Enqueue/validation failure is authoritative, even if an ID was rejected. if not result.is_success(): _deliver_outcome(result, intent) return result var command_id: int = result.command_id var completed: Variant = intent[&"buffer"].get(command_id) var queue: Variant = _runtime.get(&"command_queue") if completed == null and queue != null and queue.has_method(&"get_processed_result"): completed = queue.call(&"get_processed_result", command_id) if completed is CommandResult: _deliver_outcome(completed, intent) elif _is_pending(queue, command_id): _armed[command_id] = intent # Unclassified acceptance is not evidence of successful completion. return result func _is_pending(queue: Variant, command_id: int) -> bool: if queue == null or command_id <= 0 or not queue.has_method(&"create_snapshot"): return false var snapshot: Dictionary = queue.call(&"create_snapshot") for command: Dictionary in snapshot.get("pending", []): if int(command.get("command_id", 0)) == command_id: return true return false func _on_command_completed(result: Variant) -> void: if not result is CommandResult: return var command_id: int = result.command_id if _armed.has(command_id): var intent: Dictionary = _armed[command_id] _armed.erase(command_id) _deliver_outcome(result, intent) for intent: Dictionary in _active_submissions: if command_id > 0: intent[&"buffer"][command_id] = result func _deliver_outcome(result: CommandResult, intent: Dictionary) -> void: if bool(intent[&"consumed"]) or int(intent[&"epoch"]) != _epoch: return intent[&"consumed"] = true if result.is_replay: return if result.is_success() and ( bool(result.details.get(&"unchanged", false)) or bool(result.details.get(&"noop", false)) or bool(result.details.get(&"no_op", false)) ): return _request_cue(&"success" if result.is_success() else &"refusal", { &"epoch": _epoch, &"intent": intent[&"nonce"], &"command_id": result.command_id, }) func reject_local(reason: StringName) -> void: _nonce += 1 _request_cue(&"refusal", {&"epoch": _epoch, &"intent": _nonce, &"reason": reason}) func observe_foreground(instance: Variant, silent := false) -> void: if instance == null or int(instance.state) != 2: return var instance_id := StringName(instance.event_instance_id) if instance_id.is_empty() or _seen_foregrounds.has(instance_id): return _seen_foregrounds[instance_id] = true if not silent: _request_cue(&"notification", {&"epoch": _epoch, &"event_instance_id": instance_id}) func bind_button(button: BaseButton) -> void: var callback := _on_button_pressed.bind(button) if not button.pressed.is_connected(callback): button.pressed.connect(callback) if button is OptionButton or button is MenuButton: var popup: PopupMenu = button.get_popup() if not popup.id_pressed.is_connected(_on_popup_item_pressed): popup.id_pressed.connect(_on_popup_item_pressed) func _on_button_pressed(button: BaseButton) -> void: if is_instance_valid(button) and not button.disabled: request_click() func _on_popup_item_pressed(_id: int) -> void: request_click() func request_click() -> void: _request_cue(&"click", {&"epoch": _epoch}) func _request_cue(role: StringName, correlation: Dictionary) -> void: if _owner_shutting_down: return if bool(settings[&"muted"]) or float(settings[&"master_volume"]) <= 0.0 or float(settings[&"ui_volume"]) <= 0.0: return cue_requested.emit(role, correlation.duplicate(true)) if playback_enabled and _players.has(role): (_players[role] as AudioStreamPlayer).play() func _ensure_ui_bus() -> void: if AudioServer.get_bus_index(UI_BUS) < 0: AudioServer.add_bus() AudioServer.set_bus_name(AudioServer.bus_count - 1, String(UI_BUS)) AudioServer.set_bus_send(AudioServer.get_bus_index(UI_BUS), &"Master") var limiter := AudioEffectHardLimiter.new() limiter.ceiling_db = UI_LIMITER_CEILING_DB AudioServer.add_bus_effect(AudioServer.get_bus_index(UI_BUS), limiter) func _apply_bus_settings() -> void: _ensure_ui_bus() if bool(settings[&"muted"]) or float(settings[&"master_volume"]) == 0.0 or float(settings[&"ui_volume"]) == 0.0: for player: AudioStreamPlayer in _players.values(): player.stop() for entry: Array in [[&"Master", &"master_volume"], [UI_BUS, &"ui_volume"]]: var index := AudioServer.get_bus_index(entry[0]) if index < 0: continue var volume := float(settings[entry[1]]) AudioServer.set_bus_volume_db(index, linear_to_db(volume) if volume > 0.0 else -80.0) AudioServer.set_bus_mute(index, bool(settings[&"muted"]) or volume == 0.0) func update_settings(value: Dictionary) -> bool: if not _valid_settings(value): return false settings = value.duplicate(true) _apply_bus_settings() return true func _valid_settings(value: Dictionary) -> bool: for key: StringName in [&"master_volume", &"ui_volume"]: var raw: Variant = value.get(key) if typeof(raw) not in [TYPE_INT, TYPE_FLOAT] or not is_finite(float(raw)) or float(raw) < 0.0 or float(raw) > 1.0: return false return typeof(value.get(&"muted")) == TYPE_BOOL func read_settings() -> void: settings = DEFAULT_SETTINGS.duplicate(true) settings_writable = true settings_reason = &"" _settings_config = ConfigFile.new() _settings_fingerprint = "" if _settings_path.is_empty() or not FileAccess.file_exists(_settings_path): return _settings_fingerprint = FileAccess.get_sha256(_settings_path) if _settings_config.load(_settings_path) != OK: settings_writable = false settings_reason = &"UNREADABLE_SETTINGS" return var version: Variant = _settings_config.get_value("schema", "version", 0) if typeof(version) != TYPE_INT or version != SETTINGS_VERSION: settings_writable = false settings_reason = &"UNSUPPORTED_SETTINGS_VERSION" return var loaded := {} for key: StringName in DEFAULT_SETTINGS: loaded[key] = _settings_config.get_value("audio", String(key), DEFAULT_SETTINGS[key]) if not _valid_settings(loaded): settings_writable = false settings_reason = &"INVALID_SETTINGS_VALUES" return settings = loaded func save_settings() -> Dictionary: if not settings_writable or _settings_path.is_empty(): return {&"ok": false, &"reason": settings_reason if not settings_reason.is_empty() else &"NO_SETTINGS_PATH"} if not _valid_settings(settings): return {&"ok": false, &"reason": &"INVALID_SETTINGS_VALUES"} var current := FileAccess.get_sha256(_settings_path) if FileAccess.file_exists(_settings_path) else "" if current != _settings_fingerprint: return {&"ok": false, &"reason": &"SETTINGS_CHANGED_EXTERNALLY"} var target := ProjectSettings.globalize_path(_settings_path) var directory_error := DirAccess.make_dir_recursive_absolute(target.get_base_dir()) if directory_error != OK and directory_error != ERR_ALREADY_EXISTS: return {&"ok": false, &"reason": &"SETTINGS_DIRECTORY_FAILED"} var temporary := "%s.tmp-%d-%d" % [target, OS.get_process_id(), Time.get_ticks_usec()] if FileAccess.file_exists(temporary): return {&"ok": false, &"reason": &"SETTINGS_TEMP_EXISTS"} _settings_config.set_value("schema", "version", SETTINGS_VERSION) for key: StringName in DEFAULT_SETTINGS: _settings_config.set_value("audio", String(key), settings[key]) var error := _settings_config.save(temporary) if error != OK: if FileAccess.file_exists(temporary): DirAccess.remove_absolute(temporary) return {&"ok": false, &"reason": &"SETTINGS_WRITE_FAILED"} current = FileAccess.get_sha256(_settings_path) if FileAccess.file_exists(_settings_path) else "" if current != _settings_fingerprint: DirAccess.remove_absolute(temporary) return {&"ok": false, &"reason": &"SETTINGS_CHANGED_EXTERNALLY"} if DirAccess.rename_absolute(temporary, target) != OK: DirAccess.remove_absolute(temporary) return {&"ok": false, &"reason": &"SETTINGS_REPLACE_FAILED"} _settings_fingerprint = FileAccess.get_sha256(_settings_path) return {&"ok": true}