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.
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
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}
|
||||
Reference in New Issue
Block a user