feat: Integrate custom prop library and sandbox functionality

- Added support for custom props in the sandbox, including loading, saving, and rendering.
- Implemented `PropLibrary` for managing custom prop entries and scanning for `.prop` files.
- Introduced `PropVisuals` for shared visual and collision representation of custom props.
- Enhanced `StageSpawner` to handle spawning of custom props with layered visuals and collision hulls.
- Created `MissingPropPlaceholder` to manage missing custom props during scene reloads.
- Updated thumbnail generation to support custom props with unique keys based on file paths.
- Added tests for custom prop lifecycle, authored visual values, and missing prop handling.
- Refactored existing code to accommodate new prop functionalities and ensure backward compatibility.
This commit is contained in:
2026-09-21 23:13:35 -04:00
parent 00abacc7b2
commit 97f3730413
19 changed files with 581 additions and 23 deletions
+4
View File
@@ -50,6 +50,9 @@ This document tracks known technical debt, optimization opportunities, and minor
| 38 | **Phase 9a preview does not establish runtime attachment parity** — the Prop Editor uses a private, non-physics, full-multi-shape rig preview with editor-only facing and z-order conventions. It intentionally proves anchor authoring and arm-IK evaluation, not Sandbox loading, collision, physics, mirroring, per-limb interleaving, or attachment lifecycle. | Medium | Open | A future runtime spec must independently validate the anchor transform against gameplay animation/physics and decide its visual representation, sockets or solved-wrist API, mirror strategy, collision/freeze behavior, per-limb draw order, release/ragdoll handling, and multi-shape fidelity. Do not treat preview acceptance tests as runtime tests. (2026-09-20) | | 38 | **Phase 9a preview does not establish runtime attachment parity** — the Prop Editor uses a private, non-physics, full-multi-shape rig preview with editor-only facing and z-order conventions. It intentionally proves anchor authoring and arm-IK evaluation, not Sandbox loading, collision, physics, mirroring, per-limb interleaving, or attachment lifecycle. | Medium | Open | A future runtime spec must independently validate the anchor transform against gameplay animation/physics and decide its visual representation, sockets or solved-wrist API, mirror strategy, collision/freeze behavior, per-limb draw order, release/ragdoll handling, and multi-shape fidelity. Do not treat preview acceptance tests as runtime tests. (2026-09-20) |
| 39 | **Phase 9b prop centering is geometric AABB centering, not physical center of mass** — `Center Prop` uses the union AABB of internal polygon/line/circle points and deliberately excludes outline expansion, anchors, material mass, and collision geometry. | Low | Open | This is deterministic and appropriate for the fixed authoring origin, but a future physics-pivot workflow may need a separately designed center-of-mass or collision-bounds command. Phase 9b keeps canonical `origin` fixed/serialized, hides it from editing, never auto-centers after edits, and uses an explicit-save warning heuristic of `max(1024 px, 2 × bounds diagonal)`. Document-wide undo remains tracked separately by #37. (2026-09-20) | | 39 | **Phase 9b prop centering is geometric AABB centering, not physical center of mass** — `Center Prop` uses the union AABB of internal polygon/line/circle points and deliberately excludes outline expansion, anchors, material mass, and collision geometry. | Low | Open | This is deterministic and appropriate for the fixed authoring origin, but a future physics-pivot workflow may need a separately designed center-of-mass or collision-bounds command. Phase 9b keeps canonical `origin` fixed/serialized, hides it from editing, never auto-centers after edits, and uses an explicit-save warning heuristic of `max(1024 px, 2 × bounds diagonal)`. Document-wide undo remains tracked separately by #37. (2026-09-20) |
| 40 | **Phase 9c walking preview uses exclusive gait-or-manual ownership of the selected hand target** — the private AnimationPlayer owns the target while gait plays; beginning a cyan-target drag pauses the gait before pointer input takes ownership. | Low | Open | This deterministic policy prevents AnimationPlayer and manual IK from overwriting the same Marker2D in one frame. It means an author cannot add a live hand offset while the walk continues. If that becomes valuable, design a preview-only additive offset/blend layer rather than relying on process order. The gait remains an in-place, non-physics authoring preview and is not navigation/runtime parity (#38). (2026-09-21) | | 40 | **Phase 9c walking preview uses exclusive gait-or-manual ownership of the selected hand target** — the private AnimationPlayer owns the target while gait plays; beginning a cyan-target drag pauses the gait before pointer input takes ownership. | Low | Open | This deterministic policy prevents AnimationPlayer and manual IK from overwriting the same Marker2D in one frame. It means an author cannot add a live hand offset while the walk continues. If that becomes valuable, design a preview-only additive offset/blend layer rather than relying on process order. The gait remains an in-place, non-physics authoring preview and is not navigation/runtime parity (#38). (2026-09-21) |
| 41 | **Phase 10 custom prop collision uses one convex hull** — the planned Sandbox prop renders every authored layer, but collision encloses all valid closed-shape points in one solid hull. | Low | Planned | User-approved in `specs/phase_10_prop_integration.md`: one fixture keeps dynamic rigid bodies stable and avoids overlapping per-shape contacts. Hollows and concavities collide as filled space; revisit compound collision only if playtesting shows material misses. `PropFile.flatten_to_payload()` remains for legacy single-shape consumers; #29 stays open until implementation lands. (2026-09-21) |
| 42 | **Phase 10 Browse keeps external `.prop` paths as scene asset IDs** — arbitrary filesystem files are used in place rather than copied into `res://props/`. | Medium | Planned | This matches stickman Browse and avoids filename/import conflicts, but scenes depend on the external path remaining valid. A missing custom prop becomes an inert placeholder that preserves its original scene entry and rule references through later saves; restoring the file and reloading resolves it. Existing missing-stickman rule skipping is unchanged. See `specs/phase_10_prop_integration.md`. (2026-09-21) |
| 43 | **Phase 10 thumbnail invalidation relies on path plus mtime** — custom cache keys will hash the full canonical path and include file modification time. | Low | Planned | This prevents equal basenames in different folders from sharing a PNG, while retaining cheap refresh checks. A filesystem with coarse timestamp resolution can miss two same-tick edits; content hashing is deferred unless observed. Cleanup must not remove built-in or unrelated external thumbnails. See `specs/phase_10_prop_integration.md`. (2026-09-21) |
--- ---
@@ -96,6 +99,7 @@ This document tracks known technical debt, optimization opportunities, and minor
| 2026-09-20 | Phase 9a Prop Attach specification written and refined (`specs/phase_9a_prop_attach.md`). Approved architecture: append Attach to the existing Arrange/Reshape system; additive role-based `.prop` anchors with a fixed synthesized `origin`; and an isolated Prop Editor stickman preview that uses real arm IK while showing the full authored prop. All Sandbox/custom-prop loading, attachment, sockets, physics/collision, lifecycle, and runtime tests are explicitly deferred. Logged #36 (all gameplay consumers deferred), #37 (document-wide undo remains absent), and #38 (editor preview is not a runtime-parity guarantee). Existing #29 remains open independently because the current runtime still flattens multi-shape props. | | 2026-09-20 | Phase 9a Prop Attach specification written and refined (`specs/phase_9a_prop_attach.md`). Approved architecture: append Attach to the existing Arrange/Reshape system; additive role-based `.prop` anchors with a fixed synthesized `origin`; and an isolated Prop Editor stickman preview that uses real arm IK while showing the full authored prop. All Sandbox/custom-prop loading, attachment, sockets, physics/collision, lifecycle, and runtime tests are explicitly deferred. Logged #36 (all gameplay consumers deferred), #37 (document-wide undo remains absent), and #38 (editor preview is not a runtime-parity guarantee). Existing #29 remains open independently because the current runtime still flattens multi-shape props. |
| 2026-09-20 | Phase 9b Prop Centering specification written (`specs/phase_9b_prop_centering.md`). Approved architecture: hide canonical origin from editable anchors while retaining it in `.prop` v1.0; draw a passive constant-screen-size `(0,0)` marker in all three modes; add an always-visible Center Prop document transaction; center the authoring camera on origin for startup/New/load/reset and preserve zoom after centering; and warn explicit saves with Center & Save / Save Anyway / Cancel when displacement exceeds `max(1024 px, 2 × bounds diagonal)`. Autosave stays silent and automatic recenter-on-edit is explicitly rejected. Logged #39 (AABB authoring center is not a future physics center of mass); #37 continues to track document-wide undo. | | 2026-09-20 | Phase 9b Prop Centering specification written (`specs/phase_9b_prop_centering.md`). Approved architecture: hide canonical origin from editable anchors while retaining it in `.prop` v1.0; draw a passive constant-screen-size `(0,0)` marker in all three modes; add an always-visible Center Prop document transaction; center the authoring camera on origin for startup/New/load/reset and preserve zoom after centering; and warn explicit saves with Center & Save / Save Anyway / Cancel when displacement exceeds `max(1024 px, 2 × bounds diagonal)`. Autosave stays silent and automatic recenter-on-edit is explicitly rejected. Logged #39 (AABB authoring center is not a future physics center of mass); #37 continues to track document-wide undo. |
| 2026-09-21 | Phase 9c Prop Editor UX specification written (`specs/phase_9c_prop_editor_ux.md`). Approved architecture: a single Front Pose / Walk Left / Walk Right preview-motion selector with dynamic Pause/Resume; manual arm IK pauses gait before taking target ownership; modal typed anchor rename; Show Other Markers controls origin plus unselected anchors; one global Snap source; theme-driven `#262C39` inspector; container-driven unclipped chrome; simplified full-figure preview with no blue border. The preview directly plays the proven in-place gait/mirror policy and remains isolated from navigation, physics, actions, and Sandbox state. Logged #40 (exclusive gait/manual hand-target ownership). | | 2026-09-21 | Phase 9c Prop Editor UX specification written (`specs/phase_9c_prop_editor_ux.md`). Approved architecture: a single Front Pose / Walk Left / Walk Right preview-motion selector with dynamic Pause/Resume; manual arm IK pauses gait before taking target ownership; modal typed anchor rename; Show Other Markers controls origin plus unselected anchors; one global Snap source; theme-driven `#262C39` inspector; container-driven unclipped chrome; simplified full-figure preview with no blue border. The preview directly plays the proven in-place gait/mirror policy and remains isolated from navigation, physics, actions, and Sandbox state. Logged #40 (exclusive gait/manual hand-target ownership). |
| 2026-09-21 | Phase 10 custom prop integration specification written (`specs/phase_10_prop_integration.md`) after user approval of full layered visuals with one convex collision hull, direct external-path Browse, and missing-prop placeholders that preserve scene entries and rule references. The design reuses Sandbox prop freeze/restore and save flows, extends PropLibrary with a merged scan while retaining its four-template API, and gives custom thumbnails path-unique mtime keys. Logged #41–#43; #29 remains open pending implementation. |
--- ---
+3 -2
View File
@@ -54,8 +54,9 @@ func _on_root_size_changed() -> void:
func open(p_kind: String, entries: Array[Dictionary]) -> void: func open(p_kind: String, entries: Array[Dictionary]) -> void:
kind = p_kind kind = p_kind
_title_label.text = "Choose Your Stickman" if kind == "stickman" else "Choose a Prop" _title_label.text = "Choose Your Stickman" if kind == "stickman" else "Choose a Prop"
_browse_button.visible = kind == "stickman" _browse_button.visible = kind == "stickman" or kind == "prop"
_refresh_button.visible = kind == "stickman" _refresh_button.visible = kind == "stickman" or kind == "prop"
_empty_label.text = "No props found. Browse for a .prop file." if kind == "prop" else "No stickmen found."
set_entries(entries) set_entries(entries)
popup_centered() popup_centered()
+21
View File
@@ -0,0 +1,21 @@
class_name MissingPropPlaceholder
extends Node2D
var original_entry: Dictionary = {}
var missing_prop_path: String = ""
const MARKER_SIZE := Vector2(180.0, 54.0)
func configure(entry: Dictionary) -> void:
original_entry = entry.duplicate(true)
missing_prop_path = String(entry.get("asset_id", ""))
queue_redraw()
func get_area_rect() -> Rect2:
return Rect2(-MARKER_SIZE * 0.5, MARKER_SIZE)
func _draw() -> void:
var rect := get_area_rect()
draw_rect(rect, Color(0.76, 0.10, 0.10, 0.95), true)
draw_rect(rect, Color(1.0, 0.78, 0.78, 1.0), false, 2.0)
var asset_name := missing_prop_path.get_file()
draw_string(ThemeDB.fallback_font, rect.position + Vector2(10.0, 33.0), "Missing prop - %s" % asset_name, HORIZONTAL_ALIGNMENT_CENTER, rect.size.x - 20.0, 16, Color.WHITE)
+1
View File
@@ -0,0 +1 @@
uid://iwogmwpek8cc
+29 -1
View File
@@ -10,6 +10,8 @@ extends RigidBody2D
## properties update the children live. A material preset selector sets mass, ## properties update the children live. A material preset selector sets mass,
## friction/bounce (PhysicsMaterial), and themed visuals. ## friction/bounce (PhysicsMaterial), and themed visuals.
const PropVisuals := preload("res://scripts/prop_visuals.gd")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Enums # Enums
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -91,6 +93,8 @@ var _outline: Line2D
var _collision_polygon: CollisionPolygon2D var _collision_polygon: CollisionPolygon2D
var _collision_shape: CollisionShape2D var _collision_shape: CollisionShape2D
var _circle_shape: CircleShape2D var _circle_shape: CircleShape2D
var _custom_shapes: bool = false
var _visual_local_bounds := Rect2()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Signals # Signals
@@ -241,10 +245,34 @@ func _apply_style() -> void:
func _apply_material_preset() -> void: func _apply_material_preset() -> void:
mass = mass_for(material_preset) mass = mass_for(material_preset)
physics_material_override = physics_material(material_preset) physics_material_override = physics_material(material_preset)
if material_preset != MaterialPreset.NONE: if not _custom_shapes and material_preset != MaterialPreset.NONE:
fill_color = tint_for(material_preset) fill_color = tint_for(material_preset)
outline_color = tint_for(material_preset).darkened(0.55) outline_color = tint_for(material_preset).darkened(0.55)
func set_custom_shapes(shapes: Array[Dictionary], collision_hull: PackedVector2Array) -> void:
_ensure_children()
_custom_shapes = true
_polygon.visible = false
_outline.visible = false
_set_collision_shape_enabled(false)
_set_collision_polygon_enabled(true)
_collision_polygon.polygon = collision_hull
var visuals := get_node_or_null(NodePath("Visuals")) as Node2D
if visuals == null:
visuals = Node2D.new(); visuals.name = "Visuals"; add_child(visuals)
for child: Node in visuals.get_children(): child.queue_free()
_visual_local_bounds = PropVisuals.build(visuals, shapes)
_apply_material_preset()
func has_custom_shapes() -> bool:
return _custom_shapes
func get_visual_local_bounds() -> Rect2:
return _visual_local_bounds
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Static preset factories # Static preset factories
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+64 -1
View File
@@ -3,6 +3,8 @@ extends RefCounted
const PROP_UTILS := preload("res://scripts/prop_utils.gd") const PROP_UTILS := preload("res://scripts/prop_utils.gd")
const PROP_BLOCK := preload("res://scripts/prop_block.gd") const PROP_BLOCK := preload("res://scripts/prop_block.gd")
const PROP_FILE := preload("res://scripts/prop_file.gd")
const PROP_VISUALS := preload("res://scripts/prop_visuals.gd")
static var _templates: Array[Dictionary] = [] static var _templates: Array[Dictionary] = []
@@ -24,7 +26,64 @@ static func get_entry(id: String) -> Dictionary:
for t: Dictionary in get_entries(): for t: Dictionary in get_entries():
if String(t["id"]) == id: if String(t["id"]) == id:
return t return t
return {} return make_custom_entry(id) if is_custom_path(id) else {}
static func scan(dir_path: String = PROP_FILE.DEFAULT_DIR) -> Array[Dictionary]:
var entries: Array[Dictionary] = get_entries().duplicate(true)
if not DirAccess.dir_exists_absolute(dir_path):
push_warning("PropLibrary: prop directory '%s' does not exist." % dir_path)
return entries
var directory: DirAccess = DirAccess.open(dir_path)
if directory == null:
return entries
var paths: Array[String] = []
directory.list_dir_begin()
var file_name: String = directory.get_next()
while not file_name.is_empty():
if not directory.current_is_dir() and file_name.to_lower().ends_with(PROP_FILE.FILE_EXT):
paths.append(dir_path.path_join(file_name))
file_name = directory.get_next()
directory.list_dir_end()
paths.sort_custom(func(a: String, b: String) -> bool: return a.nocasecmp_to(b) < 0)
for path: String in paths:
var entry := make_custom_entry(path)
if not entry.is_empty(): entries.append(entry)
entries.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
if bool(a.get("is_custom", false)) != bool(b.get("is_custom", false)): return not bool(a.get("is_custom", false))
var an := String(a.get("name", "")).to_lower(); var bn := String(b.get("name", "")).to_lower()
return an < bn if an != bn else String(a.get("path", "")).nocasecmp_to(String(b.get("path", ""))) < 0)
return entries
static func make_custom_entry(path: String) -> Dictionary:
var normalized_path: String = canonical_path(path)
if not is_custom_path(normalized_path): return {}
var raw: Dictionary = PROP_FILE.load(normalized_path)
if raw.is_empty():
push_warning("PropLibrary: could not load custom prop '%s'." % normalized_path)
return {}
var parsed: Dictionary = PROP_FILE.from_file_dict(raw)
if parsed.is_empty():
push_warning("PropLibrary: could not load custom prop '%s'." % normalized_path)
return {}
var shapes: Array[Dictionary] = parsed.get("shapes", [])
var hull: PackedVector2Array = PROP_VISUALS.collision_hull(shapes)
if shapes.is_empty() or hull.size() < 3:
push_warning("PropLibrary: custom prop '%s' has no valid closed collision geometry." % normalized_path)
return {}
var name := String(parsed.get("prop_name", "")).strip_edges()
if name.is_empty(): name = normalized_path.get_file().get_basename()
var preset: int = int(parsed.get("material_preset", PROP_BLOCK.MaterialPreset.NONE))
return {"id": normalized_path, "path": normalized_path, "name": name, "material_preset": preset, "material_label": PROP_FILE.preset_to_string(preset).capitalize(), "is_custom": true, "payload": PROP_FILE.flatten_to_payload(shapes), "shapes": shapes, "collision_hull": hull}
static func is_custom_path(path: String) -> bool:
return path.to_lower().ends_with(PROP_FILE.FILE_EXT) and (path.begins_with("res://") or path.begins_with("user://") or path.is_absolute_path())
static func canonical_path(path: String) -> String:
return path.replace("\\", "/").simplify_path()
static func get_default_id() -> String: static func get_default_id() -> String:
@@ -47,4 +106,8 @@ static func _make(id: String, name: String, preset: int, label: String, payload:
"material_preset": preset, "material_preset": preset,
"material_label": label, "material_label": label,
"payload": payload, "payload": payload,
"is_custom": false,
"path": "",
"shapes": [],
"collision_hull": PackedVector2Array(),
} }
+89
View File
@@ -0,0 +1,89 @@
class_name PropVisuals
extends RefCounted
## Shared custom-prop drawing and collision approximation.
const CIRCLE_SEGMENTS: int = 24
static func build(parent: Node2D, shapes: Array[Dictionary]) -> Rect2:
var bounds := Rect2()
var has_bounds := false
for index: int in shapes.size():
var shape: Dictionary = shapes[index]
var points := _shape_points(shape)
if points.size() < 2:
push_warning("PropVisuals: skipped shape with insufficient points.")
continue
var group := Node2D.new()
group.name = "Shape_%d" % index
parent.add_child(group)
var closed := bool(shape.get("closed", String(shape.get("shape_type", "")) != "line"))
var fill := Color.from_string(String(shape.get("fill_color", "")), Color.WHITE)
var outline := Color.from_string(String(shape.get("outline_color", "")), Color.BLACK)
var width: float = maxf(0.0, float(shape.get("outline_width", 2.0)))
if closed and points.size() >= 3:
var polygon := Polygon2D.new()
polygon.name = "Polygon2D"
polygon.polygon = points
polygon.color = fill
group.add_child(polygon)
var line := Line2D.new()
line.name = "Line2D"
var loop: PackedVector2Array = points.duplicate()
if closed:
loop.append(points[0])
line.points = loop
line.default_color = outline
line.width = width
line.joint_mode = Line2D.LINE_JOINT_ROUND
line.begin_cap_mode = Line2D.LINE_CAP_ROUND
line.end_cap_mode = Line2D.LINE_CAP_ROUND
group.add_child(line)
for point: Vector2 in points:
if not has_bounds:
bounds = Rect2(point, Vector2.ZERO)
has_bounds = true
else:
bounds = bounds.expand(point)
return bounds
static func collision_hull(shapes: Array[Dictionary]) -> PackedVector2Array:
var points := PackedVector2Array()
for shape: Dictionary in shapes:
var closed := bool(shape.get("closed", String(shape.get("shape_type", "")) != "line"))
if closed:
points.append_array(_shape_points(shape))
if points.size() < 3:
return PackedVector2Array()
var hull: PackedVector2Array = Geometry2D.convex_hull(points)
if hull.size() < 3 or absf(_polygon_area(hull)) <= 0.001:
return PackedVector2Array()
return hull
static func _shape_points(shape: Dictionary) -> PackedVector2Array:
var raw: Variant = shape.get("points", PackedVector2Array())
var points := PackedVector2Array()
if raw is PackedVector2Array:
points = raw as PackedVector2Array
elif raw is Array:
for item: Variant in raw as Array:
if item is Vector2:
points.append(item as Vector2)
elif item is Dictionary:
var data: Dictionary = item as Dictionary
points.append(Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))))
if points.is_empty() and String(shape.get("shape_type", "")) == "circle":
var radius: float = maxf(0.0, float(shape.get("radius", 24.0)))
for index: int in CIRCLE_SEGMENTS:
var angle: float = TAU * float(index) / float(CIRCLE_SEGMENTS)
points.append(Vector2(cos(angle), sin(angle)) * radius)
return points
static func _polygon_area(points: PackedVector2Array) -> float:
var area: float = 0.0
for index: int in points.size():
var next: Vector2 = points[(index + 1) % points.size()]
area += points[index].cross(next)
return area * 0.5
+1
View File
@@ -0,0 +1 @@
uid://7cehuu3v8tig
+33 -10
View File
@@ -39,6 +39,7 @@ const RULE_EDITOR := preload("res://scenes/rule_editor.tscn")
const WAYPOINT_CONTEXT := preload("res://scripts/waypoint_context.gd") const WAYPOINT_CONTEXT := preload("res://scripts/waypoint_context.gd")
const STAGE_SCENE_IO := preload("res://scripts/stage_scene_io.gd") const STAGE_SCENE_IO := preload("res://scripts/stage_scene_io.gd")
const MISSING_STICKMAN_PLACEHOLDER := preload("res://scripts/missing_stickman_placeholder.gd") const MISSING_STICKMAN_PLACEHOLDER := preload("res://scripts/missing_stickman_placeholder.gd")
const MISSING_PROP_PLACEHOLDER := preload("res://scripts/missing_prop_placeholder.gd")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Enums # Enums
@@ -1272,7 +1273,7 @@ func _collect_scene_data(is_autosave: bool = false) -> Dictionary:
var n := node as Node2D var n := node as Node2D
var type := "" var type := ""
if n is TerrainBlock: type = "terrain" if n is TerrainBlock: type = "terrain"
elif n is PropBlock: type = "prop" elif n is PropBlock or n.get_script() == MISSING_PROP_PLACEHOLDER: type = "prop"
elif n is STICKMAN_RIG or n.get_script() == MISSING_STICKMAN_PLACEHOLDER: type = "stickman" elif n is STICKMAN_RIG or n.get_script() == MISSING_STICKMAN_PLACEHOLDER: type = "stickman"
elif n is TriggerArea: type = "area" elif n is TriggerArea: type = "area"
else: continue else: continue
@@ -1286,6 +1287,9 @@ func _collect_scene_data(is_autosave: bool = false) -> Dictionary:
if type == "terrain": if type == "terrain":
entry["asset_id"] = (n as TerrainBlock).spawn_id entry["asset_id"] = (n as TerrainBlock).spawn_id
entry["scale"] = STAGE_SCENE_IO.vector_to_data(n.scale) entry["scale"] = STAGE_SCENE_IO.vector_to_data(n.scale)
elif type == "prop" and n.get_script() == MISSING_PROP_PLACEHOLDER:
entry = (n.get("original_entry") as Dictionary).duplicate(true)
entry["file_id"] = file_id; entry["type"] = "prop"; entry["position"] = STAGE_SCENE_IO.vector_to_data(pos); entry["rotation"] = rot; entry["scale"] = STAGE_SCENE_IO.vector_to_data(n.scale)
elif type == "prop": elif type == "prop":
var prop := n as PropBlock var prop := n as PropBlock
entry["asset_id"] = prop.spawn_id entry["asset_id"] = prop.spawn_id
@@ -1355,7 +1359,11 @@ func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool)
var node: Node2D = null var node: Node2D = null
match type: match type:
"terrain": node = _spawner.spawn_terrain_asset(String(entry.get("asset_id", "")), pos) "terrain": node = _spawner.spawn_terrain_asset(String(entry.get("asset_id", "")), pos)
"prop": node = _spawner.spawn_prop_asset(String(entry.get("asset_id", "")), pos) "prop":
node = _spawner.spawn_prop_asset(String(entry.get("asset_id", "")), pos)
if node == null and PROP_LIBRARY.is_custom_path(String(entry.get("asset_id", ""))):
var prop_placeholder: Node2D = MISSING_PROP_PLACEHOLDER.new() as Node2D
prop_placeholder.configure(entry); prop_placeholder.position = pos; _world.add_child(prop_placeholder); node = prop_placeholder
"area": node = _spawner.spawn_area(pos) "area": node = _spawner.spawn_area(pos)
"stickman": "stickman":
node = _spawner.spawn_stickman_asset(String(entry.get("stk_path", "")), pos) node = _spawner.spawn_stickman_asset(String(entry.get("stk_path", "")), pos)
@@ -2408,7 +2416,7 @@ func _open_selector(kind: String) -> void:
if not entries.is_empty() and _stickman_library.find_by_path(_spawner.get_selected_stickman_path()).is_empty(): if not entries.is_empty() and _stickman_library.find_by_path(_spawner.get_selected_stickman_path()).is_empty():
_spawner.selected_stickman_path = String(entries[0]["path"]) _spawner.selected_stickman_path = String(entries[0]["path"])
else: else:
entries = PROP_LIBRARY.get_entries() entries = PROP_LIBRARY.scan()
if kind == "stickman" and entries.size() == 1: if kind == "stickman" and entries.size() == 1:
_on_asset_selected(entries[0]) _on_asset_selected(entries[0])
return return
@@ -2449,18 +2457,29 @@ func _on_selector_cancelled() -> void:
func _on_browse_requested() -> void: func _on_browse_requested() -> void:
if _browse_dialog != null: if _browse_dialog != null:
if _selector_kind == "prop":
_browse_dialog.title = "Open .prop"
_browse_dialog.filters = PackedStringArray(["*.prop ; Prop Files"])
else:
_browse_dialog.title = "Open .stk"
_browse_dialog.filters = PackedStringArray(["*.stk ; Stickman Files"])
_browse_dialog.popup_centered() _browse_dialog.popup_centered()
func _on_browse_file_selected(path: String) -> void: func _on_browse_file_selected(path: String) -> void:
var entry := _stickman_library.make_entry(path) var entry: Dictionary = PROP_LIBRARY.make_custom_entry(path) if _selector_kind == "prop" else _stickman_library.make_entry(path)
if entry.is_empty(): if entry.is_empty():
_show_toast("Could not load stickman: " + path) _show_toast("Could not load %s: %s" % [_selector_kind, path])
return return
_on_asset_selected(entry) _on_asset_selected(entry)
func _on_refresh_requested() -> void: func _on_refresh_requested() -> void:
if _selector_kind == "prop":
var prop_entries: Array[Dictionary] = PROP_LIBRARY.scan()
_selector.set_entries(prop_entries)
_load_or_enqueue_thumbnails("prop", prop_entries)
return
var entries: Array[Dictionary] = _stickman_library.scan() var entries: Array[Dictionary] = _stickman_library.scan()
if not entries.is_empty() and _stickman_library.find_by_path(_spawner.get_selected_stickman_path()).is_empty(): if not entries.is_empty() and _stickman_library.find_by_path(_spawner.get_selected_stickman_path()).is_empty():
_spawner.selected_stickman_path = String(entries[0]["path"]) _spawner.selected_stickman_path = String(entries[0]["path"])
@@ -2485,7 +2504,7 @@ func _load_or_enqueue_thumbnails(kind: String, entries: Array[Dictionary]) -> vo
_thumbnail_queue.append({ "kind": kind, "entry": entry, "key": key, "cache_path": png, "generation": generation }) _thumbnail_queue.append({ "kind": kind, "entry": entry, "key": key, "cache_path": png, "generation": generation })
else: else:
var id := String(entry.get("id", "")) var id := String(entry.get("id", ""))
var png := _thumbnail_cache.prop_png(id) var png := _thumbnail_cache.prop_file_png(id) if bool(entry.get("is_custom", false)) else _thumbnail_cache.prop_png(id)
var tex := _thumbnail_cache.get_texture(png) var tex := _thumbnail_cache.get_texture(png)
if tex != null: if tex != null:
if tex != null and _selector != null: if tex != null and _selector != null:
@@ -2509,11 +2528,15 @@ func _drain_thumbnail_queue() -> void:
if tex != null: if tex != null:
_thumbnail_cache.store_texture(String(job.get("cache_path", _thumbnail_cache.stickman_png(key))), tex) _thumbnail_cache.store_texture(String(job.get("cache_path", _thumbnail_cache.stickman_png(key))), tex)
else: else:
var payload: Dictionary = entry["payload"] if bool(entry.get("is_custom", false)):
var preset: int = int(entry["material_preset"]) tex = await _prop_thumb.render_shapes(entry.get("shapes", []) as Array[Dictionary])
tex = await _prop_thumb.render(payload, preset) else:
var payload: Dictionary = entry["payload"]
var preset: int = int(entry["material_preset"])
tex = await _prop_thumb.render(payload, preset)
if tex != null: if tex != null:
_thumbnail_cache.store_texture(String(job.get("cache_path", _thumbnail_cache.prop_png(String(entry["id"])))), tex) var fallback := _thumbnail_cache.prop_file_png(String(entry["id"])) if bool(entry.get("is_custom", false)) else _thumbnail_cache.prop_png(String(entry["id"]))
_thumbnail_cache.store_texture(String(job.get("cache_path", fallback)), tex)
_thumbnail_busy = false _thumbnail_busy = false
if _selector != null and _selector_open and int(job.get("generation", -1)) == _thumbnail_generation and tex != null: if _selector != null and _selector_open and int(job.get("generation", -1)) == _thumbnail_generation and tex != null:
_selector.set_thumbnail(entry, tex) _selector.set_thumbnail(entry, tex)
+12 -3
View File
@@ -364,7 +364,10 @@ func _rule_trigger_anchor(trigger: Dictionary) -> Vector2:
var params: Dictionary = trigger.get("params", {}) var params: Dictionary = trigger.get("params", {})
return params.get("waypoint_pos", Vector2.INF) return params.get("waypoint_pos", Vector2.INF)
"entered_area": "entered_area":
var area := instance_from_id(int(trigger.get("target", -1))) var target_id: int = int(trigger.get("target", -1))
if target_id < 0:
return _rule_source_position(trigger)
var area := instance_from_id(target_id)
if area is Node2D and is_instance_valid(area): if area is Node2D and is_instance_valid(area):
return (area as Node2D).global_position return (area as Node2D).global_position
return _rule_source_position(trigger) return _rule_source_position(trigger)
@@ -373,7 +376,10 @@ func _rule_trigger_anchor(trigger: Dictionary) -> Vector2:
func _rule_source_position(trigger: Dictionary) -> Vector2: func _rule_source_position(trigger: Dictionary) -> Vector2:
var src := instance_from_id(int(trigger.get("source", -1))) var source_id: int = int(trigger.get("source", -1))
if source_id < 0:
return Vector2.INF
var src := instance_from_id(source_id)
if src is Node2D and is_instance_valid(src): if src is Node2D and is_instance_valid(src):
return (src as Node2D).global_position return (src as Node2D).global_position
return Vector2.INF return Vector2.INF
@@ -385,7 +391,10 @@ func _rule_action_anchor(rule: Dictionary, fallback: Vector2) -> Vector2:
var actions: Array = rule.get("actions", []) var actions: Array = rule.get("actions", [])
if actions.is_empty(): if actions.is_empty():
return fallback return fallback
var target := instance_from_id(int(actions[0].get("target", -1))) var target_id: int = int(actions[0].get("target", -1))
if target_id < 0:
return fallback
var target := instance_from_id(target_id)
if target is Node2D and is_instance_valid(target): if target is Node2D and is_instance_valid(target):
return (target as Node2D).global_position return (target as Node2D).global_position
return fallback return fallback
+6
View File
@@ -141,6 +141,12 @@ static func get_world_aabb(node: Node2D) -> Rect2:
# Duck-typed TriggerArea: expose its centered local rect through the transform. # Duck-typed TriggerArea: expose its centered local rect through the transform.
if node.has_method("get_area_rect"): if node.has_method("get_area_rect"):
return node.global_transform * (node.call("get_area_rect") as Rect2) return node.global_transform * (node.call("get_area_rect") as Rect2)
var visuals := node.get_node_or_null(NodePath("Visuals")) as Node2D
if visuals != null:
var visual_acc := { "min_x": INF, "min_y": INF, "max_x": -INF, "max_y": -INF }
_collect_visual_points(visuals, visual_acc)
if visual_acc["min_x"] <= visual_acc["max_x"]:
return Rect2(Vector2(visual_acc["min_x"], visual_acc["min_y"]), Vector2(visual_acc["max_x"] - visual_acc["min_x"], visual_acc["max_y"] - visual_acc["min_y"]))
var poly := node.get_node_or_null(NodePath("Polygon2D")) as Polygon2D var poly := node.get_node_or_null(NodePath("Polygon2D")) as Polygon2D
if poly != null and not poly.polygon.is_empty(): if poly != null and not poly.polygon.is_empty():
var rect := Rect2(node.to_global(poly.polygon[0]), Vector2.ZERO) var rect := Rect2(node.to_global(poly.polygon[0]), Vector2.ZERO)
+17 -2
View File
@@ -165,8 +165,17 @@ func spawn_prop_asset(asset_id: String, object_position: Vector2) -> PropBlock:
if template.is_empty(): if template.is_empty():
push_warning("StageSpawner: unknown prop '%s'." % asset_id) push_warning("StageSpawner: unknown prop '%s'." % asset_id)
return null return null
var prop: PropBlock = PROP_UTILS.spawn_prop(_world, object_position, template["payload"], int(template["material_preset"]), Vector2.ZERO) var prop: PropBlock
prop.spawn_id = asset_id if bool(template.get("is_custom", false)):
prop = PROP_BLOCK.new()
prop.name = "PropBlock"
prop.position = object_position
prop.material_preset = int(template["material_preset"])
_world.add_child(prop)
prop.set_custom_shapes(template.get("shapes", []) as Array[Dictionary], template.get("collision_hull", PackedVector2Array()) as PackedVector2Array)
else:
prop = PROP_UTILS.spawn_prop(_world, object_position, template["payload"], int(template["material_preset"]), Vector2.ZERO)
prop.spawn_id = String(template.get("id", asset_id))
return prop return prop
@@ -202,6 +211,12 @@ static func get_world_aabb(node: Node2D) -> Rect2:
# Duck-typed TriggerArea: expose its centered local rect through the transform. # Duck-typed TriggerArea: expose its centered local rect through the transform.
if node.has_method("get_area_rect"): if node.has_method("get_area_rect"):
return node.global_transform * (node.call("get_area_rect") as Rect2) return node.global_transform * (node.call("get_area_rect") as Rect2)
var visuals := node.get_node_or_null(NodePath("Visuals")) as Node2D
if visuals != null:
var visual_acc := { "min_x": INF, "min_y": INF, "max_x": -INF, "max_y": -INF }
_collect_visual_points(visuals, visual_acc)
if visual_acc["min_x"] <= visual_acc["max_x"]:
return Rect2(Vector2(visual_acc["min_x"], visual_acc["min_y"]), Vector2(visual_acc["max_x"] - visual_acc["min_x"], visual_acc["max_y"] - visual_acc["min_y"]))
var poly := node.get_node_or_null(NodePath("Polygon2D")) as Polygon2D var poly := node.get_node_or_null(NodePath("Polygon2D")) as Polygon2D
if poly != null and not poly.polygon.is_empty(): if poly != null and not poly.polygon.is_empty():
var rect := Rect2(node.to_global(poly.polygon[0]), Vector2.ZERO) var rect := Rect2(node.to_global(poly.polygon[0]), Vector2.ZERO)
+15
View File
@@ -2,6 +2,7 @@ class_name PropThumbnail
extends Node extends Node
const PROP_BLOCK := preload("res://scripts/prop_block.gd") const PROP_BLOCK := preload("res://scripts/prop_block.gd")
const PROP_VISUALS := preload("res://scripts/prop_visuals.gd")
const SIZE := Vector2i(200, 200) const SIZE := Vector2i(200, 200)
@@ -64,6 +65,20 @@ func render(payload: Dictionary, material_preset: int) -> Texture2D:
return ImageTexture.create_from_image(img) return ImageTexture.create_from_image(img)
func render_shapes(shapes: Array[Dictionary]) -> Texture2D:
_clear_world()
var visual := Node2D.new()
_world.add_child(visual)
var bounds: Rect2 = PROP_VISUALS.build(visual, shapes)
_frame_camera(bounds if bounds.size.length_squared() > 0.0 else Rect2(Vector2(-24, -24), Vector2(48, 48)))
await RenderingServer.frame_post_draw
await RenderingServer.frame_post_draw
var tex := _viewport.get_texture()
visual.queue_free()
if tex == null or _is_blank(tex.get_image()): return null
return ImageTexture.create_from_image(tex.get_image())
func _compute_points(payload: Dictionary) -> PackedVector2Array: func _compute_points(payload: Dictionary) -> PackedVector2Array:
var shape_type: int = int(payload.get("type", PROP_BLOCK.ShapeType.POLYGON)) var shape_type: int = int(payload.get("type", PROP_BLOCK.ShapeType.POLYGON))
if shape_type == PROP_BLOCK.ShapeType.CIRCLE: if shape_type == PROP_BLOCK.ShapeType.CIRCLE:
+3 -1
View File
@@ -40,7 +40,9 @@ func prop_png(id: String) -> String:
func prop_file_key(path: String) -> String: func prop_file_key(path: String) -> String:
return "%s_%d" % [path.get_file().get_basename(), FileAccess.get_modified_time(path)] var canonical: String = path.simplify_path()
var digest: String = canonical.sha256_text().substr(0, 12)
return "file_%s_%d" % [digest, FileAccess.get_modified_time(path)]
func prop_file_png(path: String) -> String: func prop_file_png(path: String) -> String:
+151
View File
@@ -0,0 +1,151 @@
# Phase 10 — Custom Prop Library and Sandbox Integration
**Status:** Approved for implementation (2026-09-21). Implements the approved choices from `plans/PHASE_10_PROP_INTEGRATION.md`; this document specifies work and does not itself change runtime behavior.
## 1. Scope and existing seams
The Sandbox already has the required three-mode physics lifecycle. `StageSpawner.spawn_prop_asset()` assigns `PropBlock.spawn_id`; `SandboxStage._finalize_placed_node()` freezes props and captures authored state; PLAY unfreezes them; EDIT/DIRECT restore authored transforms and clear velocities. Scene files already save prop `asset_id`, transform, scale, and material preset. Reuse those paths for custom props.
The missing pieces are filesystem-backed prop entries, full layered visuals, a deliberate collision approximation, custom thumbnail keys and rendering, prop Browse/Refresh, and preservation of missing custom props during scene reload. `PropFile.flatten_to_payload()` currently chooses one shape; it remains a compatibility helper for existing single-shape consumers, not the custom prop visual representation.
Runtime attachment, grip/emitter roles, prop editing inside the Sandbox, and `.prop` schema changes are outside this phase.
## 2. Architecture and contracts
```text
res://props/*.prop ──scan──> PropLibrary entry ──id/path──> AssetSelector
│ │
├── parsed shapes ──> thumbnail queue ──> PropThumbnail
│ └── ThumbnailCache
└── on-demand id lookup ──> StageSpawner ──> PropBlock
├── Visuals/Shape_*
└── one collision hull
.scene asset_id ──> same on-demand lookup ──> prop or MissingPropPlaceholder
```
### 2.1 `PropLibrary` (`class_name PropLibrary extends RefCounted`)
Preserve `static func get_entries() -> Array[Dictionary]` as the **four built-in templates only**, and preserve `get_ids()` and `get_default_id() == "crate"`. Add:
```gdscript
static func scan(dir_path: String = PropFile.DEFAULT_DIR) -> Array[Dictionary]
static func make_custom_entry(path: String) -> Dictionary
static func get_entry(id: String) -> Dictionary
```
`scan()` returns built-ins first, then valid `.prop` files in `dir_path`, sorted by case-insensitive display name and path. It does a fresh, non-recursive scan each call. Missing directory means built-ins only and one descriptive warning. `make_custom_entry()` accepts a `res://`, `user://`, or absolute filesystem `.prop` path, loads with `PropFile.load()` and `PropFile.from_file_dict()`, validates renderable geometry and a non-degenerate closed-shape collision hull, and returns `{}` with a path-specific warning on failure. It must not convert an `Array` of `{x,y}` dictionaries directly into `PackedVector2Array`; use the parsed `shapes` returned by `PropFile.from_file_dict()` or a dedicated coercion helper. `get_entry()` checks built-ins first; for a path ID it calls `make_custom_entry()` on demand, so a saved external prop resolves without first opening the selector.
All entries expose these keys. GDScript cannot statically type dictionary fields; every method signature and local variable around this boundary remains statically typed.
| Key | Type | Built-in | Custom |
| --- | --- | --- | --- |
| `id` | `String` | `"crate"`, etc. | canonical source path |
| `name` | `String` | template name | `prop_name`, falling back to filename stem |
| `material_preset` | `int` | template preset | parsed file preset |
| `material_label` | `String` | existing label | corresponding preset label |
| `is_custom` | `bool` | `false` | `true` |
| `path` | `String` | `""` | same canonical path as `id` |
| `payload` | `Dictionary` | existing primitive payload | compatibility value from `flatten_to_payload()` |
| `shapes` | `Array[Dictionary]` | empty | full parsed shape stack, back to front |
| `collision_hull` | `PackedVector2Array` | empty | validated local-space hull |
The canonical custom ID is the normalized source path, including its scheme where applicable. Browse keeps an external absolute path; no file is copied. Scene portability therefore depends on that file remaining at the saved path. No last-selection setting is added.
### 2.2 Shared visual and collision conversion
Add `scripts/prop_visuals.gd` (`class_name PropVisuals extends RefCounted`) as a stateless helper used by both runtime and thumbnail rendering:
```gdscript
static func build(parent: Node2D, shapes: Array[Dictionary]) -> Rect2
static func collision_hull(shapes: Array[Dictionary]) -> PackedVector2Array
```
`build()` creates one visual group per source shape in array order. Closed polygons and circles receive a `Polygon2D` fill plus a closed `Line2D` outline. Open lines receive only a `Line2D`, with caps/joints matching the editor. Preserve each shape's fill, outline, width, and authored point coordinates. A circle uses its parsed points when present, including hand-reshaped circles; only an absent point list is regenerated from `radius`. The returned local bounds cover every rendered shape; empty or invalid individual shapes are ignored with a warning, while `PropLibrary` rejects an asset with no usable geometry.
`collision_hull()` collects points from valid **closed** polygon/circle shapes and constructs one convex hull. Open lines never create collision. Reject a hull with fewer than three distinct, non-collinear points or zero area. The hull keeps authored local coordinates without terrain grid snapping. This is a deliberate approximate solid: cavities and concavities are filled, but the body remains stable with one collision node. Do not derive collision from the frontmost shape selected by `flatten_to_payload()`; for example, the briefcase's decorative details must not become its entire collision body.
`PropBlock` (`class_name PropBlock extends RigidBody2D`) gains:
```gdscript
func set_custom_shapes(shapes: Array[Dictionary], collision_hull: PackedVector2Array) -> void
func has_custom_shapes() -> bool
func get_visual_local_bounds() -> Rect2
```
Custom nodes retain the existing `PropBlock` root, material mass/friction/bounce, contact signals, and one enabled `CollisionPolygon2D` populated from the hull. Their visual hierarchy is:
```text
PropBlock (RigidBody2D; spawn_id = source path)
├── Visuals (Node2D)
│ ├── Shape_0 (Node2D) ── Polygon2D? + Line2D
│ └── Shape_1 ...
├── CollisionPolygon2D (one convex hull)
└── existing primitive children (disabled/hidden for custom)
```
For custom props, changing `material_preset` applies physics properties but **does not replace authored per-shape colors**. Existing built-ins keep their current preset tinting and primitive structure. Reapplying the preset during scene load must preserve this distinction. `StageSpawner.spawn_prop_asset(asset_id: String, object_position: Vector2) -> PropBlock` branches on `is_custom`, uses `PropBlock.set_custom_shapes()` for custom entries, sets `spawn_id = asset_id`, and returns `null` if validation fails. `PropUtils.spawn_prop()` remains the built-in primitive path.
`StageSelection.get_world_aabb()` and `StageSpawner.get_world_aabb()` must union all custom visual points (or transform `get_visual_local_bounds()`), rather than stop at the root `Polygon2D`. This keeps selection, gizmos, ghost placement, trigger overlap, and grid indexing aligned with the full visible prop. The current AABB hit-test remains approximate.
### 2.3 Thumbnail pipeline
`PropThumbnail` keeps `render(payload: Dictionary, material_preset: int) -> Texture2D` for built-ins and adds `render_shapes(shapes: Array[Dictionary]) -> Texture2D` for custom props. Both are async render paths driven by the existing `SandboxStage._thumbnail_queue`, at most one queued render at a time. The custom path calls `PropVisuals.build()` and frames the camera around the **entire** stack; it never flattens the stack or recolors it from the material preset. A null/blank headless capture leaves the selector placeholder and does not poison the disk cache.
`ThumbnailCache.prop_png(id)` remains built-in-only. `prop_file_key(path)`/`prop_file_png(path)` include a hash of the canonical **full path** and the file modification time, with a `file_` prefix distinct from built-in keys. Two `chair.prop` files in different directories cannot share a PNG key. On Refresh, the stage recomputes keys and queues only missing images. Cleanup may remove superseded custom PNGs for known canonical paths after successful replacement; it must not delete built-in PNGs or cache files for unrelated external paths. No global cache sweep is required.
### 2.4 Selector and stage signals
`AssetSelector` keeps the existing typed signals `item_selected(entry: Dictionary)`, `browse_requested()`, `refresh_requested()`, and `cancelled()`. In `open("prop", entries)`, show Browse and Refresh, and use a prop-specific empty label. The selector's `_entry_key()` already uses `id` for props, so path IDs work without an additional key field. `set_entries()` still resets pagination and clears displayed thumbnail references; the stage repopulates them from cache or queue.
`SandboxStage` calls `PropLibrary.scan()` whenever the prop selector opens or Refresh is pressed. Browse sets the existing `FileDialog` title/filter to `.prop` according to `_selector_kind`; stickman browsing retains `.stk`. A valid browsed prop selects its path ID and enters prop placement. An invalid file shows a concise error/toast and leaves the selector open. Refresh updates the open grid, invalidates stale thumbnail jobs through the existing generation counter, and preserves the selected prop ID when still resolvable. Custom entries choose `prop_file_png(path)`; built-ins choose `prop_png(id)`.
### 2.5 Scene reload and missing custom assets
Add `scripts/missing_prop_placeholder.gd` (`class_name MissingPropPlaceholder extends Node2D`) with typed `original_entry: Dictionary`, `missing_prop_path: String`, `configure(entry: Dictionary) -> void`, and `get_area_rect() -> Rect2`. It displays an inert missing-asset marker and retains a deep copy of the original prop scene entry. The loader creates it **only for a missing/invalid path-valued custom prop ID**; unknown legacy template IDs continue to warn and skip. It applies the saved transform and scale and adds the placeholder to the World in the original object order.
The placeholder is intentionally entered into `file_to_runtime` as an available object ID, so `StageSceneIO.decode_rules()` maps existing rule references to its instance ID. It does **not** belong in the `unavailable` set that currently makes rules disappear for missing stickmen. Its `Node2D` type prevents `PropBlock` physics/contact handling; stage trigger/action execution must treat it as inert. `_collect_scene_data()` recognizes `MissingPropPlaceholder` as a `prop`, begins from `original_entry.duplicate(true)`, updates the current file ID and transform/scale, and serializes its original `asset_id` and material. The normal runtime-to-file map includes its ID so `StageSceneIO.encode_rules()` writes the preserved references. Deleting a placeholder follows normal object/rule cleanup. Reopening the scene retries the path and replaces it with a real prop when available.
Example scene flow:
```text
load prop_1 -> path missing -> placeholder instance 842 -> rule source = 842
save -> object prop_1 still has original asset_id -> rule source_id = prop_1
restore file, reload -> real PropBlock instance 913 -> rule source = 913
```
This extends preservation specifically to missing custom props; the existing missing-stickman skip policy remains outside this phase.
## 3. Mode and data flow
```mermaid
stateDiagram-v2
EDIT --> DIRECT: select Direct
EDIT --> PLAY: select Play
DIRECT --> EDIT: select Edit
DIRECT --> PLAY: select Play
PLAY --> EDIT: freeze + restore authored pose
PLAY --> DIRECT: freeze + restore authored pose
```
The custom prop goes through existing `_finalize_placed_node()`, `_save_object_state()`, `_enter_play_mode()`, and `_enter_edit_shared()` methods. Do not add a second authored-position metadata system. Stage save uses `prop.spawn_id` (the custom path) as `asset_id`. EDIT and DIRECT keep the body kinematically frozen; PLAY unfreezes it; leaving PLAY restores position/rotation and zeros linear/angular velocity. Current scale remains a scene-level transform. Scene load's material override must update physics without tinting custom artwork.
## 4. Verification and acceptance
Add a focused `tests/test_phase10_prop_integration.gd` headless suite and update relevant existing suites. Assertions must test values and behavior, not only node counts.
1. **Library:** scan returns four built-ins plus the valid checked-in `.prop` entries in deterministic order; invalid version, corrupt JSON, missing file, empty/line-only, and degenerate closed geometry are skipped with warnings. `get_entries()` remains four templates, preserving `tests/test_phase3b_library.gd`; add an explicit scan test rather than changing that assertion. A custom ID resolves by path without prior scan.
2. **Geometry:** a multi-shape sample such as `briefcase.prop` spawns all authored layers, including open outlines where present, with original per-shape colors and point values after JSON parse. It has exactly one enabled collision hull that covers the intended closed-shape extrema; lines do not expand collision. `get_world_aabb()` and selection bounds include all visual layers. Built-in crate and ball remain visually/physically unchanged.
3. **Thumbnails:** the custom render path receives every layer and computes full-stack bounds; an interactive capture shows their colors and order. Two equal basenames from different folders have distinct cache paths, and changing mtime changes the key. A headless null capture is harmless. The stage queue still drains sequentially and ignores jobs from an older selector generation.
4. **Selector:** prop Browse/Refresh are visible; stickman Browse retains the `.stk` filter. Browse accepts a valid external `.prop` as a path ID; invalid browse keeps the selector open. Refresh finds a new file, drops a removed file from the grid, and does not replace a still-valid selected prop. Pagination remains correct past 12 items.
5. **Scene persistence:** save/reload retains a custom prop's path ID, material, position, rotation, scale, and full visuals. With its source temporarily absent, load creates a placeholder carrying the original entry; save/reload preserves that entry and a rule referring to it. Restoring the file and reopening resolves a real prop and the rule reference. Existing built-in prop and missing-stickman behavior stays covered by `tests/test_phase5_file_features.gd`.
6. **Lifecycle:** in EDIT/DIRECT the custom `RigidBody2D` is frozen; in PLAY it simulates and reports contacts; after PLAY→EDIT and PLAY→DIRECT it returns to the authored transform with zero velocities. Use a physics-frame runtime test and a brief interactive Sandbox check for full-layer artwork, collision, selection, Browse, and reload.
Run the affected headless suites plus Godot 4.7.1 `--headless --check-only` on changed scripts. Test thumbnail pixels interactively because headless `SubViewport` capture can be blank. The target scene remains `res://scenes/sandbox_stage.tscn`; no new UI scene is required beyond the existing selector's code-controlled visibility/text.
## 5. Architectural trade-offs
- A single convex collision hull is cheap and stable, but fills gaps and concavities. Full layered visual fidelity is independent of this approximation.
- External Browse paths support immediate use without copying, but moving the source file breaks reload until restored. The placeholder prevents scene and rule data loss during that interval.
- Dictionary entries match the existing libraries and `.prop` JSON boundary; introducing custom `Resource` assets would add conversion and migration work without improving this phase's data flow.
- Path-plus-mtime thumbnail keys prevent same-name cross-folder collisions. Filesystems with coarse timestamp resolution can still miss two edits within one timestamp tick; content hashing is a possible later upgrade.
- Missing-prop rule references remain serialized through inert placeholders. Rules cannot fire from absent geometry; existing missing-stickman rules continue to follow their current skip behavior.
+128
View File
@@ -0,0 +1,128 @@
extends SceneTree
const PROP_LIBRARY := preload("res://scripts/prop_library.gd")
const STAGE_SPAWNER := preload("res://scripts/stage_spawner.gd")
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
const THUMBNAIL_CACHE := preload("res://scripts/thumbnails/thumbnail_cache.gd")
const PROP_FILE := preload("res://scripts/prop_file.gd")
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const MISSING_PROP_PLACEHOLDER := preload("res://scripts/missing_prop_placeholder.gd")
const TEMP_PROP_PATH := "user://phase10_custom_integration.prop"
var failures: int = 0
var checks: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_write_temp_prop()
var builtins: Array[Dictionary] = PROP_LIBRARY.get_entries()
_check(builtins.size() == 4, "built-in templates remain four entries")
var entries: Array[Dictionary] = PROP_LIBRARY.scan()
_check(entries.size() == 8, "scan returns built-ins and checked-in custom props")
var entry: Dictionary = PROP_LIBRARY.get_entry("res://props/briefcase.prop")
_check(not entry.is_empty() and bool(entry.get("is_custom", false)), "custom path resolves without selector scan")
_check((entry.get("shapes", []) as Array).size() > 1, "custom entry preserves the full shape stack")
var hull: PackedVector2Array = entry.get("collision_hull", PackedVector2Array())
_check(hull.size() >= 3, "custom entry has a valid collision hull")
var world: Node2D = Node2D.new()
root.add_child(world)
var spawner: StageSpawner = STAGE_SPAWNER.new(world)
var prop: PropBlock = spawner.spawn_prop_asset("res://props/briefcase.prop", Vector2(100, 50))
_check(prop != null and prop.has_custom_shapes(), "spawner creates layered custom PropBlock")
if prop != null:
var visuals: Node2D = prop.get_node_or_null(NodePath("Visuals")) as Node2D
_check(visuals != null and visuals.get_child_count() == (entry.get("shapes", []) as Array).size(), "each authored shape has one visual group")
var collision: CollisionPolygon2D = prop.get_node_or_null(NodePath("CollisionPolygon2D")) as CollisionPolygon2D
_check(collision != null and collision.polygon.size() >= 3 and not collision.disabled, "custom prop uses one enabled collision hull")
var aabb: Rect2 = STAGE_SELECTION.get_world_aabb(prop)
_check(aabb.size.x > 0.0 and aabb.size.y > 0.0, "selection bounds include custom visuals")
var cache: ThumbnailCache = THUMBNAIL_CACHE.new()
_check(cache.prop_file_png("res://props/briefcase.prop") != cache.prop_file_png("user://briefcase.prop"), "thumbnail keys include complete canonical path")
world.queue_free()
await process_frame
await _test_authored_visual_values()
await _test_missing_prop_round_trip()
await _test_custom_prop_lifecycle()
DirAccess.remove_absolute(TEMP_PROP_PATH)
print("RESULT: %d checks, %d failures" % [checks, failures])
quit(1 if failures > 0 else 0)
func _check(condition: bool, label: String) -> void:
checks += 1
if condition: print("PASS: " + label)
else:
failures += 1
print("FAIL: " + label)
func _write_temp_prop() -> void:
var shapes: Array[Dictionary] = [
{"shape_type": "polygon", "points": PackedVector2Array([Vector2(-30, -20), Vector2(30, -20), Vector2(30, 20), Vector2(-30, 20)]), "fill_color": "#123456FF", "outline_color": "#ABCDEF80", "outline_width": 3.5, "closed": true, "vertex_flags": PackedInt32Array([0, 0, 0, 0]), "reshaped": false},
{"shape_type": "line", "points": PackedVector2Array([Vector2(-45, 35), Vector2(45, 35)]), "fill_color": "#00000000", "outline_color": "#FEDCBAFF", "outline_width": 5.0, "closed": false, "vertex_flags": PackedInt32Array([0, 0]), "reshaped": false},
]
_check(PROP_FILE.save(TEMP_PROP_PATH, PROP_FILE.to_file_dict("Temporary Test Prop", PROP_BLOCK.MaterialPreset.METAL, shapes)), "temporary custom prop writes")
func _test_authored_visual_values() -> void:
var world: Node2D = Node2D.new()
root.add_child(world)
var spawner: StageSpawner = STAGE_SPAWNER.new(world)
var prop: PropBlock = spawner.spawn_prop_asset(TEMP_PROP_PATH, Vector2.ZERO)
_check(prop != null and prop.has_custom_shapes(), "temporary custom prop spawns as layered visual")
if prop != null:
var shape_zero: Node2D = prop.get_node_or_null(NodePath("Visuals/Shape_0")) as Node2D
var fill: Polygon2D = shape_zero.get_node_or_null(NodePath("Polygon2D")) as Polygon2D
var closed_outline: Line2D = shape_zero.get_node_or_null(NodePath("Line2D")) as Line2D
_check(fill != null and fill.color.is_equal_approx(Color("#123456FF")), "custom fill retains authored color")
_check(closed_outline != null and closed_outline.default_color.is_equal_approx(Color("#ABCDEF80")) and is_equal_approx(closed_outline.width, 3.5), "custom outline retains authored color and width")
_check(closed_outline != null and closed_outline.points.size() == 5 and closed_outline.points[0].is_equal_approx(Vector2(-30, -20)) and closed_outline.points[4].is_equal_approx(Vector2(-30, -20)), "closed shape outline repeats authored first point")
var line_outline: Line2D = prop.get_node_or_null(NodePath("Visuals/Shape_1/Line2D")) as Line2D
_check(line_outline != null and line_outline.points.size() == 2 and line_outline.points[0].is_equal_approx(Vector2(-45, 35)) and line_outline.points[1].is_equal_approx(Vector2(45, 35)) and line_outline.default_color.is_equal_approx(Color("#FEDCBAFF")), "open line retains authored points and outline")
world.queue_free()
await process_frame
func _test_missing_prop_round_trip() -> void:
var stage: Node2D = STAGE_SCENE.instantiate() as Node2D
root.add_child(stage)
await process_frame
var source: Dictionary = {"version": "1.0", "scene_name": "Missing Custom", "camera": {}, "metadata": {}, "objects": [{"file_id": "prop_1", "type": "prop", "asset_id": TEMP_PROP_PATH, "position": {"x": 42.0, "y": 75.0}, "rotation": 15.0, "scale": {"x": 1.2, "y": 0.8}, "material_preset": PROP_BLOCK.MaterialPreset.WOOD}], "event_rules": [{"id": 1, "trigger": {"type": "action_finished", "source_id": "prop_1", "target_id": null, "params": {}}, "actions": [{"type": "wait", "target_id": null, "params": {"duration": 0.1}}]}]}
DirAccess.remove_absolute(TEMP_PROP_PATH)
_check(stage._apply_scene_data(source, "user://phase10_missing.scene", false), "missing custom-prop scene loads")
var placeholder: Node2D = stage._world.get_child(0) as Node2D
_check(placeholder != null and placeholder.get_script() == MISSING_PROP_PLACEHOLDER, "missing custom prop becomes placeholder")
var preserved: Dictionary = stage._collect_scene_data()
var object_entry: Dictionary = (preserved.get("objects", []) as Array)[0]
_check(String(object_entry.get("asset_id", "")) == TEMP_PROP_PATH and (preserved.get("event_rules", []) as Array).size() == 1 and String(((preserved["event_rules"] as Array)[0]["trigger"] as Dictionary).get("source_id", "")) == "prop_1", "placeholder save preserves asset path and rule reference")
_write_temp_prop()
_check(stage._apply_scene_data(preserved, "user://phase10_missing.scene", false), "scene reloads after custom prop is restored")
var restored: PropBlock = stage._world.get_child(0) as PropBlock
_check(restored != null and restored.has_custom_shapes() and stage._event_rules.size() == 1 and int((stage._event_rules[0]["trigger"] as Dictionary).get("source", -1)) == restored.get_instance_id(), "restored custom prop replaces placeholder and resolves rule")
stage.queue_free()
await process_frame
func _test_custom_prop_lifecycle() -> void:
var stage: Node2D = STAGE_SCENE.instantiate() as Node2D
root.add_child(stage)
await process_frame
var prop: PropBlock = stage._spawner.spawn_prop_asset(TEMP_PROP_PATH, Vector2(160, -40))
prop.rotation = deg_to_rad(20.0)
stage._finalize_placed_node(prop)
var authored_position: Vector2 = prop.global_position
var authored_rotation: float = prop.global_rotation
_check(prop.freeze, "custom prop is frozen in EDIT")
stage.set_mode(stage.StageMode.PLAY)
_check(not prop.freeze, "custom prop unfreezes in PLAY")
prop.global_position += Vector2(90, 70)
prop.global_rotation += 0.4
prop.linear_velocity = Vector2(50, 20)
prop.angular_velocity = 2.0
stage.set_mode(stage.StageMode.EDIT)
_check(prop.freeze and prop.global_position.is_equal_approx(authored_position) and is_equal_approx(prop.global_rotation, authored_rotation) and prop.linear_velocity.is_zero_approx() and is_zero_approx(prop.angular_velocity), "PLAY to EDIT restores custom prop authored pose and clears velocity")
stage.queue_free()
await process_frame
@@ -0,0 +1 @@
uid://elssi38n02h2
+2 -2
View File
@@ -47,9 +47,9 @@ func _test_prop_file_keys() -> void:
f.close() f.close()
var key: String = cache.prop_file_key(path) var key: String = cache.prop_file_key(path)
var mtime: int = FileAccess.get_modified_time(path) var mtime: int = FileAccess.get_modified_time(path)
check("prop_file_key format", key == "prop_thumb_key_test_%d" % mtime) check("prop_file_key includes file namespace and mtime", key.begins_with("file_") and key.ends_with("_%d" % mtime))
check("prop_file_png uses PROP_DIR", cache.prop_file_png(path) == "user://thumbnails/props/%s.png" % key) check("prop_file_png uses PROP_DIR", cache.prop_file_png(path) == "user://thumbnails/props/%s.png" % key)
check("prop_file_key missing file still formats", cache.prop_file_key("res://nope.prop") == "nope_0") check("prop_file_key missing file still formats", cache.prop_file_key("res://nope.prop").begins_with("file_") and cache.prop_file_key("res://nope.prop").ends_with("_0"))
DirAccess.remove_absolute(path) DirAccess.remove_absolute(path)
+1 -1
View File
@@ -6,7 +6,7 @@ RELATIVE_TARGETS = [
"../../AGENTS.md", "../../AGENTS.md",
"../../README.md", "../../README.md",
"../../plans", "../../plans",
"../../docs" "../../specs"
] ]
NOTEBOOK_TITLE = "Stickman Studio" NOTEBOOK_TITLE = "Stickman Studio"