Implement interactive prop handling for StickmanRig

- Added PropTrajectory class for deterministic ballistic calculations.
- Introduced StickmanPropInteraction component to manage prop interactions (pickup, drop, throw).
- Implemented grip resolution and attachment logic for props.
- Added signals for interaction events (prop_picked_up, prop_dropped, prop_thrown).
- Defined specifications for prop interaction lifecycle and event rules.
- Ensured compatibility with existing prop systems and added new capabilities for prop pickup.
This commit is contained in:
2026-09-23 21:26:13 -04:00
parent 0a7f97cec7
commit 043f114083
22 changed files with 1444 additions and 37 deletions
+259
View File
@@ -0,0 +1,259 @@
# Phase 11 — Interactive Props and Event Rules
Status: **approved — implementation complete, awaiting Editor verification** (2026-09-23).
Source: plans/PHASE_11_PROP_INTERACTION.md, refined through the spec-pipeline architecture round.
## 1. Accepted decisions
1. Deliver the work as gated milestones. Milestone 11A adds the interaction foundation, pickup/drop, and their triggers. Milestone 11B adds deterministic aimed throws and the trajectory preview. Milestone 11C adds ragdoll disarm and lifecycle hardening. Each milestone must work before the next begins.
2. Valid props are pickup-enabled by default and use authored grip anchors when available. An explicit **can_be_picked_up** capability lets a prop such as a tree opt out. Props without a compatible authored grip use a deterministic bounds-center fallback.
3. Throws target an exact point and save the selected arc/flight-time setting. The same solver drives the preview and PLAY release. A finite launch-speed cap and flight-time range reject targets that would require an implausible throw.
These decisions are requirements for implementation.
## 2. Scope
### 2.1 Milestone 11A — pickup, hold, drop, and triggers
- Add the can_be_picked_up asset capability to .prop data and the Prop Editor. Missing values default to true for old .prop files. Existing built-in props remain pickup-enabled; future built-ins can opt out in their library entry.
- Carry normalized grip anchors and the pickup capability through PropLibrary, StageSpawner, and PropBlock. Custom props currently lose their anchors at the library boundary; this closes that gap.
- Add a dedicated StickmanPropInteraction component under RigComponents. It owns one held prop, the selected hand and grip, saved IK target state, the pickup/approach lifecycle, and release.
- Add pick_up_prop and drop_prop queue actions. The queue runner delegates these actions to the component and waits for completion. Auto-approach is a private subgoal that calls the existing horizontal walk_to behavior; it does not insert a visible or saved queue action.
- Extend Director action editing/target capture so the actor and a specific prop instance can be selected. Prop identity uses a stage object file ID, not spawn_id, because several instances can share one asset.
- Extend StageSceneIO queue serialization to map runtime prop instance IDs to and from scene file IDs. Preserve scene version 1.0 with additive action fields. A missing target drops only the unresolved action with a warning, following the existing unresolved-reference policy.
- Add prop_picked_up and prop_dropped trigger types. Emit them from the actual successful state transition, with source = acting StickmanRig and target = affected PropBlock. Drop includes manual release; ragdoll disarm is added in 11C.
- Add the trigger choices to TriggerRegistry and RuleEditor. Existing consequences remain the current stickman action set; prop-targeted consequence actions are out of scope.
### 2.2 Milestone 11B — aimed throw
- Add throw_prop with a specific prop instance, exact world target, and flight_time/arc setting.
- In DIRECT, capture the landing point through the stage's existing target-capture flow and show a live parabolic preview. The action is saved in the actor's queue and runs in PLAY; there is no free-aim input during playback.
- Use one pure trajectory solver for preview and release. It uses the release point, target, flight time, and the live 2D gravity vector scaled by the prop's gravity_scale.
- Initial tuning envelope: minimum flight time 0.35 s, maximum flight time 1.5 s, and maximum launch speed 900 px/s. Treat these as named tunable constants. Validate finite inputs and reject a target whose required velocity exceeds the speed cap. Do not clamp the velocity, since that would miss the stored target.
- The Director preview marks an invalid target clearly and does not commit the action until it is valid. Changing the arc setting re-solves both preview and stored velocity from the same function.
- Release uses the computed initial velocity directly as PropBlock.linear_velocity, not as an unscaled impulse. This keeps a selected trajectory independent of material mass. Apply angular spin as a separate bounded setting or deterministic default; it must not alter the linear landing target.
- Add prop_thrown, emitted once at the successful physics release. It uses the same source/target direction as the other prop triggers.
### 2.3 Milestone 11C — ragdoll disarm and lifecycle hardening
- Before StickmanRagdoll begins building bodies, StickmanRig asks StickmanPropInteraction to release any held prop. The release must happen before the animated body is hidden and before the rig pose is handed to physics.
- Transfer a finite linear and angular momentum snapshot from the rig to the released PropBlock. Add a typed snapshot API at the rig/ragdoll boundary if needed; capture values before the ragdoll transition changes the source pose.
- Emit exactly one prop_dropped event with a ragdoll release reason.
- Release safely on PLAY exit and actor/prop deletion. PLAY exit restores the stage's authored transforms through the existing authored-state snapshot. Invalid references must never leave a prop frozen or an IK marker targeting a freed node.
- Cover interruption during approach, reach, windup, or release without duplicate triggers or stale physics state.
### 2.4 Explicitly deferred
- Two-hand carries, inventory/equipment slots, multiple held props per rig, melee/use actions, prop-targeted rule consequences, and AI choice of which prop to use.
- Slope-aware approach, full obstacle avoidance, or a general 2D path/reach planner. Approach uses the existing horizontal locomotion and reports a clean failure if it cannot reach a valid stand point.
- Multiplayer, network replication, persistent physics state, and new scene-file versioning.
- A dedicated stage event registry node. SandboxStage remains the event dispatcher and matcher.
- Ragdolling a held prop together with a character; a held prop is disarmed into ordinary world physics before ragdoll construction.
## 3. Existing architecture constraints
- StickmanRig is the public facade. It owns RigComponents/Locomotion, Ragdoll, and ActionRunner; prop interaction belongs beside those components rather than in the facade or a second stage-only controller.
- StickmanActionRunner uses action phases and emits action_started/action_finished. A prop phase must preserve queue order and action lifecycle signals. Internal approach steps must not appear as authored queue rows.
- Locomotion is horizontal, feet-based, and exposes walk_to. The pickup stand point is beside the prop bounds on the ground plane. If navigation cannot reach it, the pickup action fails without attaching.
- DIRECT authors queue actions while PLAY executes them. Props are frozen for authored editing and simulate in PLAY. Prop actions are authored in DIRECT and remain deterministic in PLAY.
- PropAnchorData.compatible_grip_ids() and solve_attachment_transform() already provide hand compatibility and mirror-aware grip alignment. Reuse those helpers. PropAttachPreview is the reference for sampling solved wrist endpoints and converting between the wrist and authored grip.
- There is no StageEventRegistry node. TriggerRegistry describes trigger metadata; SandboxStage owns _handle_event(), matching, and reactive action dispatch.
- StageSceneIO currently serializes queues without an object-reference map. Rules already encode runtime object references to scene file IDs; queue serialization must gain the equivalent mapping for prop targets.
- PropBlock is a RigidBody2D and remains parented under World during a hold. Do not use RemoteTransform2D reparenting for runtime socket attachment.
## 4. Data model and asset compatibility
### 4.1 Pickup capability
Add **can_be_picked_up: bool** to the PropFile in-memory model and additive top-level .prop field:
{
"version": "1.0",
"prop_name": "Oak Tree",
"can_be_picked_up": false,
"anchors": {},
"shapes": []
}
PropFile.to_file_dict() always writes the boolean. PropFile.from_file_dict() reads only an actual boolean; absent or malformed values become true. Keep FILE_VERSION at 1.0, matching the project's existing additive-field policy. Loading an old file does not dirty it merely because the default was supplied.
Add a kid-facing “Can be picked up” checkbox to the Prop Editor's general prop settings. It is persisted through normal save, save-as, autosave, recovery, and New. This capability is asset metadata; it does not change the prop's collision, mass, or gravity.
PropLibrary.make_custom_entry() must preserve can_be_picked_up and normalized anchors. Built-in templates include an explicit value, initially true for crate, ball, plank, and triangle. StageSpawner transfers both fields to PropBlock. PropBlock exposes a typed runtime property with a safe default of true for scenes and callers that do not come through StageSpawner.
### 4.2 Grip resolution
For an eligible prop, pickup resolves a hand/grip pair in this order:
1. Enumerate authored grip anchors compatible with each candidate hand using PropAnchorData.compatible_grip_ids().
2. For hand: "left" or "right", choose the compatible grip that requires the least reach for that hand. For hand: "auto", compare compatible hand/grip pairs and choose the shortest valid reach. Break ties deterministically in favor of the right hand, then lexicographic grip ID.
3. If no authored grip is compatible, synthesize a runtime-only grip at the center of PropBlock's full visual local bounds, with zero rotation and both hands allowed. Do not serialize this fallback into the asset.
4. Reject pickup immediately if can_be_picked_up is false, the prop is invalid/already held, the rig already holds a different prop, or no arm can reach after approach. Report a short Director status message and finish the action cleanly.
Only one prop per rig and one rig per prop are supported in this phase. The check and hold reservation occur synchronously before the reach animation begins, so two queued actors cannot both acquire one prop.
## 5. Runtime ownership and interaction lifecycle
### 5.1 Component boundary
Add scripts/stickman_prop_interaction.gd and a PropInteraction child to scenes/stickman_rig.tscn under RigComponents. StickmanRig resolves/configures it, exposes narrow public forwarding methods or typed signals where the stage needs access, and invokes release before ragdoll handoff.
The component owns:
- A small state machine for idle, approaching, reaching, holding, winding up, and recovery.
- Held PropBlock, chosen hand/grip ID, saved IK target/rest pose, and any interpolation timers.
- A single release kernel used by drop, throw, ragdoll, mode exit, and object cleanup.
- The authoritative attachment transform update and its physics-state snapshot.
Suggested typed completion contract:
signal interaction_finished(action: Dictionary, succeeded: bool)
signal prop_action_executed(action_type: StringName, rig: StickmanRig, prop: PropBlock, reason: StringName)
Use the project's normal typed-signal style if names differ, but the contract must distinguish failed actions from successful transitions and must emit each transition once.
### 5.2 Pickup and hold
1. Resolve and reserve the target prop. If the rig is farther than its current arm reach, compute a stand point adjacent to the prop's left or right bounds, on the ground plane, and call the existing locomotion.walk_to(). Select the side that is reachable and requires less horizontal travel; use a deterministic tie-break.
2. When locomotion reports arrival, recompute reach from current bone/wrist positions. If the target is still outside the solver's safe arm reach, fail cleanly rather than repeatedly walking or forcing a stretched IK limb.
3. Interpolate the selected IK hand marker toward the authored grip's current world position for the configured reach duration. Preserve the opposite hand target. Cache the pre-interaction hand target once so release restores the actual locomotion/idle pose.
4. At reach completion, solve the prop transform from the solved wrist transform and selected anchor with PropAnchorData.solve_attachment_transform(). Freeze the prop, keep it under World, suppress collisions against that rig's animated collision proxies, and latch the hand target.
5. While holding, update the prop's global transform from the solved wrist and grip using the same helper. Apply the anchor's z_index_offset relative to the holder while preserving the prop's authored z state for restoration.
6. Prop movement follows the hand during locomotion. The prop remains frozen and does not participate in collision contacts while held.
The exact process callback/order must be selected during implementation by matching the solved wrist update order. The attachment may never lag a frame behind a visible wrist or feed the target marker back into itself.
### 5.3 Drop and release
The central release operation accepts a reason and a linear/angular velocity payload. It is idempotent and:
- Validates and clears the held-prop reference and hand latch.
- Restores the cached freeze mode, collision layer/mask/exceptions, z state, and saved gravity/damping values.
- Sets final global transform, linear_velocity, and angular_velocity before enabling dynamic simulation.
- Restores the selected hand marker smoothly to its cached pose.
- Emits the successful release event exactly once after the prop is in a valid world-physics state.
Manual drop uses the rig's current finite root/locomotion velocity and a neutral angular velocity unless a deterministic small spin is approved during implementation. An interrupted pickup before the hold latch cancels the reach and restores the marker without emitting picked-up or dropped. Stopping a queue after a completed pickup does not silently drop the held prop; only a drop action or a lifecycle release does.
PLAY exit releases first, then the existing authored-state restore returns prop and rig transforms to the EDIT/DIRECT snapshot. Deleting the held prop clears the hold and hand target without attempting to restore a freed node. Deleting the holder drops the valid prop safely before the rig leaves World.
## 6. Action and scene-file contracts
### 6.1 Runtime payloads
Runtime actions use node instance IDs; an asset path or spawn_id is never used to choose among duplicate prop instances.
{ "type": "pick_up_prop", "target_prop": 123456, "hand": "auto" }
{ "type": "drop_prop" }
{ "type": "throw_prop", "target_prop": 123456, "target_position": Vector2(...), "flight_time": 0.9 }
hand accepts auto, left, or right. flight_time is stored in seconds and validated against the configured bounds. Drop acts on the rig's currently held prop and carries no prop ID.
### 6.2 Saved payloads
StageSceneIO maps target_prop through the same scene-object reference tables used by rules:
{ "type": "pick_up_prop", "target_prop_id": "prop_3", "hand": "auto" }
{ "type": "drop_prop" }
{ "type": "throw_prop", "target_prop_id": "prop_3", "target_position": { "x": 500, "y": 80 }, "flight_time": 0.9 }
Queue encode/decode receives the relevant runtime-to-file and file-to-runtime maps. Reject non-finite target coordinates, invalid hand values, non-finite/out-of-range flight times, missing prop references, and a throw target that fails the same solver validation. Invalid actions are skipped with the existing queue warning mechanism; unrelated actions and the rest of the queue remain loadable.
ActionRegistry, ActionEditor, QueuePanel, stage action capture, and ActionRegistry summaries expose the new actions. The queue editor's prop picker selects an object instance from World and displays its current asset name plus a stable per-scene ordinal so duplicate crates are distinguishable.
### 6.3 Trigger/event contract
Register:
| Type | Source | Target | Firing point |
| --- | --- | --- | --- |
| prop_picked_up | acting StickmanRig | acquired PropBlock | Hold latch succeeds and the prop is aligned to the wrist |
| prop_dropped | acting StickmanRig | released PropBlock | Manual drop or lifecycle disarm completes |
| prop_thrown | acting StickmanRig | released PropBlock | Throw velocity is assigned and physics is enabled |
The event dictionary includes type, source, target, event position, and reason/action data. Rule matching honors exact runtime source and target IDs and wildcard -1 values as it does for existing events. The trigger UI identifies the target kind as prop. prop_picked_up and prop_dropped are added in 11A; prop_thrown is added in 11B. A ragdoll release is prop_dropped with reason ragdoll, not a second new trigger type.
Emit transitions from StickmanPropInteraction through StickmanRig to SandboxStage. Do not infer pickup/drop/throw from action_finished: failed actions also finish, while lifecycle releases may not have a matching queue action.
## 7. Throw solver and Director preview
### 7.1 Solver
Create one pure helper, owned by StickmanPropInteraction or a small stateless PropTrajectory utility:
solve_throw_velocity(release_point, target_point, flight_time, gravity_vector) -> result
For time t, return:
velocity = (target_point - release_point - 0.5 * gravity_vector * t * t) / t
The result contains ok, velocity, flight_time, and an error reason. Reject zero/negative time, non-finite vectors, time outside 0.35–1.5 seconds, or velocity magnitude over 900 px/s. Resolve gravity using the current project 2D gravity direction and magnitude multiplied by PropBlock.gravity_scale. Preview and release call this exact helper.
The initial cap and range are intentionally tied to the project's pixel scale: with standard downward gravity near 980 px/s², a same-height throw at the speed cap has a theoretical range of about 826 px, approximately one current stickman height. The speed cap is the hard physical bound; the flight-time bound prevents long, slow, implausible arcs. Keep both values named and tunable, and show invalidity instead of silently modifying the authored target.
### 7.2 Playback release
The throw uses a brief IK windup, then at its release moment:
1. Sample the current solved wrist transform.
2. Solve velocity again from the stored exact target, current release point, flight_time, and live gravity.
3. If the result is no longer valid (for example, the actor moved after authoring), abort with a concise message and restore the arm. Do not emit prop_thrown.
4. Release through the central kernel, set the computed linear velocity directly, apply the bounded spin, and emit prop_thrown.
5. Blend the arm back to its cached rest target.
Windup/recovery are target-marker tweens rather than a new animation resource or body-rotation system. The plan's approximate 100 ms windup, 150 ms release, and 50 ms follow-through are initial tuning targets, not hard timing compatibility requirements.
### 7.3 Director capture
Throw target capture is available while authoring a queue in DIRECT. The reticle shows the solved arc from the current expected release point to the pointer. A compact arc control stores flight_time. Invalid targets use the existing stage feedback/status path, are visibly distinct, and cannot be committed. The saved queue stores target position and flight_time; playback never reads the mouse.
## 8. Ragdoll and lifecycle order
StickmanRig.set_ragdoll(true) performs this order:
1. Ask StickmanPropInteraction for a finite rig momentum snapshot while the animated pose and root movement data are still available.
2. Release a held prop through the central release kernel with that snapshot and reason ragdoll.
3. Emit prop_dropped once through SandboxStage so matching rules can enqueue their normal reactive actions.
4. Continue the existing locomotion handoff and StickmanRagdoll.enter_ragdoll() path.
The release precedes body hiding and ragdoll construction. If ragdoll construction fails after a release, the prop remains safely released; it is never refrozen or rebound implicitly.
On PLAY exit, release any held prop before _restore_authored_state(). If queue/actor cleanup occurs while approaching or reaching, cancel the private subgoal and restore marker state. If the actor is removed while holding, drop into World using its last valid transform and finite velocity. All cleanup paths are idempotent.
## 9. Acceptance and user verification
### Milestone 11A
- Old v1.0 .prop assets load pickup-enabled and keep their existing anchors; a saved false capability survives Prop Editor save/load/autosave.
- A custom grip anchor reaches the hand with authored rotation, scale, mirror, and allowed-hand rules respected. Props without compatible grips use the deterministic bounds-center fallback.
- A non-pickup prop fails with feedback and never freezes. Existing built-ins remain eligible.
- Picking a nearby prop reaches, aligns, freezes, and holds it through walking. Dropping restores physics and collision behavior, hand pose, z state, and movement velocity.
- A distant target uses one private horizontal walk_to subgoal before reaching. An unreachable target ends cleanly with no queue mutation or stale hold.
- Duplicate instances of one asset can be independently targeted, save/reload retains the target instance, and unresolved IDs do not corrupt other queue rows.
- Pickup/drop triggers fire once only after successful transitions, identify the correct source and prop, and execute matching rules.
- DIRECT only authors the actions; PLAY runs them. PLAY exit restores the authored scene transforms.
### Milestone 11B
- The live preview and playback compute the same velocity for the same release point, target, flight time, gravity, and prop scale.
- Valid targets land at the stored point when unobstructed within normal physics tolerance. Gravity scale is respected.
- Targets outside the speed or time envelope are visibly invalid and cannot be saved. A prop whose release point changes enough during playback to exceed the cap aborts without a throw event.
- Throw velocity is mass-independent. prop_thrown fires once at release; no throw event fires on cancellation or invalid playback.
### Milestone 11C
- Entering ragdoll releases a held prop before kinematic body hiding and the physics handoff. The prop receives finite rig linear/angular motion and then responds to gravity/collisions.
- Ragdoll disarm fires one prop_dropped event with reason ragdoll.
- PLAY exit, deleted prop, deleted holder, queue cancellation, and invalid/stale references leave no frozen prop, dangling target, duplicate event, or lost authored state.
Manual verification takes place in the Godot editor and Sandbox Stage. Implementation must run the skill-prescribed headless per-script syntax check for changed scripts. Documentation/README updates are deferred until the user confirms: “Feature verified and working.”
## 10. Trade-offs and known limits
- The internal approach is horizontal because that is the existing locomotion contract. It will not find a route around an obstacle; it fails cleanly when the current navigation cannot reach the chosen stand point.
- A bounds-center fallback keeps built-ins and older props usable without inventing saved anchor data. Authors can add explicit grip anchors for better hand placement.
- One held prop per rig keeps state ownership simple. Two-hand grips are authored metadata but simultaneous two-hand solving remains deferred.
- A speed cap plus maximum flight time makes aiming deterministic and prevents arbitrary-distance throws. The initial cap is a tuning value that should be adjusted only against the visible stage/rig scale.
- Keeping the prop under World avoids fragile RemoteTransform2D paths and reparenting transforms, but requires a correctly ordered transform update from the solved wrist.
- Trigger matching is edge-triggered at the semantic transition. Rules may enqueue reactive stickman actions; this phase does not add prop manipulation as a rule consequence.