Files
TheTree_mock/scripts/ui/ui_audio_feedback.gd
T
Takeshi b84bade045 Save The Tree implementation and acceptance work in progress
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.
2026-10-11 15:58:00 +09:00

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}