Files
stickman/specs/phase_10_prop_integration.md
T
ryan 97f3730413 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.
2026-09-21 23:13:35 -04:00

16 KiB

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

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:

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:

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:

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:

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:

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

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.