Files
stickman/scripts/prop_file.gd
ryan b66ded5f60 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.
2026-09-19 21:26:03 -04:00

335 lines
12 KiB
GDScript

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