- 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.
241 lines
7.7 KiB
GDScript
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)
|