Files
ryan ddb298f631 Add visual harmonization tests for editor UI components and ground finish chooser
- Implemented `test_editor_visual_harmonization.gd` to validate the Stickman editor and prop editor UI layouts, ensuring proper functionality and visual consistency.
- Created `test_editor_visual_theme.gd` to verify the loading and application of visual themes in the editor, including semantic overrides and default values.
- Introduced `test_phase19_ground_finish_chooser.gd` to test the ground finish chooser's functionality, including layout responsiveness, selection behavior, and interaction with the stage.
- Added new scripts for ground finish preview and chooser UI components to enhance the editor's visual representation of ground finishes.
2026-10-23 15:20:12 -04:00

241 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 EditorVisualThemeScript := preload("res://scripts/shared/editor_ui/editor_visual_theme.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",
},
}
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
var _warned: Dictionary = {}
var _editor_visual_theme: EditorVisualTheme = EditorVisualThemeScript.new()
func load_from(path: String = DEFAULT_PATH) -> bool:
invalidate_caches()
_editor_visual_theme.load_from(EditorVisualTheme.DEFAULT_PATH)
_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))
# ---------------------------------------------------------------------------
# Compatibility accessors: editor chrome is owned by EditorVisualTheme.
# ---------------------------------------------------------------------------
func font_body() -> int:
return _editor_visual_theme.chrome_px(&"body", &"prop")
func font_header() -> int:
return _editor_visual_theme.chrome_px(&"section", &"prop")
func font_menu() -> int:
return _editor_visual_theme.chrome_px(&"menu", &"prop")
func font_status() -> int:
return _editor_visual_theme.chrome_px(&"status", &"prop")
func font_mode() -> int:
return _editor_visual_theme.chrome_px(&"mode", &"prop")
func font_menu_bar() -> int:
return _editor_visual_theme.chrome_px(&"menu", &"prop")
func font_prop_name() -> int:
return _editor_visual_theme.chrome_px(&"prop_name", &"prop")
func font_context_menu() -> int:
return _editor_visual_theme.chrome_px(&"context_menu", &"prop")
func button_min_height() -> int:
return _editor_visual_theme.chrome_px(&"button_min_height", &"prop")
func row_min_height() -> int:
return _editor_visual_theme.chrome_px(&"row_min_height", &"prop")
func mode_button_min_height() -> int:
return _editor_visual_theme.chrome_px(&"mode_button_min_height", &"prop")
func text_color() -> Color:
return _editor_visual_theme.chrome_color(&"text", &"prop")
func text_muted_color() -> Color:
return _editor_visual_theme.chrome_color(&"text_muted", &"prop")
func border_color() -> Color:
return _editor_visual_theme.chrome_color(&"border", &"prop")
func accent_color() -> Color:
return _editor_visual_theme.chrome_color(&"edit", &"prop")
func inspector_background_color() -> Color:
return _editor_visual_theme.chrome_color(&"surface", &"prop")
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)