Files
stickman/scripts/editors/prop/prop_theme.gd

243 lines
7.7 KiB
GDScript

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/props/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", "inspector_background": "#262C39"},
},
}
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 inspector_background_color() -> Color:
return color("ui.colors.inspector_background", Color("#262C39"))
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)