Implement Phase 17a Prop Roles UI/UX Specification and associated functionality

- Added UI/UX specification for prop roles and set down mechanics in phase 17a.
- Created `PropStageRoles` script to manage normalized behavior for props, including depth, motion, and collision settings.
- Introduced `StagePhysicsPolicy` for shared physics configurations related to props and actors.
- Developed comprehensive tests for prop roles, including default settings, legacy migration, serialization, and runtime behavior.
- Ensured proper handling of set down actions, including clearance and orientation preservation.
This commit is contained in:
2026-10-09 23:11:34 -04:00
parent 3f66cc3195
commit 1d4a71525a
52 changed files with 20051 additions and 109 deletions
+156
View File
@@ -0,0 +1,156 @@
# Phase 17a — Prop roles and Set Down: architecture specification
> **Status:** User-approved and accepted: Phase 17a implementation and architecture.
> **Product contract:** [Phase 17 plan](../plans/phase_17_props_for_storytelling.md) and [approved 17a UI/UX specification](phase_17a_prop_roles_ui.md).
> **Scope:** Placed prop depth and behavior, covered-object selection, and Set Down in the Sandbox Stage. Use spots, wobble, groups, and doorways belong to later work.
## Decisions and behavior
- A placed `PropBlock` stays a `RigidBody2D`. An authored role profile determines its PLAY-start state; pickup temporarily makes it held and collision-free; every intentional release makes it a dynamic body with the authored after-release collision profile. This avoids replacing nodes and preserves `stage_object_id`, queue references, signals, and scale handling.
- New placements start still. Existing scene props without 17a fields retain their legacy dynamic, collidable behavior. The chosen default for **After letting go** is **Bumps into stickmen: On** and **Bumps into other props: On**. Explicitly turning the latter Off means the released prop passes through other props, including support props; terrain still catches it.
- **Holds other props** advertises a prop's existing physical shape as a support. It creates no support line. A prop can support another prop and let characters pass through. A released prop can rest on a support only if its **Bumps into other props** setting is On. When both **Holds other props** and a released prop's **Bumps into other props** are Off, the Off setting wins for that released body's own prop contacts.
- **Implemented behavior:** A newly configured **Blocks stickmen** prop blocks the character's body and movement route but does not become a walkable terrain platform. Terrain supplies normal ground and navigation. Legacy scene behavior, including recovery probes that previously found props, is preserved. Keep obstruction and recovery cases in the regression checklist.
## Implementation snapshot
- `PropStageRoles`, `PropBlock`, and `StagePhysicsPolicy` now hold per-placement roles and the EDIT, start-still, start-physics, held, and released collision profiles. Stage files use version 1.2; 1.0 and 1.1 migrate, missing role data receives legacy behavior, and invalid present role values are rejected before the scene is replaced. The reusable `.prop` format remains unchanged.
- `StageSelection.hit_test_all()` returns geometric candidates in draw order. EDIT opens an **Objects here** chooser for overlapping objects, including covered props.
- The prop settings card is built from the shared `scripts/shared/stage_ui/` controls and `stage_panels` theme tokens. See the [Stage panel UI system](stage_panel_ui_system.md) and the [17a UI specification](phase_17a_prop_roles_ui.md).
- `set_down_prop` is wired through the queue, rules, editor, save/load, target capture, and `StickmanPropInteractor`. The implementation provides a conditional preview when the held prop cannot be inferred, checks release clearance and reach, keeps the held prop non-collidable through approach/reach, restores the pickup orientation, and releases it with the configured profile.
- Depth currently uses fixed behind/in-front draw tiers. There is no ground-point Y sorting. Use spots, impact wobble, scene groups, and functional doorways remain outside 17a.
`tests/test_phase17a_prop_roles.gd` has been added for role defaults/migration, serialization, runtime profiles, support contact, Set Down clearance/release, orientation, and low reach. Automated test run: no execution result is documented.
## Pre-17a baseline and integration points
| Existing code | Consequence for 17a |
| --- | --- |
| Before 17a, `PropBlock` at `scripts/props/prop_block.gd` was one vector `RigidBody2D` with generated collision shape and a stable `stage_object_id`. | Instance settings are stored on this node, independent of the reusable `.prop` asset; shape generation and material physics remain in place. |
| Before 17a, `SandboxStage._start_run()` unfreezed every `RigidBody2D`; `_reset_run()` froze them and `_restore_authored_state()` restored transforms. | PLAY now applies each prop's authored start profile, and reset restores role state, masks, depth, velocity, and transform after held props are detached. |
| Before 17a, `StickmanPropInteractor._commit_pickup()` saved freeze, masks, and z order before clearing collisions, and `_release_held()` restored that snapshot. | Drop, Throw, and Set Down now use the authored *released* profile; scene reset restores the authored start profile. |
| Before 17a, `StickmanLocomotion._update_walking()` wrote `rig.global_position` directly, and animated body proxies and terrain shared collision bit 1. | Named physics bits and movement sweeps now let **Blocks stickmen** stop grounded movement; changing a prop mask alone would not stop direct root movement. |
| Before 17a, `StageSelection.hit_test()` considered one world AABB winner, with ragdoll hit priority. | `hit_test_all()` now returns ordered candidates for the **Objects here** chooser. |
| Before 17a, `StageSceneIO.FILE_VERSION` was 1.1; stage save wrote prop ID, asset, scale, and material. | Stage files now write 1.2 with validated optional per-placement roles and migration defaults; `.prop` files and queue target IDs remain unchanged. |
## Static contracts and ownership
Add a small `class_name PropStageRoles extends RefCounted` in `scripts/props/prop_stage_roles.gd`. It owns constants, allowed strings, default/migration profiles, validation, and the pure contact predicate. It is not a node or an event bus. The stage and interactor call it through `PropBlock` methods. Example contract:
```gdscript
class_name PropStageRoles
extends RefCounted
const DEPTH_BEHIND: String = "behind"
const DEPTH_FRONT: String = "front"
const START_STILL: String = "still"
const START_PHYSICS: String = "physics"
static func new_placement() -> Dictionary: ...
static func legacy_placement() -> Dictionary: ...
static func normalize(raw: Variant, fallback: Dictionary) -> Dictionary: ...
static func props_should_collide(a_roles: Dictionary, a_state: int, b_roles: Dictionary, b_state: int) -> bool: ...
```
`PropBlock` owns an explicit, typed API rather than letting UI code assign physics flags:
```gdscript
enum RuntimeRole { EDIT, START_STILL, START_PHYSICS, HELD, RELEASED }
var stage_roles: Dictionary = {} # normalized per-placement profile
var runtime_role: RuntimeRole = RuntimeRole.EDIT
func set_stage_roles(roles: Dictionary) -> void: ...
func apply_runtime_role(role: RuntimeRole) -> void: ...
func can_be_picked_up() -> bool: ...
func is_actor_blocking() -> bool: ...
func can_receive_prop_contact() -> bool: ...
```
The saved per-prop entry gains one `roles` dictionary. The values below are JSON-safe and remain independent; a copied placement receives a new `stage_object_id` and a deep copy of the roles.
```json
{
"file_id": "prop_2",
"type": "prop",
"stage_object_id": "existing-stable-id",
"roles": {
"depth": "behind",
"start_motion": "still",
"holds_other_props": true,
"blocks_stickmen": false,
"can_pick_up": false,
"released_blocks_stickmen": true,
"released_bumps_other_props": true,
"legacy_draw_order": false
}
}
```
Set `StageSceneIO.FILE_VERSION` to `"1.2"` and accept/migrate `"1.0"`, `"1.1"`, and `"1.2"`. Apply the existing 1.0 stable-ID migration first, then add 17a role fields to old props as `legacy_placement()`. A 1.2 prop with no `roles` also receives that legacy profile, so an incomplete older export is safe. For a present `roles` dictionary, missing known keys take `new_placement()` defaults; present known keys must have valid strings or strict booleans, otherwise reject the document in `StageSceneIO` before `_apply_scene_data()` destroys the current scene. `legacy_draw_order` is a persisted boolean inside `roles`; it is true only for migrated props or a saved legacy placement, and becomes false when the child explicitly chooses a depth. New placement uses `new_placement()`: behind characters, starts still, does not hold others, does not block stickmen, pickup On for assets with a usable grip, both after-release bumps On, and `legacy_draw_order=false`. For assets without a compatible grip, `can_pick_up` defaults Off and is unavailable until the grip exists. Do not infer settings from an asset filename. Keep `ContentPaths.canonicalize()` for path-bearing entries; roles contain no paths. Existing `stage_object_id` remains the action reference. Do not alter `.prop` version 1.0. Preserve missing-prop placeholder `original_entry`, including unknown role data, when round-tripping.
An older prop was previously dynamic at PLAY start, collided with other props and characters, and was pickable if it had a grip. Legacy defaults reflect those facts. Old files can contain props interleaved with characters in `World` child order; two new depth choices cannot exactly represent every old interleaving. Preserve object order and keep migrated props at `z_index == 0` while `legacy_draw_order=true`. Derive the displayed Behind/In front choice from the number of characters before versus after the prop in saved order (ties choose Behind); show a short **Old scene order** cue on the card because a mixed old arrangement may sit between characters. An explicit depth click sets `legacy_draw_order=false` and moves the prop to its chosen tier relative to every character. Re-save the marker and object order so reload does not silently move the prop. New props use fixed behind/front tiers, without Y sorting.
## Physics and collision policy
Define named physics bit constants in one stage physics policy module; keep terrain's existing bit 1 so navigation, ground rays, and old content retain it. Reserve distinct bits for animated/ragdoll actors, general props, and props that currently block actors. Configure animated collision proxies, ragdoll bodies, props, and physics queries from these constants. Update `StickmanLocomotion` ground/landing probes and airborne sweeps deliberately: normal terrain following still accepts `TerrainBlock` only, recovery keeps its previous legacy prop behavior, and the airborne horizontal sweep also sees props with the current actor-blocking bit.
Do not rely on a one-sided mask to make an unwanted contact disappear. Godot masks describe what each body scans, so a second body's scan can still create a contact. The policy sets both sides' relevant bits and maintains symmetric `PhysicsBody2D.add_collision_exception_with()` / `remove_collision_exception_with()` for prop pairs whose instance settings forbid contact. Reconcile exceptions when a prop is placed, deleted, edited, picked up, released, reset, or loaded; update affected pairs, not every pair every frame. Held props clear both layer and mask and are excluded from overlap queries. For a modest scene, pair reconciliation on these discrete transitions is simpler than body replacement or one physics layer per placed prop.
The intended matrix is:
| Pair | Contact rule |
| --- | --- |
| Any unheld prop / terrain | Yes; terrain still catches released props. |
| Prop / animated or ragdoll stickman | The prop's current `blocks_stickmen` choice at PLAY start, or `released_blocks_stickmen` after release. Held: No. |
| Start-still prop / moving prop | Yes only when still prop **Holds other props** and moving prop accepts prop contact. |
| Two moving props | Yes when both accept prop contact; an explicit after-release **Bumps into other props: Off** vetoes the pair. |
| Two start-still props | No physical response is needed; authored placements remain independently selectable. |
Starting dynamic props accept contact with configured supports and other dynamic props; the initial card has no separate outgoing prop-bump switch. A start-still prop advertises prop support only when `holds_other_props` is On. A released prop with `released_bumps_other_props` Off passes through every prop, even a support. If a released prop itself has `holds_other_props` On, it can support another prop only while its own prop-bump setting is On. Collision events must be emitted only for actual allowed contacts. On reset, clear contact-pair suppression/baselines to avoid fake collision events on the next PLAY. Avoid changing material mass, bounce, or shape geometry to express these roles.
```text
Authored EDIT ──PLAY──> START_STILL (frozen) or START_PHYSICS (dynamic)
^ │ │
│ successful pickup successful pickup
│ └─────────┬───────────────┘
│ v
│ HELD (no collisions)
│ │ Set Down / Drop / Throw
│ v
└────────────STOP/RESET──────── RELEASED (dynamic)
```
In EDIT, props remain frozen and physically quiescent but geometrically selectable. On PLAY start, `START_STILL` uses `freeze = true` with the existing generated collider active; `START_PHYSICS` uses `freeze = false`. On pickup, verify `can_pick_up` and a valid grip, reserve the prop, save only the temporary pose state needed for cancellation, freeze it, and clear collisions. On Set Down, Drop, or Throw, apply `RELEASED`, wake the body, and apply its authored release masks regardless of its start motion. Set Down gives zero launch velocity and spin, so it falls under gravity; Drop retains existing loose-release behavior unless the revised implementation makes a small compatibility correction. Throw retains its computed impulse. After release, it remains pickable when `can_pick_up` is On. Ragdoll forced release uses the same released profile. Scene exit/reset detaches held props, clears their owner/reservation, restores the authored role, depth, transform, and zero velocity before another PLAY. Pause/resume preserves each prop's current runtime role and frozen state rather than turning start-still props dynamic.
Draw order is per placement: new behind props render beneath characters and new front props above them, independent of physics. Use stable `CanvasItem.z_index` tiers for unheld props and the existing held-hand offset while held; restore authored depth on release. Selection outlines/gizmos draw in a separate overlay above stage artwork. Sorting and selection compare effective z tier, then sibling order; AABB size is only a tie-breaker for objects with equal draw rank.
## Character movement must honor blocking props
`StickmanLocomotion._update_walking()` currently assigns the root transform and can cross a physical body even when its limb proxies collide. Add a horizontal shape sweep of the animated body's relevant torso/head proxy shapes before every grounded movement step and before the final arrival snap, excluding the actor's own proxy RIDs and the held prop. Query terrain and the actor-blocking prop bit; retain the largest safe fraction that does not penetrate a blocking prop. On obstruction, stop advancing, keep grounded terrain following, and fail/finish the affected Move To or Set Down approach with a visible reason after a short no-progress interval; do not report arrival at an unreachable point. Use the same actor-blocking predicate for preview route checks. Continue to use the existing terrain navigation for walkable ground. When a blocking prop moves into the route during PLAY, recheck from the current pose; do not tunnel through it.
This collision sweep also needs to cover direct steering, close-target fast paths in `move_to()`, and any other root-position shortcut that can cross a prop. Airborne horizontal motion already casts shapes; update its mask to include actor-blocking props. Ragdoll bodies receive compatible masks and must not collide with a prop set to pass through stickmen. Existing prop collision reaction/event logic still receives allowed contacts.
## Set Down action and reach
Use the flat action `{ "type": "set_down_prop", "target_position": Vector2 }`. The corresponding saved queue value is `{ "type": "set_down_prop", "target_position": {"x": 420.0, "y": -260.0} }`; rule actions use `ActionRegistry.to_rule_action()` / `from_rule_action()` and the existing `StageSceneIO` rule encoder, with the same parameter inside `params`. `target_position` is the requested prop origin at release, not a landing target, grip position, table attachment, or support ID. It must be finite. Runtime may raise this point only when needed to clear the collision geometry of a support beneath it; the authored request stays unchanged. The queue runner waits until the approach, reach, and smooth body recovery complete or fail, and a failed action completes safely so later actions can proceed.
Add `set_down_prop` to `ActionRegistry`, `ActionEditor`, stickman action popup, stage target-capture enum, cursor overlay, queue summaries, and `StageSceneIO` encode/decode. The Action Editor uses **Choose release place** / **Change release place**, Done only after a finite stage point has been chosen, and the existing popup hide/restore pattern for stage capture. Both direct queue and rule authoring reach the same capture function. Esc, right-click, or visible Cancel restores the prior draft point when editing and discards an unsaved new candidate. For a known prop, a green click records the coordinate and a red click leaves capture open. When exact prop/reach/occupancy cannot be known from earlier actions, a finite click records a **conditional** target with the neutral marker and **Prop depends on earlier actions**; it is never colored green or presented as verified. PLAY performs the full check and gives a recoverable failure cue if needed. Snap, if enabled, changes only the clicked world point. `ContentPaths` is unaffected.
The interactor adds SET_DOWN_APPROACHING and SET_DOWN_REACHING (or equivalent explicit substates), with one shared nonmutating solver for authoring preview and PLAY validation. It reuses the queued held-prop preview prefix from Throw to determine the prop, grip, hand, and predicted actor pose. The prefix evaluator must also consume earlier Set Down actions as release actions and account for Drop/Throw; it may return `known=false` when a rule or earlier action makes the held prop uncertain. Do not move or reserve a live prop during preview. When unknown, show the neutral marker and **Prop depends on earlier actions**, and assess grounded approach/reach where possible without pretending to know exact occupancy. As with the existing Throw preview, cache physics shape checks in `_physics_process()` and draw the latest result in `_process()`; do not perform fresh direct-space queries from every pointer draw.
For a known prop, retain its current held orientation and authored/play scale. Treat `target_position` as the requested release origin, then check the prospective collision shape against terrain and released-profile actor/prop contacts; exclude the held prop's own RID but check the acting rig's torso/head and other solid parts when released actor bumps are On. If the shape overlaps terrain or a prop whose **Holds other props** setting is On, raise the prospective origin vertically just enough to clear the overlapping support geometry. Do not shift it sideways or select a named surface. Keep the requested point unchanged in the saved action. If the shape overlaps the acting character or a non-support prop, reject it with **No room to let go here**. Do not add an implicit owner-collision grace period or permit release inside the torso. An open-air point remains exactly where clicked. Compute the grip position from the resolved transform and asset anchor, then solve a grounded approach on terrain to either side and check wrist/IK reach. Do not teleport the actor or clamp the point to make it reachable. Preview the requested point and corrected release origin when they differ. Re-resolve at runtime because actors and props may have moved since authoring. If the route is blocked or the point remains occupied, keep the prop held, restore its hand pose, show **Too far to reach** or **No room to let go here**, finish the action safely, and let the child edit the target. If no prop is held, show a short cue on that actor and finish safely without creating or dropping a prop.
During the reach, move the held prop smoothly with the hand marker toward the resolved release transform. If the point is low, bend the torso and knees during the reach, then release the prop and ease the arm and body back to their resting pose. At release, use the resolved origin and apply `RELEASED` with zero launch velocity so gravity carries the prop onto the surface. The support prop's collision geometry and gravity determine where the object settles. While held, the prop has no collision layer or mask; released collision settings take effect at the release moment. A pause freezes the animation and prop state. Ordinary action cancellation before release keeps the prop held and restores the marker; ragdoll aborts Set Down and uses the existing forced-release path with the authored released profile. STOP restores authored placement. Preserve the existing grip attachment logic and scale conversion rather than assuming an unscaled local anchor.
## Stage UI, selection, and theme
Create the `PanelContainer` Prop settings card in the stage UI, with `MarginContainer`, scrollable `VBoxContainer`, header thumbnail/name, a large Close control, the exact copy and controls in the [UI spec](phase_17a_prop_roles_ui.md). Bind it only to one selected `PropBlock`; selecting a stickman/terrain hides it, and multiple selected nodes show **Select one prop to change its settings** rather than editing several silently. Choice changes normalize roles, update EDIT depth immediately, mark the scene dirty, and refresh the card. Close preserves selection and settings. Existing thumbnail cache can provide the prop header image; a type icon is acceptable until an asset thumbnail is ready.
Expose `StageSelection.hit_test_all(world_pos: Vector2) -> Array[Node2D]`: gather selectable `World` children geometrically even when occluded, sort by effective canvas z and sibling order, and return the top candidate first. Keep ragdoll-to-rig mapping. Normal click selects the top visible candidate; when several candidates overlap, show the **Objects here** button near the pointer. The chooser lists every candidate with at least 44 px rows, thumbnail/type icon, name, depth cue, and current-selection check. Choosing a row calls `select_only()` with that exact node, so a fully covered prop stays editable. The chooser has no effect on shift multiselect or box selection; dismissal leaves current selection intact. A selection overlay remains above all prop/actor art regardless of depth.
Use `SandboxTheme.control_theme()` on the card and popup. Read dimensions, fonts, accents, selected, valid, and invalid colors from `SandboxTheme` / `sandbox_theme.json`, adding narrowly scoped fallback tokens for the Prop card and Set Down preview. Every interactive target is at least 44 × 44 logical pixels and no shorter than the theme's button minimum; the card scrolls at narrow widths. Set Down overlay includes amber DIRECT framing, **Click where to let go**, a release cross, known-prop ghost or neutral marker, route/reach guide, green **Ready to set down** or the specific red failure cue, and a visible Cancel control. The ghost forecasts release pose only, not final landing. Selection and state use outlines/checks and text as well as color.
## Regression checklist
For future regression runs, use `tests/test_phase17a_prop_roles.gd` for role defaults/migration, queue and rule Set Down serialization, unknown-prop preview, released-vs-held transitions, and support contact, followed by Godot headless syntax validation. A small runtime scene can check that a support-only table catches a released pot while a stickman passes through, that switching **Can bump into other props** Off makes the pot fall through the table but still land on terrain, and that **Blocks stickmen** stops walking instead of letting the root tunnel through. Reopen saved scenes, copy a placement, select a fully occluded prop, and check correction of a bad Set Down target. Repeat PLAY after STOP and confirm identical authored arrangement, role settings, and usable stable IDs. Check pause/resume during holding and after release, plus ragdoll release.
The approved table/pot, open-air Set Down, invalid-target feedback, overlap chooser, card readability, old-scene replay, and movement-blocking behavior are useful visual/runtime regression scenarios.