chobit/shared/godot/autoloads/app_state.gd
2026-03-29 23:25:08 -07:00

265 lines
6.4 KiB
GDScript

extends Node # gdlint: ignore = max-public-methods
## Single source of truth for ALL persistent state across the entire app.
## JSON file at user://app_state.json — zero external dependencies.
##
## In-memory Dictionary with coalesced flushes (one write per frame max).
## GDScript consumers use typed getters/setters.
## External processes (tray, etc.) access state via UDP commands in
## tray_listener.gd using generic get_section/set_section ops.
const SAVE_PATH: String = "user://app_state.json"
const BUNDLED_DEFAULTS: String = "res://config/chobit.cfg"
var _state: Dictionary = {}
var _dirty: bool = false
func _ready() -> void:
_load()
tree_exiting.connect(_flush_sync)
# -- Generic section access (used by tray_listener for external clients) ------
func get_section(name: String) -> Dictionary:
return _section(name).duplicate()
func set_section(name: String, data: Dictionary) -> void:
_state[name] = data
_mark_dirty()
func get_all() -> Dictionary:
return _state.duplicate(true)
# -- Window ------------------------------------------------------------------
func get_window_x() -> int:
return _section("window").get("x", -1)
func get_window_y() -> int:
return _section("window").get("y", -1)
func set_window_position(x: int, y: int) -> void:
var win := _section("window")
win["x"] = x
win["y"] = y
_mark_dirty()
func get_zoom() -> float:
return _section("window").get("zoom", 0.5)
func set_zoom(value: float) -> void:
_section("window")["zoom"] = value
_mark_dirty()
# -- Avatar -------------------------------------------------------------------
func get_avatar_rotation_y() -> float:
return _section("avatar").get("rotation_y", 0.0)
func set_avatar_rotation_y(value: float) -> void:
_section("avatar")["rotation_y"] = value
_mark_dirty()
# -- Sounds -------------------------------------------------------------------
func get_sound(slot: String) -> String:
return _section("sounds").get(slot, "")
func set_sound(slot: String, value: String) -> void:
_section("sounds")[slot] = value
_mark_dirty()
func has_sound(slot: String) -> bool:
return _section("sounds").has(slot)
# -- Backend ------------------------------------------------------------------
func get_backend(key: String, fallback: String) -> String:
return _section("backend").get(key, fallback)
func set_backend(key: String, value: String) -> void:
_section("backend")[key] = value
_mark_dirty()
# -- Companion ----------------------------------------------------------------
func get_companion(key: String, fallback: Variant) -> Variant:
return _section("companion").get(key, fallback)
func set_companion(key: String, value: Variant) -> void:
_section("companion")[key] = value
_mark_dirty()
# -- Camera / Tray ------------------------------------------------------------
func get_camera_enabled() -> bool:
return bool(_section("tray").get("camera_capture_enabled", false))
func set_camera_enabled(value: bool) -> void:
_section("tray")["camera_capture_enabled"] = value
_mark_dirty()
func get_active_camera() -> int:
return int(_section("tray").get("active_camera", 0))
func set_active_camera(index: int) -> void:
_section("tray")["active_camera"] = index
_mark_dirty()
func get_camera_rect() -> Dictionary:
var cr: Variant = _section("tray").get("camera_rect")
return cr if cr is Dictionary else {}
func set_camera_rect(rect: Dictionary) -> void:
_section("tray")["camera_rect"] = rect
_mark_dirty()
func get_focus_steal_cooldown() -> float:
return float(_section("tray").get("behavior_settings", {}).get("focus_steal_cooldown", 15.0))
func set_focus_steal_cooldown(value: float) -> void:
var bs: Dictionary = _section("tray").get("behavior_settings", {})
bs["focus_steal_cooldown"] = value
_section("tray")["behavior_settings"] = bs
_mark_dirty()
func get_gaze_duration() -> float:
return float(_section("tray").get("behavior_settings", {}).get("gaze_duration_s", 3.0))
func set_gaze_duration(value: float) -> void:
var bs: Dictionary = _section("tray").get("behavior_settings", {})
bs["gaze_duration_s"] = value
_section("tray")["behavior_settings"] = bs
_mark_dirty()
func get_gaze_margin() -> int:
return int(_section("tray").get("behavior_settings", {}).get("gaze_margin", 50))
func set_gaze_margin(value: int) -> void:
var bs: Dictionary = _section("tray").get("behavior_settings", {})
bs["gaze_margin"] = value
_section("tray")["behavior_settings"] = bs
_mark_dirty()
func get_snap_enabled() -> bool:
return bool(_section("window").get("snap_enabled", true))
func set_snap_enabled(value: bool) -> void:
_section("window")["snap_enabled"] = value
_mark_dirty()
# -- Internal -----------------------------------------------------------------
func _section(name: String) -> Dictionary:
if not _state.has(name):
_state[name] = {}
return _state[name]
func _mark_dirty() -> void:
if not _dirty:
_dirty = true
call_deferred("_flush")
func _flush() -> void:
if not _dirty:
return
_dirty = false
_write_json()
func _flush_sync() -> void:
if _dirty:
_dirty = false
_write_json()
func _write_json() -> void:
var json_str := JSON.stringify(_state, "\t")
var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file == null:
var err := FileAccess.get_open_error()
push_error("AppState: Failed to open %s (error %d)" % [SAVE_PATH, err])
return
file.store_string(json_str)
# -- Load ---------------------------------------------------------------------
func _load() -> void:
if _load_json():
FlightRecorder.record("app_state.loaded", "Loaded from %s" % SAVE_PATH, {})
return
_load_bundled_defaults()
FlightRecorder.record("app_state.defaults", "Using bundled defaults", {})
func _load_json() -> bool:
if not FileAccess.file_exists(SAVE_PATH):
return false
var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
if file == null:
return false
var content := file.get_as_text()
var json := JSON.new()
if json.parse(content) != OK:
push_error("AppState: Failed to parse %s%s" % [SAVE_PATH, json.get_error_message()])
return false
if json.data is Dictionary:
_state = json.data
return true
return false
func _load_bundled_defaults() -> void:
var cfg := ConfigFile.new()
if cfg.load(BUNDLED_DEFAULTS) != OK:
return
for section: String in cfg.get_sections():
if not _state.has(section):
_state[section] = {}
for key: String in cfg.get_section_keys(section):
_state[section][key] = cfg.get_value(section, key)