Files
stickman/scripts/sandbox_theme.gd
T
ryan 730a9a966b feat: Implement Live Director System for interactive simulation control
- Added waypoint context updates to handle action queue statuses in `waypoint_context.gd`.
- Introduced new scene file `manwalk.scene` with terrain and stickman setup.
- Created architecture summary for the Live Director System detailing core state transitions, physics mechanics, UI elements, and action execution.
- Specified class contracts and interfaces for the Live Director implementation in `phase_12_live_director.md`.
- Defined state transitions, input handling, and queue editing mechanics for the Live Director feature.
2026-09-24 15:25:10 -04:00

181 lines
9.9 KiB
GDScript

class_name SandboxTheme
extends RefCounted
## Validated, cached presentation data for the Sandbox Stage.
const SCHEMA_VERSION: String = "2.0"
const DEFAULT_PATH: String = "res://sandbox_theme.json"
const DEFAULT_CONFIG: Dictionary = {
"version": SCHEMA_VERSION,
"global": {"font_preset": "ui", "font_size": 18, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0", "bg_color": "#171B24F2", "surface_color": "#252B38FA", "border_color": "#FFFFFF33", "border_width": 1, "corner_radius": 10, "padding": 8, "spacing": 8},
"fonts": {"ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": {"ui": {"label": "Standard", "path": ""}}, "sizes": {"action_popup": 24, "action_popup_emoji": 22, "assignment_badge": 20, "rule_label": 18, "status_pill": 18, "tooltip": 18, "file_menu": 22, "file_menu_popup": 26, "mode_switcher": 24, "timestamp_badge": 22, "queue_panel": 22, "rule_panel": 22, "action_editor": 22, "rule_editor": 22, "panel_row": 20, "panel_title": 24, "asset_label": 18, "asset_badge": 14}, "flags": {"panel_title_bold": true, "rule_label_bold": false, "badge_bold": true, "action_popup_bold": false, "action_popup_italic": false}},
"mode_colors": {"edit": "#22C6FF", "direct": "#FFB300", "play": "#33DD77", "guide": "#22C6FF"},
"grid": {"snap_size": 15.0},
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "timestamp_badge": {"corner_radius": 10, "padding": 8, "bg_color": "#171B24F2"}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[620,300], "font_size":22, "message_font_size":26}},
"popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156]}},
"palette": {"style_defaults": {}, "dock_width": 116, "card_size": [96, 94], "icon_size": 30, "drag_threshold": 8, "selected_border_width": 3},
"overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "completed_path_alpha": 0.20, "completed_path_color": "#959EAD", "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}},
"widgets": {"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"], "size_presets": [{"id":"medium", "label":"Medium", "value":28}], "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]}
}
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
var _font_cache: Dictionary = {}
var _style_cache: Dictionary = {}
var _control_theme: Theme = null
var _warned: Dictionary = {}
func load_from(path: String = DEFAULT_PATH) -> bool:
invalidate_caches()
_config = DEFAULT_CONFIG.duplicate(true)
if not FileAccess.file_exists(path):
_warn_once(path, "SandboxTheme: '%s' was not found; using defaults." % path)
return false
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
if file == null:
_warn_once(path, "SandboxTheme: '%s' could not be read; using defaults." % path)
return false
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not parsed is Dictionary:
_warn_once(path, "SandboxTheme: '%s' is malformed; using defaults." % path)
return false
_apply_legacy_aliases(parsed as Dictionary)
_config = _deep_merge(_config, parsed as Dictionary)
return true
func raw_config() -> Dictionary:
return _config.duplicate(true)
func value(path: String, fallback: Variant = null) -> Variant:
var cursor: Variant = _config
for piece: String in path.split("."):
if not cursor is Dictionary or not (cursor as Dictionary).has(piece):
return fallback
cursor = (cursor as Dictionary)[piece]
return cursor
func color(path: String, fallback: Color) -> Color:
var raw: Variant = value(path, null)
if not raw is String:
return fallback
var text: String = String(raw)
if not text.begins_with("#") or (text.length() != 7 and text.length() != 9):
_warn_once(path, "SandboxTheme: invalid color at '%s'." % path)
return fallback
return Color.from_string(text, fallback)
func number(path: String, fallback: float, minimum: float = -INF, maximum: float = INF) -> float:
var raw: Variant = value(path, fallback)
if not raw is int and not raw is float:
_warn_once(path, "SandboxTheme: invalid number at '%s'." % path)
return fallback
var n: float = float(raw)
if not is_finite(n): return fallback
return clampf(n, minimum, maximum)
func integer(path: String, fallback: int, minimum: int = -2147483648, maximum: int = 2147483647) -> int:
return clampi(int(roundi(number(path, float(fallback), float(minimum), float(maximum)))), minimum, maximum)
func font(component_path: String = "", preset_id: String = "ui") -> Font:
var path: String = ""
if not component_path.is_empty(): path = String(value(component_path + ".font_path", ""))
if path.is_empty(): path = String(value("fonts.presets.%s.path" % preset_id, ""))
if path.is_empty(): path = String(value("fonts.ui_font", ""))
if path.is_empty(): return ThemeDB.fallback_font
if _font_cache.has(path): return _font_cache[path]
var resource: Resource = ResourceLoader.load(path) if ResourceLoader.exists(path) else null
if resource is Font:
_font_cache[path] = resource
return resource as Font
_warn_once("font." + path, "SandboxTheme: font '%s' is unavailable; using fallback." % path)
return ThemeDB.fallback_font
func emoji_font() -> Font:
var path: String = String(value("fonts.emoji_font", ""))
if path.is_empty(): return font()
return font("", "emoji") if ResourceLoader.exists(path) else font()
func style(component_path: String) -> Dictionary:
var result: Dictionary = {}
var section: String = component_path.get_slice(".", 0)
result = _deep_merge(result, value("global", {}))
result = _deep_merge(result, value(section + ".style_defaults", {}))
result = _deep_merge(result, value(component_path, {}))
return result
func stylebox(component_path: String) -> StyleBoxFlat:
var cfg: Dictionary = style(component_path)
var key: String = JSON.stringify(cfg)
if not _style_cache.has(key):
var box := StyleBoxFlat.new()
box.bg_color = _style_color(cfg, "bg_color", color("global.surface_color", Color("#252B38")))
box.border_color = _style_color(cfg, "border_color", color("global.border_color", Color("#FFFFFF33")))
box.set_border_width_all(int(cfg.get("border_width", integer("global.border_width", 1, 0, 16))))
box.set_corner_radius_all(int(cfg.get("corner_radius", integer("global.corner_radius", 10, 0, 64))))
var padding: int = int(cfg.get("padding", integer("global.padding", 8, 0, 64)))
box.content_margin_left = padding; box.content_margin_right = padding
box.content_margin_top = padding; box.content_margin_bottom = padding
_style_cache[key] = box
return (_style_cache[key] as StyleBoxFlat).duplicate()
func control_theme() -> Theme:
if _control_theme == null:
_control_theme = Theme.new()
_control_theme.default_font = font()
_control_theme.default_font_size = integer("global.font_size", 18, 8, 96)
return _control_theme
func swatches() -> Array[Color]:
var result: Array[Color] = []
for item: Variant in value("widgets.swatches", []):
if item is String: result.append(Color.from_string(String(item), Color.WHITE))
return result
func size_presets() -> Array[Dictionary]:
var result: Array[Dictionary] = []
for item: Variant in value("widgets.size_presets", []):
if item is Dictionary and not String((item as Dictionary).get("id", "")).is_empty(): result.append((item as Dictionary).duplicate(true))
if result.is_empty(): result.append({"id":"medium", "label":"Medium", "value":28})
return result
func font_presets() -> Array[Dictionary]:
var result: Array[Dictionary] = []
var presets: Dictionary = value("fonts.presets", {})
for id: String in presets:
var entry: Dictionary = presets[id] if presets[id] is Dictionary else {}
result.append({"id": id, "label": String(entry.get("label", id.capitalize())), "font": font("", id)})
if result.is_empty(): result.append({"id":"ui", "label":"Standard", "font":font()})
return result
func emoji_items() -> Array[String]:
var result: Array[String] = []
for item: Variant in value("widgets.emoji", []): if item is String: result.append(String(item))
return result
func invalidate_caches() -> void:
_font_cache.clear(); _style_cache.clear(); _control_theme = null; _warned.clear()
func _apply_legacy_aliases(source: Dictionary) -> void:
var modes: Dictionary = source.get("mode_colors", {})
for pair: Array in [["edit_accent","edit"], ["direct_accent","direct"], ["play_accent","play"], ["guide_line","guide"]]:
if modes.has(pair[0]) and not modes.has(pair[1]): modes[pair[1]] = modes[pair[0]]
var fonts: Dictionary = source.get("fonts", {})
var sizes: Dictionary = fonts.get("sizes", {})
for name: String in ["action_popup", "action_popup_emoji", "assignment_badge", "rule_label", "status_pill", "tooltip", "queue_panel", "rule_panel", "action_editor", "rule_editor", "panel_row", "panel_title"]:
var legacy: String = name + "_font_size"
if fonts.has(legacy) and not sizes.has(name): sizes[name] = fonts[legacy]
fonts["sizes"] = sizes; source["fonts"] = fonts; source["mode_colors"] = modes
func _deep_merge(base: Dictionary, overrides: Dictionary) -> Dictionary:
var merged: Dictionary = base.duplicate(true)
for key: Variant in overrides:
if merged.get(key) is Dictionary and overrides[key] is Dictionary: merged[key] = _deep_merge(merged[key], overrides[key])
else: merged[key] = overrides[key]
return merged
func _style_color(cfg: Dictionary, key: String, fallback: Color) -> Color:
return Color.from_string(String(cfg.get(key, "")), fallback)
func _warn_once(key: String, message: String) -> void:
if not _warned.has(key): _warned[key] = true; push_warning(message)