class_name PropTheme extends RefCounted ## Validated, cached presentation data for the Prop Editor. ## ## Mirrors SandboxTheme's loader mechanics (deep-merge, dotted-path accessors, ## warn-once, clamp, invalid-input fallback) but scoped to prop-editor tokens. ## Fallback hierarchy: prop_theme.json -> DEFAULT_CONFIG -> hardcoded ## PropBlock constants. Physics values (mass/friction/bounce) are deliberately ## NOT themeable; PropBlock's static preset tables remain the single source of ## truth. const PropBlockScript := preload("res://scripts/prop_block.gd") const SCHEMA_VERSION: String = "1.0" const DEFAULT_PATH: String = "res://prop_theme.json" const PRESET_KEYS: PackedStringArray = ["none", "wood", "rubber", "cardboard", "metal"] const DEFAULT_CONFIG: Dictionary = { "version": SCHEMA_VERSION, "outline_widths": {"default": 2.0, "min": 0.5, "max": 32.0}, "material_tints": { "wood": {"fill": "#8C6133", "outline": "#40290F"}, "rubber": {"fill": "#E63333", "outline": "#590D0D"}, "cardboard": {"fill": "#D9B873", "outline": "#66521D"}, "metal": {"fill": "#808C99", "outline": "#262E38"}, "none": {"fill": "#8C6133", "outline": "#26201E"}, }, "primitive_defaults": { "box": {"size_x": 48.0, "size_y": 48.0}, "ball": {"radius": 24.0}, "plank": {"length": 160.0, "thickness": 16.0}, "triangle": {"base": 56.0, "height": 48.0}, "line": {"length": 120.0}, "circle": {"radius": 40.0, "segments": 12}, }, "canvas": { "grid_size": 15, "background_color": "#1F1F1F", "grid_color": "#FFFFFF26", }, "ui": { "fonts": {"body": 18, "header": 24, "menu": 22, "menu_bar": 22, "mode": 26, "status": 18, "prop_name": 20, "context_menu": 22}, "hit_targets": {"button_min_height": 36, "row_min_height": 36, "mode_button_min_height": 56}, "colors": {"text": "#F2F2F2", "text_muted": "#9A9A9A", "border": "#FFFFFF33", "accent": "#4FA3FF"}, }, } var _config: Dictionary = DEFAULT_CONFIG.duplicate(true) 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, "PropTheme: '%s' was not found; using defaults." % path) return false var file: FileAccess = FileAccess.open(path, FileAccess.READ) if file == null: _warn_once(path, "PropTheme: '%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, "PropTheme: '%s' is malformed; using defaults." % path) return false _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, "PropTheme: 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, "PropTheme: 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) ## Material tint for a PropBlock.MaterialPreset value and channel ("fill" or ## "outline"). Hierarchy: theme file -> DEFAULT_CONFIG -> PropBlock.tint_for ## (outline's final fallback is the tint darkened 0.55). func material_tint(preset: int, channel: String) -> Color: var key: String = PRESET_KEYS[preset] if preset >= 0 and preset < PRESET_KEYS.size() else "none" var tint: Color = PropBlockScript.tint_for(preset) var fallback: Color = tint if channel == "fill" else tint.darkened(0.55) return color("material_tints.%s.%s" % [key, channel], fallback) func outline_width_default() -> float: return number("outline_widths.default", PropBlockScript.DEFAULT_OUTLINE_WIDTH, 0.0, 256.0) func outline_width_min() -> float: return number("outline_widths.min", 0.5, 0.0, 256.0) func outline_width_max() -> float: return number("outline_widths.max", 32.0, 0.0, 1024.0) ## Primitive default dimension, e.g. primitive_default("box", "size_x"). ## Falls back to the DEFAULT_CONFIG primitive table (which mirrors the ## PropUtils.create_* generator defaults). func primitive_default(primitive: String, key: String, fallback: float = 0.0) -> float: var table: Dictionary = value("primitive_defaults.%s" % primitive, {}) if table is Dictionary and (table as Dictionary).has(key): return number("primitive_defaults.%s.%s" % [primitive, key], fallback) var defaults: Dictionary = DEFAULT_CONFIG.get("primitive_defaults", {}) var entry: Dictionary = defaults.get(primitive, {}) return float(entry.get(key, fallback)) func grid_size() -> int: return integer("canvas.grid_size", 15, 1, 512) func canvas_background_color() -> Color: return color("canvas.background_color", Color("#1F1F1F")) func canvas_grid_color() -> Color: return color("canvas.grid_color", Color(1.0, 1.0, 1.0, 0.15)) # --------------------------------------------------------------------------- # Kid-friendly UI tokens (Round 2) # --------------------------------------------------------------------------- func font_body() -> int: return integer("ui.fonts.body", 18, 8, 72) func font_header() -> int: return integer("ui.fonts.header", 24, 8, 96) func font_menu() -> int: return integer("ui.fonts.menu", 22, 8, 72) func font_status() -> int: return integer("ui.fonts.status", 18, 8, 72) func font_mode() -> int: return integer("ui.fonts.mode", 26, 8, 96) func font_menu_bar() -> int: return integer("ui.fonts.menu_bar", 22, 8, 72) func font_prop_name() -> int: return integer("ui.fonts.prop_name", 20, 8, 96) func font_context_menu() -> int: return integer("ui.fonts.context_menu", 22, 8, 96) func button_min_height() -> int: return integer("ui.hit_targets.button_min_height", 36, 16, 200) func row_min_height() -> int: return integer("ui.hit_targets.row_min_height", 36, 16, 200) func mode_button_min_height() -> int: return integer("ui.hit_targets.mode_button_min_height", 56, 16, 200) func text_color() -> Color: return color("ui.colors.text", Color("#F2F2F2")) func text_muted_color() -> Color: return color("ui.colors.text_muted", Color("#9A9A9A")) func border_color() -> Color: return color("ui.colors.border", Color(1.0, 1.0, 1.0, 0.2)) func accent_color() -> Color: return color("ui.colors.accent", Color("#4FA3FF")) func invalidate_caches() -> void: _warned.clear() 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 _warn_once(key: String, message: String) -> void: if not _warned.has(key): _warned[key] = true push_warning(message)