Files
stickman/docs/specs/phase_11_prop_interactions.md
T

477 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 11 — Prop Interactions and Event Triggers
Status: **Approved for implementation (2026-09-24).**
Throw UX amendment: **Approved for implementation (2026-09-25).**
Throw landing-target amendment: **Approved for implementation (2026-09-25).**
Queue-aware throw-preview amendment: **Approved for implementation (2026-09-25).**
Source: `docs/archive/plans/PHASE_11_PROP_INTERACTIONS.md`. This spec keeps the requested
pickup, drop, throw, ragdoll handoff, and “When…” features while adapting them to
the current Sandbox Stage architecture and closing the design gaps identified
during review.
## 1. Approved design decisions
1. A throw action stores a **fixed stage-world target position**. At run time the
trajectory starts at the actor’s current held-prop transform and aims at
that saved point. Actor-relative throws are deferred.
2. Phase 11 supports **one prop held in one compatible hand per stickman**.
Two-handed grips and holding multiple props are deferred.
3. Each placed prop has a **persistent per-instance stage ID**. The ID is
separate from the asset’s `spawn_id` and from Godot’s session-only instance
ID. Duplicates of the same library prop therefore remain distinguishable.
## 1.1 Previously approved throw UX amendment (2026-09-25; superseded by §1.2)
This amendment refines the approved throw action and does not add fields to its
saved payload. It supersedes the exact-landing interpretation of decision 1
and §3.3 while keeping `target_position` as a fixed stage-world point.
- **Cursor meaning:** the saved point is an aim-and-power handle. At release,
the vector from the predicted release point to that handle sets the launch
bearing; its length sets launch speed. The prop is allowed to land short of
or beyond the cursor as gravity and collisions dictate.
- **Power curve:** map cursor distance monotonically and smoothly to launch
speed, clamp at a maximum, and keep the limits in one set of tunable
constants. Initial tuning recommendation: 300 px/s at minimum input, rising
to a 1,250 px/s cap at 700 px of cursor distance. Distances beyond that stay
at the cap. Retain the existing near-zero-distance no-op guard.
- **Prediction:** preview and execution use the same release point, cursor
vector, power mapping, velocity, gravity, and prop gravity scale. Draw the
sampled ballistic path and show the cursor separately from the predicted
impact point. Use the held prop's collision shape to estimate its first
world collision along the path, ignoring the actor and held prop; if no
collision occurs within the existing 2.0-second maximum horizon, mark the
horizon endpoint. This remains an estimate for moving bodies.
- **Overhand motion:** replace the short backward/upward wrist offset with a
head-relative IK arc. Lift the held prop behind and beside the head, carry
it above the crown, then sweep forward over the head. Release during the
forward sweep and finish with a follow-through. Derive points from the live
rig pose and facing, and clamp them within the selected arm's reachable
length so the IK solver does not snap or overextend. A small torso lean may
support the motion; do not reposition the legs.
- **Release origin:** calculate the preview from the prop's predicted
attachment transform at the release waypoint, not its pre-windup transform.
Recompute the same origin at the release frame before applying the bounded
launch velocity. Keep the action payload as `{ type, target_position }`;
power is derived at runtime, so existing saved queues remain readable.
Acceptance checks for the amendment: launch speed increases monotonically with
cursor distance until the cap; launch bearing follows the cursor vector;
preview points agree with the chosen release pose and solver; the overhand
hand path clears the head and remains within arm reach; release happens during
the forward sweep; the predicted impact marker is distinct from the cursor.
## 1.2 Approved throw landing-target amendment (2026-09-25)
This approved amendment supersedes §1.1's aim-and-power mapping. It keeps the
saved `{ type, target_position }` payload and treats its fixed stage-world point
as the requested landing/aim point.
- **Range cap:** cap the raw cursor point to a 3,600 px radius from the
stickman's root position at preview time and again when the throw action
starts. If it is farther away, project it onto that radius along the
root-to-cursor vector. The root is the intuitive range reference; the actual
ballistic path still starts at the prop's predicted hand-release transform.
A cursor within the existing 4 px no-op threshold of the root skips the
throw and leaves the prop held.
Keep the raw saved world point unchanged, so queued actions remain readable.
When a queued actor has moved by the time the throw starts, recompute only
the effective capped point from its current root position.
- **Target solver:** in unobstructed flight, solve the launch velocity so the
prop reaches the effective target on the descending part of its arc. Continue
using project gravity and the prop's `gravity_scale`. Search flight times
from 0.35 s to the 2.0 s horizon and select the shortest valid time whose
launch speed does not exceed 2,100 px/s. This favors a fast trajectory while
preserving the selected endpoint. If no valid candidate exists, show the
target as unreachable and skip the throw without releasing the prop.
- **Preview and physical collisions:** preview and execution use the same
effective target, release transform, selected flight time, velocity, gravity,
and prop gravity scale. Shape-cast the held prop along the path, excluding
the thrower's collision bodies and the prop itself. A world collision before
the target ends the guide at its first safe contact point. If no collision
occurs before the requested endpoint, the guide reaches that endpoint. If
the point is in open air, the prop continues under normal physics after
passing it; the guide marks the requested point rather than the rest of that
continuing flight. Moving-body impacts remain an estimate.
- **Target markers:** show the raw cursor separately from the effective
capped target. When a collision truncates the guide before that target, show
the collision point separately as well. Thus a cursor at 1,200 px from the
root shows an effective target at 3,600 px, while retaining the cursor marker
at 1,200 px.
- **Prop weight/material:** keep one shared range and speed policy in this
change. Physical mass does not change gravitational acceleration. A future
balance pass may add an explicit gameplay throwability factor derived from
prop weight or material; it must not silently change the physics mass or
existing `.prop` schema.
The previously approved overhand IK path, its reach clamp, forward-sweep
release, follow-through, and release-origin prediction remain in force.
Acceptance checks for this amendment: a target within 3,600 px remains exact;
farther targets clamp to a 3,600 px radius from the current stickman root; the
cursor and effective target markers remain distinct; the solver chooses the
shortest valid descending arc under the speed cap; earlier collisions truncate
the guide; unreachable targets release no prop; and queued throws preserve the
raw fixed world point while recomputing a bound when necessary.
## 1.3 Approved queue-aware throw-planning preview (2026-09-25)
At queue-edit time, a throw may follow a queued pickup that has not executed yet.
The preview must model that planned held-prop state without mutating the live
rig, prop, queue, reservations, or physics. This is a planning estimate; runtime
continues to solve from the actual held prop and release transform.
- **Queue prefix:** for an edited throw, inspect authored actions before its
stable action ID. For a newly appended queue or Direct throw, inspect the
current queue as the prefix. Start from the rig's current held-prop state,
then fold only pending actions in order: a valid `pick_up_prop` selects its
stage prop ID and requested hand; `drop_prop` or `throw_prop` clears the
virtual held prop. Completed actions are already reflected in live state and
are not replayed. A missing or incompatible prop leaves the preview
unavailable.
- **Predicted pickup pose:** use the same grip-selection and pickup stand-off
calculations as runtime. For an out-of-reach pickup, predict the approach
root position from the selected grip and `APPROACH_GAP`; also account for
preceding authored `walk_to` targets. This predicts the ordinary queue case
without starting locomotion or attaching the prop. If an earlier action makes
the future actor or prop transform indeterminate (for example, an active
ragdoll or a prior physical throw), do not present the path as exact; show a
planning/uncertain status and use the best available pose estimate.
- **Pure preview context:** pass the resolved prop, chosen grip/hand, and
predicted root transform as explicit preview inputs to the existing
gravity-aware solver and collision shape cast. Do not temporarily set the
interactor's `_held_prop`, reserve the prop, change the hand target, or move
either scene object to make the preview work.
- **Feedback:** show a plan-preview hint such as “Assumes Step 2 pickup
succeeds.” Keep raw cursor, capped target, and collision markers. Reserve red
for a genuinely unreachable solved target; if no planned or live prop can be
resolved, show a neutral explanation instead of a misleading straight red
trajectory. Preview status must update as the queue prefix or target changes.
- **Scope:** queue edit, queue append, and Direct throw captures have a known
authored prefix and use this model. Rule-authored throws have no fixed action
prefix, so they use the live held-prop state and report preview unavailable
when no prop is currently held.
Acceptance checks for this amendment: at `t = 0`, editing a pending throw after
a pending pickup displays a gravity/collision-aware planning guide; the preview
does not change prop ownership, transforms, reservations, the queue, or live
physics; a preceding drop/throw clears the inferred held prop; the predicted
pickup approach moves the guide origin; and actual execution recomputes the
trajectory from the real release transform. A missing, invalid, or ambiguous
prefix never masquerades as a solver-unreachable red trajectory.
## 2. Existing contracts and scope
- `StickmanRig` is the public runtime facade. It already composes separate
Locomotion, Ragdoll, and ActionRunner components under `RigComponents`.
- `StickmanActionRunner` owns authored queue IDs and emits one start/finish
pair per authored action. Walking is currently a normal queue action.
Pickup’s internal approach must not be inserted into the authored queue:
pickup remains one authored action, with one action ID and one completion.
- `StickmanLocomotion.walk_to()` drives the existing navigation/terrain
behavior, but its final root displacement is horizontal. Prop approach must
choose a grounded stand point and verify hand reach; it cannot assume
arbitrary 2D walking.
- `PropAnchorData` already normalizes grip roles, allowed hands, local
transforms, and attachment-transform math. `PropFile` already serializes
anchors, but `PropLibrary` currently drops them and `StageSpawner` does not
pass them into `PropBlock`. Phase 11 must close that runtime data path.
- `StickmanRig` uses animated IK target markers. A prop hold/throw pose needs a
single explicit owner for the active hand target after animation updates and
before the IK solve. Do not let an AnimationPlayer track and the interaction
code fight over the same target.
- Rule/action templates live in `TriggerRegistry` and `ActionRegistry`.
`SandboxStage` handles rule matching and event dispatch; there is no current
`StageEventRegistry`. Extend these existing paths.
- `PropBlock.begin_play_instance_scale()` moves authored instance scale into
its visuals/collision during PLAY. Grip alignment must use that effective
scale, not assume the RigidBody root retains the EDIT scale.
In scope: one-hand pickup, drop, and targeted throw; automatic approach;
prop/rig state handoff; stage-scene persistence for prop identity and queue
payloads; Direct and queue/rule authoring; and three edge-triggered prop rules.
Out of scope: two-hand or multiple-prop holding; torso/leg bending to improve
pickup reach; prop-to-prop mounting; melee hitboxes; moving-target tracking;
actor-relative throws; a general-purpose event bus rewrite; and document-wide
undo/redo.
## 3. Runtime ownership and action lifecycle
Add a `StickmanPropInteractor` component under each rig’s `RigComponents`.
It owns the selected hand, held prop reference, chosen grip ID, reservation,
saved collision/freeze settings, active interaction phase, and recent held
transform samples. `StickmanRig` exposes a narrow facade to the ActionRunner
and calls the interactor’s ragdoll/reset hooks. The ActionRunner remains the
owner of authored queue progression; it delegates prop work to the interactor
and waits in a prop-interaction phase until that action succeeds, fails, or is
cancelled.
The interaction state is:
| State | Contract |
| --- | --- |
| Idle | No prop is held and no pickup is reserved. |
| Approaching | The pickup action owns one prop reservation and uses locomotion to reach a grounded stand point. It is still one active authored action. |
| Reaching | The chosen arm target blends to the chosen grip; the prop is not yet reported as held. |
| Held | Exactly one prop is aligned to the solved wrist and cannot be claimed by another rig. |
| Throwing | The arm performs windup/follow-through; release is committed once at the release boundary. |
Cancellation before pickup attachment stops the approach/arm blend and releases
the prop reservation. Cancellation of a throw before its release boundary
leaves the prop held; cancellation after release never pulls it back and only
ends the remaining follow-through.
Action payloads:
| Type | Runtime payload | Behavior |
| --- | --- | --- |
| `pick_up_prop` | `{ type, target_prop_id: String, hand: "auto" | "left" | "right" }` | Resolve and reserve that exact prop, approach if needed, then attach one compatible grip. |
| `drop_prop` | `{ type }` | Release the currently held prop. If none is held, complete as a skipped no-op. |
| `throw_prop` | `{ type, target_position: Vector2 }` | Throw the held prop toward the saved stage-world point. If none is held, complete as a skipped no-op. |
Skipped means the queue item reaches its existing completed state with a
no-op outcome; it does not add a new persistent queue status. The existing
`action_finished` signal continues to mean that a queue item ended. The
dedicated `prop_*` triggers below are the success signals for actual prop
state changes.
### 3.1 Pickup selection and auto-approach
At action start, resolve `target_prop_id` to a live `PropBlock` in this stage.
Reject a missing ID, a placeholder, an already-held/reserved prop, a rig
already holding a prop, or a hand/anchor combination with no compatible grip.
These failures finish the queue item as skipped and emit no pickup event.
Use the prop’s normalized authored anchors. For `hand: "auto"`, consider
compatible grip/hand pairs, prefer `primary_grip`, then choose the reachable
pair with the shortest wrist-to-grip distance. An explicit left/right request
must use that hand; do not silently switch hands when its selected grip forbids
it. If no editable grip exists, use a generated one-hand fallback grip at the
center of the prop’s visual bounds. Built-in props use that fallback unless
their template provides an explicit grip.
Measure reach from the selected solved wrist to the transformed grip, not from
the rig root to the prop origin. If the grip is out of reach, compute a grounded
stand point on a reachable side of the prop and ask existing locomotion to walk
there. The stand point must account for current terrain height and the chosen
hand. After arrival, re-resolve the prop, recompute its grip transform, and
confirm actual reach before starting the arm blend. A moving prop may cause at
most one approach replan; if it is still unreachable, release the reservation
and skip the action. No unbounded “chase the prop” loop is allowed.
Reservations are claimed synchronously when pickup begins so two actors cannot
both approach and attach the same prop. Release the reservation on success,
failure, cancellation, prop deletion, or actor deletion. Do not emit a
`walk_to` completion or waypoint-arrival event for the internal approach.
### 3.2 Attachment and hold
Interpolate the selected hand’s IK target to the grip over a short reach blend.
Once the arm is solved, align the prop with
`PropAnchorData.solve_attachment_transform()`, using the solved wrist
transform, the grip’s local transform, effective PLAY scale, and the
left-facing mirror convention. Apply the anchor’s z-index offset relative to
the rig’s draw order.
Keep the `PropBlock` under the stage World. Do not reparent it to the animated
rig or use reparenting as the attachment mechanism. During hold, use
`RigidBody2D.FREEZE_MODE_KINEMATIC`, update the prop transform from the solved
wrist in the interaction update, and temporarily disable its collision layer
and mask so it cannot snag terrain, its owner’s collision proxies, or generate
ordinary contact events while held. Save and restore all modified physics
settings on release. The hold transform must be updated only after the wrist
pose has been solved for that frame.
Sample the held prop transform each physics frame. Those samples provide
release linear/angular velocity for drops and ragdoll disarm; do not use
`_last_grounded_walk_velocity_x` as full actor momentum. It is a grounded,
horizontal locomotion value and does not describe airborne or wrist motion.
### 3.3 Drop and throw
A manual drop detaches at the current prop transform, restores its collision
settings and dynamic state, and applies the most recent finite sampled
attachment velocity. A throw uses the same release handoff, then overrides
linear velocity with the trajectory solver result and adds a small deterministic
spin. Release and its event are committed exactly once.
The approved baseline uses a bounded ballistic solver and no-op guards. The
approved §1.2 amendment changes its target handling as follows:
1. Keep the saved absolute `target_position`. At preview and throw start,
compute the effective point by clamping its radial distance from the current
stickman root to `MAX_THROW_RANGE` (3,600 px). Use the prop's actual predicted
attachment transform at the release waypoint as the ballistic origin.
2. For a candidate flight time `t`, compute the required initial velocity as
`(effective_target - release_position - 0.5 * gravity * t * t) / t`. Search
`[0.35 s, 2.0 s]` and choose the shortest finite candidate that reaches the
effective point on descent and stays at or under `MAX_THROW_SPEED`
(2,100 px/s). The time interval is the preferred tuning seam if playtesting
shows that the default arc needs to be more or less lofted.
3. If no candidate can reach the target within those bounds, mark the preview
invalid and leave the prop held when the action runs. Never substitute an
undershot point while presenting it as the requested landing target.
The preview uses the same effective target, release transform, flight time,
velocity, gravity, and prop gravity scale as execution. Shape-cast the prop
along the path up to the effective target, excluding the thrower's collision
bodies and the held prop. An earlier world collision truncates the guide at
first safe contact; otherwise the guide reaches the effective target. In open
air, the target is an aim point and the physical prop continues until a later
collision. Show the raw cursor, effective capped target, and an earlier
collision point as separate markers.
A queue-authored throw may use the queue-aware planning preview in §1.3. Its
inferred prop, grip/hand, and estimated root pose are immutable solver inputs;
the preview must not simulate pickup by changing live ownership or transforms.
Runtime release always recomputes from actual state.
A cursor within the 4 px zero-distance threshold of the stickman root is a
no-op and leaves the prop held. Godot's positive-Y-down gravity vector must be used consistently. Set
`linear_velocity` from the solved velocity; do not pass a velocity vector to
`apply_central_impulse()`. Never pass a NaN or infinite vector into physics.
Prop mass does not change the solver result; prop-specific throwability remains
future tuning work.
The rig faces toward the saved target before throw windup when the horizontal
offset is outside a small dead zone; it retains that direction afterward.
Use a scripted arm IK pose for windup, release, and follow-through. Do not
directly rotate the torso or reposition the legs in this phase.
## 4. Ragdoll and Sandbox lifecycle
Before `StickmanRig.set_ragdoll(true)` resets locomotion or begins the ragdoll
handoff, ask the interactor to detach any held prop. Restore its collision and
physics settings and apply the latest sampled attachment velocity. This path is
idempotent and works for direct ragdoll, collision-triggered ragdoll, and a
queued ragdoll action.
Ragdoll disarm emits one `prop_dropped` event with reason `ragdoll`; it does
not run the manual drop animation. A manual drop emits the same event with
reason `manual`. A scene reset or leaving PLAY detaches silently, clears
reservations/pose overrides, then lets the existing authored-state restore
return props to their starting transforms. It must not create a gameplay rule
event during cleanup.
Pause/resume preserves a held attachment and its sampled transform state.
Reset/restart restores the unheld authored prop state. Prop interaction actions
require an animated rig; if queued while ragdolled, use the existing recovery
barrier behavior before pickup/throw. A no-held drop/throw after recovery
remains a skipped no-op.
## 5. Events and authoring
Add ActionRegistry entries and labels/icons for `pick_up_prop`, `drop_prop`,
and `throw_prop`. Extend ActionEditor/QueuePanel serialization and the Direct
action popup so actions can be added and edited. Direct popup labels should
read **🫴 Pick Up Prop…**, **⬇️ Drop Prop**, and **🏹 Throw Prop…**. Pick Up
captures a prop; Throw captures a stage-world point; Drop queues immediately.
The When/Then action editor must support those same payloads, including the
second stage capture needed to choose the prop or throw point after the acting
stickman is selected.
Add TriggerRegistry entries for:
| Type | Source | Target | Fire condition |
| --- | --- | --- | --- |
| `prop_picked_up` | Acting stickman | Picked-up prop | After grip alignment and held-state ownership commit. |
| `prop_dropped` | Acting stickman | Released prop | After manual or ragdoll release commits; event payload includes the reason. |
| `prop_thrown` | Acting stickman | Thrown prop | At the exact release boundary, after physics velocity is assigned. |
Prop trigger rules select a stickman source and prop target. Use the existing
ordered, all-matching rule evaluation and normal reactive action queues. Emit
events from the interactor’s successful state transitions, not merely because
an ActionRunner item finished. Do not emit while a prop is being continuously
held, after a failed action, or during scene cleanup. Rule actions can target a
different stickman and can refer to their own prop ID/throw point.
User-authored rules may form a real cycle (for example, pick up → drop → pick
up). Do not synthesize rules or dispatch prop events inline from a failed
attempt. Keep transition events edge-triggered and preserve the existing
stage-run/event-dispatch guards.
## 6. Stage identity, anchors, and saved data
Add a non-empty, opaque `stage_object_id` to every spawned `PropBlock`.
Generate it for each placement/instance; never copy it from the asset or from a
duplicated prop. Keep `spawn_id` exclusively as library/template provenance.
The stage owns resolution and uniqueness checks. Missing custom-prop
placeholders preserve the same ID so replacing a missing asset with its real
asset restores queue/rule references.
Bump StageSceneIO to version 1.1. Keep reading 1.0 scenes; migration assigns
each prop’s current unique `file_id` as its initial `stage_object_id`.
Write `stage_object_id` on each prop entry thereafter. Reject duplicate or
empty IDs in a 1.1 file before applying it, preserving the current scene on
load failure. Existing per-save `file_id` mappings continue to serialize
runtime rule references; do not use Godot instance IDs as saved references.
StageSceneIO queue encode/decode and rule-action conversion must preserve
`target_prop_id` as a stable string and `target_position` as finite vector
data. A queued action whose prop ID no longer resolves stays in the queue and
finishes as skipped when run. A missing-prop placeholder is inert and cannot
be picked up. A rule action with a missing prop target is skipped at execution;
it must not crash or silently retarget another prop.
Carry anchors from `PropLibrary.make_custom_entry()` through `StageSpawner`
into `PropBlock`. Built-in props get a generated centered one-hand grip from
their visual bounds unless their template already defines one. Preserve the
existing .prop anchor schema; do not add runtime-only hand state to .prop
assets.
## 7. Implementation sequence
1. **Identity and data path:** persistent stage IDs, 1.0→1.1 scene migration,
action payload serialization, and custom/built-in grip availability.
2. **Pickup/drop vertical slice:** PropInteractor, one-hand state ownership,
approach/revalidation, hold pose, drop, action cancellation, and Direct /
queue editing.
3. **Throw:** fixed world-target capture, root-relative 3,600 px range clamp,
shortest-valid bounded landing solver, facing/overhand arm arc, release
velocity, collision-aware trajectory preview, and queue-prefix planning
context for pending throw targets.
4. **Lifecycle:** ragdoll disarm, PLAY pause/resume/reset, deletion, and
recovery-barrier integration.
5. **Rules:** three prop triggers, source/target capture, When/Then prop
actions, persistence, and missing-target behavior.
Each step should be headless-verifiable with pure serialization/solver cases
and followed by editor/runtime verification in the Sandbox Stage.
## 8. Acceptance criteria
- A distant pickup approaches using one active queue item, then attaches one
compatible grip to one hand without emitting a synthetic walk event.
- Left/right facing, authored anchor position/rotation, prop scale, and
z-index offset produce stable grip alignment. Built-in props remain pickable.
- Missing, deleted, reserved, incompatible, or unreachable pickup targets fail
safely, release their reservation, and emit no successful prop event.
- Drop restores physics/collision properties and transfers sampled release
motion. Drop and throw with no held prop complete as skipped no-ops.
- A throw queued before the actor moves keeps its saved stage-world point; if
the range cap binds at runtime, the effective point is clamped from the
actor's then-current root. Preview and runtime use the same gravity and
target solver.
- A throw within range reaches its selected point on the descending arc when
unobstructed; targets beyond range show the 3,600 px effective point separately
from the cursor. Earlier collisions stop the guide at the collision point.
- At `t = 0`, a pending throw after a pending pickup has a non-mutating,
queue-aware planning guide; no held prop or invalid prefix shows a neutral
explanation instead of a false red ballistic path.
- Ragdoll releases a held prop before hiding the animated rig, transfers
sampled motion, and emits exactly one ragdoll-reason drop event.
- Pause/resume preserves the hold; PLAY reset/exit releases silently and
restores authored state.
- Prop pickup/drop/throw triggers fire exactly once after their respective
committed transitions and execute matching rules in existing rule order.
- Prop IDs and new queue/rule action payloads survive save/load, missing custom
prop placeholders, and object reordering without binding to the wrong copy.
Final user sign-off on §1.3 was received 2026-09-25. Implementation and editor
verification are complete (2026-09-25).