265 lines
6.4 KiB
GDScript
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)
|