Add headless regression tests for Prop Editor functionality
- Implement tests for PropFile, including preset conversion, file dictionary conversion, round-trip serialization, version rejection, default values for missing keys, flattening, sanitization, and save/load functionality. - Create tests for PropEditor, covering window title formatting, mode button text, safe saving, and recent file list management. - Add tests for PropTheme, validating fallback behavior, invalid input handling, and default values for primitives and UI tokens. - Introduce tests for ThumbnailCache, ensuring proper key formatting, PNG handling, and behavior with missing files.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://lophq30fv72p
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://v8gaqlxoei3a
|
||||
@@ -0,0 +1,334 @@
|
||||
class_name PropFile
|
||||
extends RefCounted
|
||||
## PropFile - Static schema + converters for .prop vector prop assets.
|
||||
##
|
||||
## The file stores the editor's full multi-shape stack (version "1.0") plus
|
||||
## enough provenance for later sandbox placement. flatten_to_payload() converts
|
||||
## the stack into the single-shape payload PropUtils.spawn_prop() already
|
||||
## consumes: frontmost closed polygon -> else circle -> else first shape.
|
||||
|
||||
const PropBlockScript := preload("res://scripts/prop_block.gd")
|
||||
const TerrainUtilsScript := preload("res://scripts/terrain_utils.gd")
|
||||
|
||||
const FILE_VERSION: String = "1.0"
|
||||
const FILE_EXT: String = ".prop"
|
||||
const DEFAULT_DIR: String = "res://props"
|
||||
const SUPPORTED_VERSIONS: Array[String] = ["1.0"]
|
||||
# index == PropBlock.MaterialPreset
|
||||
const PRESET_STRINGS: PackedStringArray = ["none", "wood", "rubber", "cardboard", "metal"]
|
||||
const SHAPE_STRINGS: PackedStringArray = ["polygon", "circle", "line"]
|
||||
const DEFAULT_RADIUS: float = 24.0
|
||||
const DEFAULT_CIRCLE_SEGMENTS: int = 12
|
||||
|
||||
|
||||
static func preset_to_string(preset: int) -> String:
|
||||
return PRESET_STRINGS[clampi(preset, 0, PRESET_STRINGS.size() - 1)]
|
||||
|
||||
|
||||
static func preset_from_string(s: String) -> int:
|
||||
var idx: int = PRESET_STRINGS.find(s.to_lower())
|
||||
if idx < 0:
|
||||
return PropBlockScript.MaterialPreset.NONE
|
||||
return idx
|
||||
|
||||
|
||||
static func shape_type_to_payload_type(s: String) -> int:
|
||||
if s.to_lower() == "circle":
|
||||
return PropBlockScript.ShapeType.CIRCLE
|
||||
return PropBlockScript.ShapeType.POLYGON
|
||||
|
||||
|
||||
## Sanitize a shape dictionary: polygon/line points run through
|
||||
## TerrainUtils.sanitize_points (grid snap, redundancy/collinear removal,
|
||||
## clockwise enforcement). Circles pass through unchanged (radius-based).
|
||||
static func sanitize_shape(shape: Dictionary, grid_size: float = TerrainUtilsScript.DEFAULT_GRID_SIZE) -> Dictionary:
|
||||
var result: Dictionary = shape.duplicate(true)
|
||||
var shape_type: String = str(result.get("shape_type", "polygon"))
|
||||
if shape_type == "circle":
|
||||
return result
|
||||
var pts: PackedVector2Array = _coerce_points(result.get("points", PackedVector2Array()))
|
||||
var cleaned: PackedVector2Array = TerrainUtilsScript.sanitize_points(pts, grid_size)
|
||||
var flags: PackedInt32Array = result.get("vertex_flags", PackedInt32Array())
|
||||
var kept_flags := PackedInt32Array()
|
||||
# Keep vertex flags aligned: sanitize only ever drops points, so walk the
|
||||
# cleaned list and retain a flag for each surviving vertex (match by index
|
||||
# order; approximate alignment is acceptable for write-only metadata).
|
||||
kept_flags.resize(cleaned.size())
|
||||
for i: int in mini(cleaned.size(), flags.size()):
|
||||
kept_flags[i] = flags[i]
|
||||
result["points"] = cleaned
|
||||
result["vertex_flags"] = kept_flags
|
||||
return result
|
||||
|
||||
|
||||
static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[Dictionary]) -> Dictionary:
|
||||
var serialized: Array = []
|
||||
for shape: Dictionary in shapes:
|
||||
serialized.append(_serialize_shape(shape))
|
||||
var now: String = Time.get_datetime_string_from_system()
|
||||
return {
|
||||
"version": FILE_VERSION,
|
||||
"prop_name": prop_name,
|
||||
"material_preset": preset_to_string(material_preset),
|
||||
"shapes": serialized,
|
||||
"metadata": {"created_at": now, "modified_at": now},
|
||||
}
|
||||
|
||||
|
||||
## Parse a file-level dictionary (or JSON string) into the editor model.
|
||||
## Returns {} on any schema error (with a push_warning), otherwise
|
||||
## { "prop_name": String, "material_preset": int, "shapes": Array[Dictionary] }.
|
||||
static func from_file_dict(data: Variant) -> Dictionary:
|
||||
if data is String:
|
||||
var parsed: Variant = JSON.parse_string(data as String)
|
||||
if not parsed is Dictionary:
|
||||
push_warning("PropFile: file data is not a valid JSON object.")
|
||||
return {}
|
||||
data = parsed
|
||||
if not data is Dictionary:
|
||||
push_warning("PropFile: file data is not a dictionary.")
|
||||
return {}
|
||||
var dict: Dictionary = data as Dictionary
|
||||
var version: String = str(dict.get("version", ""))
|
||||
if not SUPPORTED_VERSIONS.has(version):
|
||||
push_warning("PropFile: unsupported file version '%s'." % version)
|
||||
return {}
|
||||
|
||||
var preset: int = preset_from_string(str(dict.get("material_preset", "none")))
|
||||
var shapes: Array[Dictionary] = []
|
||||
var raw_shapes: Variant = dict.get("shapes", [])
|
||||
if raw_shapes is Array:
|
||||
for item: Variant in raw_shapes as Array:
|
||||
if item is Dictionary:
|
||||
shapes.append(_parse_shape(item as Dictionary, preset))
|
||||
return {
|
||||
"prop_name": str(dict.get("prop_name", "")),
|
||||
"material_preset": preset,
|
||||
"shapes": shapes,
|
||||
}
|
||||
|
||||
|
||||
## Deterministic conversion of the multi-shape stack into a single
|
||||
## PropUtils.spawn_prop payload. Rule: frontmost closed polygon -> else circle
|
||||
## -> else first shape. Returns {} when shapes is empty.
|
||||
static func flatten_to_payload(shapes: Array[Dictionary]) -> Dictionary:
|
||||
if shapes.is_empty():
|
||||
push_warning("PropFile: cannot flatten an empty shape stack.")
|
||||
return {}
|
||||
var chosen: Dictionary = {}
|
||||
var kind: String = "polygon"
|
||||
# 1. Frontmost closed polygon (scan back to front).
|
||||
for i: int in range(shapes.size() - 1, -1, -1):
|
||||
var sd: Dictionary = shapes[i]
|
||||
if str(sd.get("shape_type", "polygon")) == "polygon" and bool(sd.get("closed", true)):
|
||||
chosen = sd
|
||||
kind = "polygon"
|
||||
break
|
||||
# 2. Else frontmost circle.
|
||||
if chosen.is_empty():
|
||||
for i: int in range(shapes.size() - 1, -1, -1):
|
||||
var sd: Dictionary = shapes[i]
|
||||
if str(sd.get("shape_type", "polygon")) == "circle":
|
||||
chosen = sd
|
||||
kind = "circle"
|
||||
break
|
||||
# 3. Else the first shape (even a line) as a polygon.
|
||||
if chosen.is_empty() and not shapes.is_empty():
|
||||
chosen = shapes[0]
|
||||
kind = "polygon"
|
||||
|
||||
var payload: Dictionary = {}
|
||||
if kind == "circle":
|
||||
payload = {
|
||||
"type": PropBlockScript.ShapeType.CIRCLE,
|
||||
"radius": float(chosen.get("radius", DEFAULT_RADIUS)),
|
||||
}
|
||||
else:
|
||||
var pts: PackedVector2Array = _coerce_points(chosen.get("points", PackedVector2Array()))
|
||||
payload = {
|
||||
"type": PropBlockScript.ShapeType.POLYGON,
|
||||
"points": TerrainUtilsScript.sanitize_points(pts),
|
||||
}
|
||||
payload["fill_color"] = _color_from_hex(str(chosen.get("fill_color", "")), PropBlockScript.tint_for(PropBlockScript.MaterialPreset.NONE))
|
||||
payload["outline_color"] = _color_from_hex(str(chosen.get("outline_color", "")), PropBlockScript.DEFAULT_OUTLINE_COLOR)
|
||||
payload["outline_width"] = float(chosen.get("outline_width", PropBlockScript.DEFAULT_OUTLINE_WIDTH))
|
||||
return payload
|
||||
|
||||
|
||||
## Write `data` (a to_file_dict result) as pretty-printed JSON to `path`.
|
||||
static func save(path: String, data: Dictionary) -> bool:
|
||||
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_warning("PropFile: could not open '%s' for writing." % path)
|
||||
return false
|
||||
file.store_string(JSON.stringify(data, "\t"))
|
||||
file.close()
|
||||
return true
|
||||
|
||||
|
||||
## Atomic write mirroring StageSceneIO.write_file_safe: write a temp file,
|
||||
## swap the previous file to .bak, rename the temp into place, then remove the
|
||||
## backup. Used by the editor autosave so a crash mid-write cannot corrupt it.
|
||||
static func save_safe(path: String, data: Dictionary) -> bool:
|
||||
var tmp := path + ".tmp"
|
||||
var bak := path + ".bak"
|
||||
var file := FileAccess.open(tmp, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_warning("PropFile: could not write temporary file '%s'." % tmp)
|
||||
return false
|
||||
file.store_string(JSON.stringify(data, "\t"))
|
||||
file.flush()
|
||||
file.close()
|
||||
if FileAccess.file_exists(bak):
|
||||
DirAccess.remove_absolute(bak)
|
||||
if FileAccess.file_exists(path) and DirAccess.rename_absolute(path, bak) != OK:
|
||||
DirAccess.remove_absolute(tmp)
|
||||
return false
|
||||
if DirAccess.rename_absolute(tmp, path) != OK:
|
||||
if FileAccess.file_exists(bak):
|
||||
DirAccess.rename_absolute(bak, path)
|
||||
return false
|
||||
if FileAccess.file_exists(bak):
|
||||
DirAccess.remove_absolute(bak)
|
||||
return true
|
||||
|
||||
|
||||
## Read and parse a .prop file. Returns the raw dictionary on success, {}
|
||||
## on read/parse failure (with a push_warning). Schema validation (version,
|
||||
## shapes) happens in from_file_dict.
|
||||
static func load(path: String) -> Dictionary:
|
||||
if not FileAccess.file_exists(path):
|
||||
push_warning("PropFile: '%s' does not exist." % path)
|
||||
return {}
|
||||
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("PropFile: could not open '%s' for reading." % path)
|
||||
return {}
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not parsed is Dictionary:
|
||||
push_warning("PropFile: '%s' is not a valid JSON object." % path)
|
||||
return {}
|
||||
return parsed as Dictionary
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
static func _serialize_shape(shape: Dictionary) -> Dictionary:
|
||||
# Accept both internal (PackedVector2Array) and export (Array of {x,y})
|
||||
# point formats; a typed assignment would silently zero an Array of dicts.
|
||||
var points: PackedVector2Array = _coerce_points(shape.get("points", PackedVector2Array()))
|
||||
var points_arr: Array = []
|
||||
for pt: Vector2 in points:
|
||||
points_arr.append({"x": pt.x, "y": pt.y})
|
||||
var flags: PackedInt32Array = shape.get("vertex_flags", PackedInt32Array())
|
||||
var flags_arr: Array = []
|
||||
for i: int in flags.size():
|
||||
flags_arr.append(flags[i])
|
||||
var result := {
|
||||
"shape_type": str(shape.get("shape_type", "polygon")),
|
||||
"points": points_arr,
|
||||
"fill_color": str(shape.get("fill_color", "")),
|
||||
"outline_color": str(shape.get("outline_color", "")),
|
||||
"outline_width": float(shape.get("outline_width", PropBlockScript.DEFAULT_OUTLINE_WIDTH)),
|
||||
"closed": bool(shape.get("closed", false)),
|
||||
"vertex_flags": flags_arr,
|
||||
"reshaped": bool(shape.get("reshaped", false)),
|
||||
}
|
||||
if str(shape.get("shape_type", "")) == "circle":
|
||||
result["radius"] = float(shape.get("radius", DEFAULT_RADIUS))
|
||||
return result
|
||||
|
||||
|
||||
static func _parse_shape(shape: Dictionary, preset: int) -> Dictionary:
|
||||
var shape_type: String = str(shape.get("shape_type", "polygon"))
|
||||
if not SHAPE_STRINGS.has(shape_type):
|
||||
shape_type = "polygon"
|
||||
|
||||
var pts := PackedVector2Array()
|
||||
var pts_variant: Variant = shape.get("points", [])
|
||||
if pts_variant is Array:
|
||||
for p: Variant in pts_variant as Array:
|
||||
if p is Dictionary:
|
||||
var d: Dictionary = p as Dictionary
|
||||
pts.append(Vector2(float(d.get("x", 0.0)), float(d.get("y", 0.0))))
|
||||
elif p is Vector2:
|
||||
pts.append(p as Vector2)
|
||||
elif pts_variant is PackedVector2Array:
|
||||
pts = pts_variant as PackedVector2Array
|
||||
|
||||
var closed: bool
|
||||
if shape.has("closed"):
|
||||
closed = bool(shape.get("closed", false))
|
||||
else:
|
||||
closed = shape_type != "line"
|
||||
|
||||
var radius: float = float(shape.get("radius", DEFAULT_RADIUS))
|
||||
if shape_type == "circle" and pts.is_empty():
|
||||
pts = _circle_points(radius, DEFAULT_CIRCLE_SEGMENTS)
|
||||
|
||||
var vf := PackedInt32Array()
|
||||
var vf_variant: Variant = shape.get("vertex_flags", [])
|
||||
if vf_variant is Array:
|
||||
var vf_arr: Array = vf_variant as Array
|
||||
vf.resize(vf_arr.size())
|
||||
for i: int in vf_arr.size():
|
||||
vf[i] = int(vf_arr[i])
|
||||
elif vf_variant is PackedInt32Array:
|
||||
vf = vf_variant as PackedInt32Array
|
||||
if vf.size() != pts.size():
|
||||
vf.resize(pts.size())
|
||||
|
||||
var tint: Color = PropBlockScript.tint_for(preset)
|
||||
var fill_hex: String = str(shape.get("fill_color", ""))
|
||||
if fill_hex.is_empty():
|
||||
fill_hex = tint.to_html()
|
||||
var outline_hex: String = str(shape.get("outline_color", ""))
|
||||
if outline_hex.is_empty():
|
||||
outline_hex = tint.darkened(0.55).to_html()
|
||||
|
||||
var result := {
|
||||
"shape_type": shape_type,
|
||||
"points": pts,
|
||||
"radius": radius,
|
||||
"fill_color": fill_hex,
|
||||
"outline_color": outline_hex,
|
||||
"outline_width": float(shape.get("outline_width", PropBlockScript.DEFAULT_OUTLINE_WIDTH)),
|
||||
"closed": closed,
|
||||
"vertex_flags": vf,
|
||||
"reshaped": bool(shape.get("reshaped", false)),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
static func _circle_points(radius: float, segments: int) -> PackedVector2Array:
|
||||
var pts := PackedVector2Array()
|
||||
var seg: int = maxi(segments, 3)
|
||||
for i: int in seg:
|
||||
var angle: float = TAU * float(i) / float(seg)
|
||||
pts.append(Vector2(cos(angle), sin(angle)) * radius)
|
||||
return pts
|
||||
|
||||
|
||||
static func _color_from_hex(hex: String, fallback: Color) -> Color:
|
||||
if hex.is_empty():
|
||||
return fallback
|
||||
return Color.from_string(hex, fallback)
|
||||
|
||||
|
||||
## Accept either internal (PackedVector2Array) or export (Array of {x,y})
|
||||
## point formats.
|
||||
static func _coerce_points(variant: Variant) -> PackedVector2Array:
|
||||
var pts := PackedVector2Array()
|
||||
if variant is PackedVector2Array:
|
||||
pts = variant as PackedVector2Array
|
||||
elif variant is Array:
|
||||
for p: Variant in variant as Array:
|
||||
if p is Dictionary:
|
||||
var d: Dictionary = p as Dictionary
|
||||
pts.append(Vector2(float(d.get("x", 0.0)), float(d.get("y", 0.0))))
|
||||
elif p is Vector2:
|
||||
pts.append(p as Vector2)
|
||||
return pts
|
||||
@@ -0,0 +1 @@
|
||||
uid://rbk8by20x7g4
|
||||
@@ -0,0 +1,238 @@
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://qxwnxe3u1o33
|
||||
@@ -39,6 +39,14 @@ func prop_png(id: String) -> String:
|
||||
return PROP_DIR + "/" + id + "_v" + str(PROP_VERSION) + ".png"
|
||||
|
||||
|
||||
func prop_file_key(path: String) -> String:
|
||||
return "%s_%d" % [path.get_file().get_basename(), FileAccess.get_modified_time(path)]
|
||||
|
||||
|
||||
func prop_file_png(path: String) -> String:
|
||||
return PROP_DIR + "/" + prop_file_key(path) + ".png"
|
||||
|
||||
|
||||
func load_png(png_path: String) -> Texture2D:
|
||||
if _memory.has(png_path):
|
||||
return _memory[png_path] as Texture2D
|
||||
|
||||
Reference in New Issue
Block a user