Preserve production code, regression fixtures, audio assets and provenance, test infrastructure, and the original-completion plan and progress ledger. Final game and Windows acceptance remain pending; excluded local artifacts and native experiments are not part of this snapshot.
330 lines
12 KiB
GDScript
330 lines
12 KiB
GDScript
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}
|