Implement collision bodies, physics events, and hit reactions for StickmanRig

- Added a new specification for collision bodies, detailing the implementation requirements and corrections needed in the StickmanRig, PropBlock, and SandboxStage scripts.
- Created a comprehensive test suite for collision bodies, including tests for proxy geometry, physics alignment, reaction handling, ghost collision, and stage contact routing.
- Ensured that the collision system correctly handles ragdoll transitions and maintains the integrity of existing event rules.
- Introduced new methods in StickmanRig for managing collision bodies and applying reactions based on impact direction and speed.
- Updated PropBlock to emit signals for collisions and separations, preserving the order of events for stickman and prop interactions.
This commit is contained in:
2026-09-13 10:31:41 -04:00
parent 04e0f1bea2
commit ce6bcecd00
12 changed files with 1698 additions and 58 deletions
+329
View File
@@ -0,0 +1,329 @@
# Phase 4.5a — Per-Part Collision Bodies, Physics-Based Prop Events, and Reaction System
## 1. Overview
This phase gives the ANIMATED stickman a physical presence and makes stickman-prop interaction feel real. Three outcomes:
1. **Props bounce off the animated stickman** — a set of per-part kinematic collision bodies so props physically collide with the figure instead of passing through it.
2. **Collision events fire from real physics contact** — replace the feet-point geometric test with the contact signal from the prop's `RigidBody2D`, so a `collided` trigger fires whenever a prop visibly touches the stickman.
3. **The stickman visibly reacts to hits** — when a prop hits a limb, that limb briefly flinches away from the impact, then springs back to its animation.
The impact direction comes from the **physics contact normal** reported by `body_entered` on the prop side, not from the prop's velocity. This is the physically accurate direction the prop is pushing the stickman.
**Not in scope:** `explode_prop`, `remove_prop`, `spawn_prop`, `prop_impulse`, or any prop-targeted actions. Those belong to a future phase.
---
## 2. Scope
| In Scope | Out of Scope |
| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| 10 `AnimatableBody2D` + `CollisionShape2D` per stickman (one per body part) | Prop-targeted actions |
| Physics-based `collided` events | Ragdoll-prop reaction (ragdoll bodies handle physics, no rule fires during ragdoll) |
| Collision normal as impact direction | Continuous contact debouncing |
| Transient bone reaction (flinch-and-recover) | Precise polygon geometry (capsules and circles only) |
| RECOVERING keeps collision bodies enabled | Limb animation-driven collision shape resizing |
| LEFT mirror tolerance (option A) | Reaction for ragdoll or recovering state |
---
## 3. Changes to `StickmanRig`
### 3.1 Build collision bodies
In `_ready()`, after resolving the `Body/*` part nodes, create one `AnimatableBody2D` + `CollisionShape2D` per visual part, parented to the `Body/*` node. The `Body/*` node is already driven by its `RemoteTransform2D`, so each collision body follows the visual automatically.
```
Body/LeftUpperArm (Node2D, driven by RemoteTransform2D)
├── (visual: Line2D or Polygon2D mounted by the adapter)
└── CollisionBody (AnimatableBody2D, collision_layer = 1, collision_mask = 1)
└── CollisionShape2D (CapsuleShape2D)
```
Name the collision body `"CollisionBody"` for consistency with `test_harness` conventions.
**Metadata:** On creation, call `body.set_meta("stickman_rig", self)` and `body.set_meta("part_name", "<part_key>")` (e.g. `"left_upper_arm"`). The stage uses these to resolve which rig was hit and which part.
Store the 10 collision bodies in a dictionary keyed by part name: `_collision_bodies: Dictionary[String, AnimatableBody2D]`.
### 3.2 Shape sizing
Each collision shape sits in `Body/*` local coordinates, matching the "hanging convention" the adapter uses (joint at local origin, far end along local +Y).
| Part key | Shape | Height | Radius | Local position |
| ----------------------------------- | ---------------- | ------------------ | -------------------------------- | -------------------------------------------------------- |
| `head` | `CircleShape2D` | — | `HEAD_COLLISION_RADIUS := 90.0` | `(0, 0)` — head visual is already centered on its origin |
| `torso` | `CapsuleShape2D` | `torso_length` | `TORSO_COLLISION_RADIUS := 20.0` | `(0, torso_length / 2)` |
| `left_upper_arm`, `right_upper_arm` | `CapsuleShape2D` | `upper_arm_length` | `ARM_COLLISION_RADIUS := 12.0` | `(0, upper_arm_length / 2)` |
| `left_lower_arm`, `right_lower_arm` | `CapsuleShape2D` | `lower_arm_length` | `ARM_COLLISION_RADIUS` | `(0, lower_arm_length / 2)` |
| `left_upper_leg`, `right_upper_leg` | `CapsuleShape2D` | `upper_leg_length` | `LEG_COLLISION_RADIUS := 14.0` | `(0, upper_leg_length / 2)` |
| `left_lower_leg`, `right_lower_leg` | `CapsuleShape2D` | `lower_leg_length` | `LEG_COLLISION_RADIUS` | `(0, lower_leg_length / 2)` |
**Bone lengths** come from the fitted rig (already applied by `StkRigAdapter`). Store them as `_collision_lengths: Dictionary[String, float]` in `_ready()` by reading each `Bone2D`'s `length` property. Constants for radii and the head radius live at the top of the script alongside the existing constants.
**Head sizing note:** the head visual is a full geometry (circle, rectangle, etc.), not a bone. A fixed `CircleShape2D` of radius 90 (matching the pose-guide circle) is a reasonable approximation. Future refinement: read the mounted head's local bbox.
**LEFT mirror (option A):** the parent `Master.scale.x = -1` applies to the collision shapes. Since capsules and circles are symmetric, mirroring has no visual effect. Godot may emit a warning about non-uniform scale on `CollisionShape2D` — this is expected and harmless. Do not attempt to counter-mirror.
### 3.3 State management
| State | Collision bodies |
| ------------ | --------------------------------------------------------------------------------- |
| `ANIMATED` | Enabled (`collision_layer = 1`, `collision_mask = 1`) |
| `RECOVERING` | Enabled — props continue to bounce off the recovering figure |
| `RAGDOLL` | Disabled (`collision_layer = 0`, `collision_mask = 0`) — ragdoll bodies take over |
Implement a helper `_set_collision_bodies_enabled(enabled: bool)` that iterates `_collision_bodies` and toggles both `collision_layer` and `collision_mask`.
Call `_set_collision_bodies_enabled(false)` at the start of `_enter_ragdoll()`.
Call `_set_collision_bodies_enabled(true)` at the end of `_on_stand_up_finished()`.
Do **not** toggle during `RECOVERING` — the bodies stay enabled.
**Contact settling (optional):** if recovering stickmen re-enable bodies that contain resting props and the resulting `body_entered` spam feels wrong, add a `_contact_settling_timer` of 0.2 s during which reaction dispatches are suppressed but physics contacts still resolve. Start without this; add only if it's needed.
### 3.4 Reaction system
The rig maintains a per-joint transient reaction offset applied additively after the IK stack solves each frame.
**State:**
\_impact_reactions: Dictionary = {}
```
key: joint name (e.g. "LeftUpperArm", "LeftLowerArm", "Head", "Torso")
value: { "angle": float, "strength": float, "decay": float }
```
**Public entry point:**
```
func apply_collision_reaction(part_name: String, impact_direction: Vector2, impact_speed: float) -> void
```
The method:
1. Maps the hit part to affected joints (see §6.1).
2. Converts `impact_direction` and `impact_speed` into an angle offset per joint (see §6.2).
3. Merges into `_impact_reactions` (if an entry exists, take the stronger angle and reset strength to 1.0).
**Apply pass:** call `_apply_impact_reactions(delta)` from `_physics_process()` **after** `_pin_mirrored_head_rotation()` (matching the existing pattern that runs after the IK stack). For each entry:
```
joint_bone.rotation += reaction["angle"] * reaction["strength"]
reaction["strength"] -= delta / reaction["decay"]
if reaction["strength"] <= 0.0:
_impact_reactions.erase(joint)
```
**Timing note:** the codebase already relies on `_pin_mirrored_head_rotation()` being called from `_physics_process` _after_ the modification stack solves. If for any reason reaction offsets don't show (i.e. they're being overwritten before render), the fallback is to move both the pin and the reaction pass into a `_process_internal` override on the rig. Do not change the existing pin behavior when adding the reaction pass — match it.
**Interaction with ragdoll:** clear `_impact_reactions` at the start of `_enter_ragdoll()`. Do not apply reactions during `RAGDOLL` or during the `RECOVERING` tween.
---
## 4. Changes to `PropBlock`
### 4.1 Extended `collided` signal
Change the existing signal signature:
signal collided(other: Node, contact_normal: Vector2, contact_position: Vector2)
text
Existing listeners must be updated (the stage is the only one).
### 4.2 Contact normal extraction
In `_on_body_entered(body)`:
1. Query the body state: `var state := PhysicsServer2D.body_get_direct_state(get_rid())`.
2. If the state is valid, iterate `state.get_contact_count()` and find the contact whose collider matches `body`.
3. Read `state.get_contact_local_normal(i)` and `state.get_contact_local_position(i)` — these are the contact normal and position in the prop's local frame.
4. Convert to world space via `global_transform` before emitting.
5. If no matching contact is found (timing edge case), fall back to:
- `contact_normal` = `(body.global_position - global_position).normalized()` (rough approximation)
- `contact_position` = `global_position`
Emit `collided.emit(body, contact_normal, contact_position)`.
**Why not `body_entered` alone:** the `body_entered` signal gives the colliding body but not the contact normal. `PhysicsServer2D.body_get_direct_state()` exposes the contact list at the moment the signal fires, which is when we need it.
---
## 5. Changes to `SandboxStage`
### 5.1 Remove the geometric feet-point test
Delete `_update_stickman_prop_collision()` and its call from `_physics_process()`. The physics-based path fully replaces it.
### 5.2 Event routing
When a stickman is placed (`_place_at()` → after spawning), connect its rig's `PropBlock`-related contacts. Since props are spawned independently and can collide with any stickman, the cleaner approach is:
- On `object_placed(node)`, if `node is StickmanRig`, iterate all existing `PropBlock` children of `World` and connect their `collided` signal to `_on_prop_collided(stickman_rig, prop, other, normal, position)` (via `.bind()`).
- When a `PropBlock` is placed, iterate all existing `StickmanRig` children of `World` and connect the prop's `collided` to the same handler for each rig.
Alternatively, use a global registry: `PropBlock.collided` is a signal on the prop, and the stage listens to _all_ props. But the prop doesn't know about stickmen — the stage needs to dispatch based on `other`.
Simplest implementation:
- The stage maintains `_prop_blocks: Array[PropBlock]` (populated on place, cleaned on delete).
- Every `PropBlock.collided(other, normal, pos)` fires → stage handler `_on_prop_collided(prop, other, normal, pos)`.
- The handler resolves `other`:
- If `other.has_meta("stickman_rig")` → `var rig = other.get_meta("stickman_rig")`, `var part = other.get_meta("part_name")`.
- Otherwise → the collision is prop-vs-prop; **keep the existing prop-vs-prop behavior** (whatever the current system does with `collided` — probably a rule trigger with `collided` trigger type where the source and target are both props). Do not regress this path.
### 5.3 Stickman-prop event dispatch
When the handler resolves a stickman:
1. Compute a stable pair key: `"%d_%d" % [rig.get_instance_id(), prop.get_instance_id()]`.
2. Check `_stickman_prop_collisions: Dictionary` (edge-trigger state). If the key is already present, do nothing (the pair is already "touching").
3. Otherwise, mark the key, then fire the `collided` trigger for any rule whose `trigger.type == "collided"` and whose `trigger.source`/`trigger.target` match `(rig, prop)` or `(prop, rig)` (either order).
4. Clear the key when the prop separates. Use `PropBlock.body_exited` if the prop has it — add a `body_exited` signal mirroring `body_entered` — OR poll the contact count each frame. Cleaner: extend `PropBlock` with `signal separated(other: Node)` fired from `body_exited`.
5. **Also** dispatch the reaction: `rig.apply_collision_reaction(part, normal, prop.linear_velocity.length())`.
### 5.4 Reaction dispatch
The `apply_collision_reaction` call is on the stickman, not the prop. Pass:
- `part_name` (from metadata on the collision body)
- `impact_direction` = `contact_normal` (world-space, from the prop's body state)
- `impact_speed` = `prop.linear_velocity.length()` (used as a magnitude scale)
The rig maps these to bone offsets (see §6).
---
## 6. Reaction Details
### 6.1 Part → affected joints
| Hit part | Primary joint | Secondary joint |
| ----------------- | -------------------------- | ------------------------------- |
| `head` | `Head` (bone) | — |
| `torso` | `Torso` (bone, small tilt) | — |
| `left_upper_arm` | `LeftUpperArm` | — |
| `left_lower_arm` | `LeftLowerArm` | `LeftUpperArm` (half strength) |
| `right_upper_arm` | `RightUpperArm` | — |
| `right_lower_arm` | `RightLowerArm` | `RightUpperArm` (half strength) |
| `left_upper_leg` | `LeftUpperLeg` | — |
| `left_lower_leg` | `LeftLowerLeg` | `LeftUpperLeg` (half strength) |
| `right_upper_leg` | `RightUpperLeg` | — |
| `right_lower_leg` | `RightLowerLeg` | `RightUpperLeg` (half strength) |
The secondary joint gets the same angle offset but a **weaker magnitude** (e.g. 0.4×) and a **shorter decay** (e.g. 0.25 s vs 0.4 s).
### 6.2 Impact direction → angle offset
The contact normal points from the prop toward the stickman's surface (or vice versa — sign depends on Godot's convention, verify during implementation).
Compute the angle offset per joint as a function of the impact normal in the joint's local frame:
1. Convert the world-space impact direction into the joint bone's local frame: `local_dir = bone.global_transform.basis_xform_inv(normal)`.
2. The rotation direction is determined by `sign(local_dir.x)` — the sign of the direction the prop is pushing in the limb's local X.
3. `angle_offset = REACTION_BASE_ANGLE * sign(local_dir.x) * (impact_speed / REACTION_REFERENCE_SPEED)` clamped to a max.
Constants:
- `REACTION_BASE_ANGLE := 0.35` (radians, ~20°) — the angle offset for a reference-speed impact.
- `REACTION_REFERENCE_SPEED := 400.0` (px/s) — the impact speed that produces the base angle.
- `REACTION_MAX_ANGLE := 0.6` (radians, ~34°) — cap.
If `local_dir.x` is near zero (a head-on impact along the bone axis), pick a default direction based on the joint side (left limbs push left, right limbs push right).
### 6.3 Decay tuning
| Joint | Decay (s) |
| -------------------- | --------- |
| Head | 0.5 |
| Torso | 0.4 |
| Upper limbs | 0.4 |
| Lower limbs | 0.35 |
| Secondary (adjacent) | 0.25 |
Decay is the time for `strength` to fall from 1.0 to 0.0. Shorter = snappier recovery.
### 6.4 What the reaction _is not_
- Not a physics force — it doesn't move the rig root, doesn't push the prop.
- Not persistent — it fully decays.
- Not additive over multiple impacts beyond `max(angle)` — a new impact replaces the existing angle if stronger, else takes the max and resets `strength = 1.0`.
---
## 7. Edge Cases
| Case | Behavior |
| --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Prop rests on stickman's head | `body_entered` fires once; head recoils briefly; prop rolls off |
| Prop hits two stickman parts in the same frame | Each contact fires separately; the edge-trigger dict dedupes to one `collided` rule fire per `(stickman, prop)` pair; reaction applies to both hit parts |
| Walking stickman shoves a resting prop | `AnimatableBody2D` pushes the prop; contact fires once; both react |
| Prop hits stickman during RECOVERING | Physics still bounces (bodies enabled); reaction is skipped (state is not `ANIMATED`) |
| Prop is deleted | Existing cleanup runs; the edge-trigger entry is orphaned but harmless (key includes the prop's instance ID, which won't recur) |
| Stickman is deleted | All 10 collision bodies freed with the rig; no orphaned listeners |
| Recovering stickman re-enables bodies over a resting prop | `body_entered` fires — edge-trigger dedupes; optional settling timer if this feels wrong |
| Left-facing stickman (`Master.scale.x = -1`) | Capsules and circles are symmetric; mirroring is a no-op visually; Godot may warn about non-uniform scale — tolerated |
| Ragdoll | All 10 bodies disabled; ragdoll bodies handle physics; no reaction (reactions are an ANIMATED-only feature) |
---
## 8. Acceptance Criteria
### 8.1 Physical bounce
- [ ] A prop thrown at an ANIMATED stickman **bounces off** and lands on the ground.
- [ ] A prop thrown at the stickman's **arm, leg, torso, and head** individually bounces off each part.
- [ ] A prop resting on the ground next to an ANIMATED stickman stays put.
- [ ] A walking stickman **shoves** a resting prop out of the way.
- [ ] In RAGDOLL mode, props no longer collide with the collision bodies; ragdoll bodies handle physics.
- [ ] In RECOVERING mode, props still bounce off the recovering figure.
### 8.2 Collision event
- [ ] A prop visibly touching any part of the stickman fires the `collided` trigger.
- [ ] The rule `When [Stickman A] collides with [Prop X] → [Action]` fires once per contact.
- [ ] A prop bouncing off and re-colliding fires again.
- [ ] Prop-vs-prop collisions still fire the `collided` trigger through the existing path.
- [ ] No console errors on any collision.
### 8.3 Reaction
- [ ] A prop hitting the stickman's **lower arm** makes that arm flinch away from the impact and recover within ~0.4 s.
- [ ] A prop hitting the **head** makes the head recoil and recover.
- [ ] The flinch direction is **away from the impact** (matches the contact normal).
- [ ] A gentle prop (slow speed) produces a **small** flinch; a fast prop produces a **large** flinch (capped at `REACTION_MAX_ANGLE`).
- [ ] The walk animation continues uninterrupted — the limb is momentarily offset, then catches up.
- [ ] Multiple hits in quick succession don't accumulate beyond the cap.
- [ ] Reactions do **not** fire during `RAGDOLL` or `RECOVERING`.
### 8.4 Regression
- [ ] Existing rules (`arrived_at_waypoint`, `entered_area`, `action_finished`, `speech_finished`) still work.
- [ ] Sequential action queues still run.
- [ ] Ragdoll entry/exit and stand-up recovery still work.
- [ ] No `.stk` format change.
- [ ] No new warnings during normal play.
- [ ] Performance remains acceptable with 5 stickmen and 10 props.
---
## 9. Files Changed
| File | Change |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `scripts/stickman_rig.gd` | Add 10 collision bodies + shapes; add `_set_collision_bodies_enabled()`; add `apply_collision_reaction()`; add `_apply_impact_reactions()`; hook the state toggles in `_enter_ragdoll()` / `_on_stand_up_finished()`. |
| `scripts/prop_block.gd` | Extend `collided(other)` → `collided(other, normal, position)`; add `separated(other)` signal (from `body_exited`); extract contact normal via `PhysicsServer2D.body_get_direct_state()`. |
| `scripts/sandbox_stage.gd` | Remove `_update_stickman_prop_collision()`; add `_on_prop_collided()` and `_on_prop_separated()` handlers; add `_stickman_prop_collisions` edge dict; dispatch rule trigger + `apply_collision_reaction()`. |
No new scenes. No `.tscn` changes. No `.stk` format change.
---
## 10. Notes for the Developer
- **Timing for reactions:** match the existing `_pin_mirrored_head_rotation()` pattern (called from `_physics_process`). If the offsets don't visibly appear, the fallback is `_process_internal` — but try the existing slot first; do not change the existing head-pin behavior.
- **Contact normal sign:** Godot's `get_contact_local_normal(i)` returns the normal in the prop's local frame, pointing toward the collision surface. Verify the direction empirically — a push test with a resting prop should have the stickman limb flinch _away_ from the prop. Invert the sign if it goes the wrong way.
- **`AnimatableBody2D` vs `StaticBody2D`:** `AnimatableBody2D` correctly transfers the kinematic body's velocity to RigidBody2D props. `StaticBody2D` does not — a walking stickman would let props clip through. Do not substitute.
- **Left-mirror warning:** Godot 4 may emit "Non-uniform scale is not supported on CollisionShape2D" when `Master.scale.x = -1`. This is expected and does not affect correctness. Do not silence or work around it.
- **Reaction scope:** reactions are a visual garnish, not a physics force. Keep them additive and decaying. Do not feed them back into the IK solver or the ragdoll.
- **Reaction key:** use the rig's bone names (`"LeftUpperArm"`, `"Head"`, etc. — the ones in `BEND_JOINT_BONE_PATHS` and `Z_ORDER_BY_PROFILE`) for consistency.
+197
View File
@@ -0,0 +1,197 @@
# Phase 4 Bug Fixes — High-Level Plan
## 1. Overview
This plan addresses eight defects discovered after Phase 4 shipped. Bug 1 requires a design decision (reactive-action interrupt policy); Bugs 2–8 are diagnosed defects with clear fix directions.
The plan is a design brief for the architect. Implementation details belong in the resulting spec, not here.
---
## 2. Bug 1 — Reactive Event Interrupt Policy
### 2.1. Problem Statement
A walk is in progress; a rule fires (`When [Stickman] collides with [Prop] → Ragdoll`). The reactive action is appended to the queue tail and only executes after the walk finishes. The user expects immediate response.
### 2.2. Design Decision — Three Interruption Roles
Every action — whether in the sequential queue or arriving reactively — fits one of three **roles**:
| Role | Behavior when triggered reactively |
| -------------- | ------------------------------------------------------------------ |
| **Sequential** | Cancel the current action; run this action; resume the queue after |
| **Overlay** | Run alongside the current action; the queue is not touched |
| **Takeover** | Cancel everything (including overlays); take over the body state |
### 2.3. Role Assignment Per Action Type
| Action | Role |
| --------- | --------------------------------------------------------------------------------- |
| `walk_to` | Sequential |
| `wait` | Sequential |
| `ragdoll` | Takeover |
| `recover` | Takeover |
| `speak` | **Overlay by default** — director can promote to Sequential via a per-rule toggle |
### 2.4. Director-Facing Behavior
The rule builder shows a single checkbox on **speak** actions only:
> **"Stop what he's doing?"** (default OFF)
- **Unchecked (default)** → speak is an Overlay. The stickman keeps walking / waiting / whatever he was doing. The speech plays alongside.
- **Checked** → speak becomes Sequential. The stickman stops what he's doing, says his line, then resumes the queue.
No checkbox on `walk_to`, `wait`, `ragdoll`, or `recover` — their roles are fixed and unambiguous.
### 2.5. Why This Model
- **Covers every combination** — walk + speak, speak + walk, walk + ragdoll, speak + speak, wait + walk, etc. Each reduces to a role pairing.
- **Kid-friendly** — one checkbox with plain-language wording on the only action where both behaviors are meaningful.
- **Chain reactions handle "both at once"** — a director who wants "say 'Ouch!' AND walk away" adds two actions to the rule: overlay speak + sequential walk.
- **Forward compatible** — the registry gains an `overlay_eligible` flag on action templates. Today only `speak` is marked overlay-eligible. Future actions like `gesture`, `emote`, or `aim` can be added later without changing the architecture.
### 2.6. Overlay Conflict Rule
When two overlay speaks arrive in the same window (e.g., two props hit at once), the new speech **replaces** the old — no queue pileup, one bubble at a time.
### 2.7. Takeover Cleanup
`ragdoll` and `recover` (as Takeover actions) explicitly cancel any active overlay speech on entry. The bubble hides and the queue pauses until the takeover state resolves.
### 2.8. Acceptance Criteria (Bug 1)
- [ ] `Walk → collides → Ragdoll` — ragdoll fires **on contact**, walk is cancelled
- [ ] `Walk → collides → Speak "Ouch!"` (checkbox OFF) — walk continues, speech plays
- [ ] `Walk → collides → Speak "Ouch!"` (checkbox ON) — walk stops, speech plays, then queue resumes
- [ ] `Speak → collides → Walk` — speak cancels, walk starts
- [ ] `Walk → collides → [Speak (overlay), Walk]` — speech and walk fire together
- [ ] `Walk → collides → Ragdoll` while a speak overlay is active — speech cancels, ragdoll fires
- [ ] Sequential queue resumes after every interrupt
- [ ] `clear_reactive_actions()` still strips reactive actions on Edit-mode return
---
## 3. Bug 2 — Default Pose Changes on Placement
**Problem:** The stickman's default pose (both arms up) changes to arms-down after placement.
**Suspected area:** Placement path differences in IK target state, modification stack enabled timing, or animation player auto-start behavior between the reference rig and the spawned rig.
**Fix direction:** Ensure a newly placed stickman's pose matches the reference/default pose exactly at spawn time. Diagnose the delta between the reference rig and a spawned rig on the same frame.
---
## 4. Bug 3 — Animation Continues After Scene Stop
**Problem:** When the user switches from PLAY to DIRECT/EDIT mid-flinch, the head visibly tweens back to its resting position instead of snapping.
**Suspected area:** Reaction-decay tweens are not being cancelled on mode exit.
**Fix direction:** All in-flight pose tweens (reaction decay, stand-up, etc.) must be cancelled and the affected bones set directly to their captured base rotations on mode exit. No animation should play during state restoration.
---
## 5. Bug 4 — Props Cannot Be Moved or Rotated
**Problem:** A placed prop can be selected (rotate gizmo appears), but neither drag nor rotate works.
**Suspected area:** The edit-mode freeze state (`FREEZE_MODE_KINEMATIC`) may be blocking transform writes, or the drag/rotate input path is not routing to props, or another system is resetting the transform after the drag.
**Fix direction:** Audit the prop drag/rotate input path end-to-end. Verify the frozen state permits `global_position`/`global_rotation` writes for edit-time manipulation, and that nothing re-sets the transform after commit.
---
## 6. Bug 5 — Ghost Pose ≠ Placed Pose
**Problem:** The placement ghost shows the stickman with bent legs. The placed stickman has straight legs.
**Suspected area:** Shared root cause with Bug 2 — the ghost is a static render (modification stack disabled), while the placed rig is live and the IK solver drives a different pose.
**Fix direction:** The placed stickman's pose must match the ghost's pose at spawn. This is likely resolved by fixing Bug 2.
---
## 7. Bug 6 — Speech Bubble Mirrors with LEFT Profile
**Problem:** When facing LEFT (`Master.scale.x = -1`), the speech bubble and its text render mirrored — backwards text.
**Suspected area:** The speech bubble is a child of the rig root; the whole-rig X-mirror propagates into its `_draw()`.
**Fix direction:** Isolate the speech bubble from the rig's mirror. Either move it out of the mirrored subtree (world-space child of the stage, following the head bone) or apply a counter-mirror inside its draw. UI must not inherit anatomy mirroring.
---
## 8. Bug 7 — Snap Uses Object Size Instead of Grid
**Problem:** With snap enabled, dragging objects (steps, etc.) snaps at intervals equal to the object's own width/height, not the grid.
**Suspected area:** The drag-snap function is quantizing to the dragged object's AABB dimensions rather than the stage grid — likely residual block-unit paint stride logic applied incorrectly to the general drag path.
**Fix direction:** Object drags must snap to the stage grid (`_grid_size`) only. Block-unit stride is exclusive to terrain drag-painting; it must not leak into the general drag path.
---
## 9. Bug 8 — Single Terrain Placement Uses Block-Unit Snapping
**Problem:** Placing a single terrain block (one click) snaps to the block's own width/height intervals even when Snap is OFF.
**Suspected area:** Same as Bug 7 — the single-placement path reuses the block-unit paint stride instead of the grid or no-snap.
**Fix direction:** Single terrain placement must follow the standard object snapping rules:
- Snap ON → snap to grid
- Snap OFF → no snapping; place at cursor exactly
Block-unit stride is for drag-painting only.
---
## 10. Bug Grouping
| Group | Bugs | Theme |
| ------------------------ | ---- | --------------------------------------------- |
| **A — Reactive model** | 1 | Design decision + runner/action role refactor |
| **B — Pose consistency** | 2, 5 | Placement pose must match default/ghost |
| **C — Animation stop** | 3 | Snap pose on mode exit, no tween |
| **D — Prop interaction** | 4 | Restore drag/rotate in Edit |
| **E — Mirror isolation** | 6 | UI must not inherit rig mirror |
| **F — Snapping source** | 7, 8 | Grid vs. block-unit stride confusion |
---
## 11. Suggested Implementation Order
| Order | Group | Rationale |
| ----- | -------------- | ---------------------------------------------------------------------------------------------------------------- |
| 1 | **B (#2, #5)** | Likely one root cause; visible, high-impact on first-use impression |
| 2 | **E (#6)** | Localized, low-risk, easy to verify |
| 3 | **F (#7, #8)** | Likely one root cause; snapping is a daily-use frustration |
| 4 | **D (#4)** | May need investigation; blocks prop interaction |
| 5 | **C (#3)** | Interacts with reaction system; do after A's mechanism is understood |
| 6 | **A (#1)** | Requires the reactive-role refactor; touches the runner, the registry, the rule builder, and the reaction system |
Bug 1 is last because it is the largest and touches the most systems. It also benefits from the clean foundation the other fixes restore.
---
## 12. Open Questions for the Architect
1. **Bug 1 — Overlay speak mechanics:** Should the overlay speak still respect the `_speech_active` state, or should the state be generalized to a list of active overlays? (Recommendation: single active overlay; new replaces old.)
2. **Bug 1 — Chain reactions with mixed roles:** When a rule contains `[overlay speak, sequential walk]`, the overlay speak should fire first and the sequential walk should interrupt it. Confirm the ordering.
3. **Bug 2/5 — Ghost vs. placed rig:** Should the ghost be a live rig (always matches placed) or a static preview (placed rig is initialized to match)? Live ghost is more accurate but heavier.
4. **Bug 4 — Prop freeze:** Confirm whether the freeze state is the actual blocker, or whether the input path is broken independently.
---
## 13. Acceptance Summary
- Every bug has a clear repro, a fix direction, and verifiable acceptance criteria.
- Bug 1's design is locked in: three roles (Sequential, Overlay, Takeover), one director-facing checkbox on speak, forward-compatible registry flag.
- Bugs 2–8 are diagnosed defects with localized fixes.
- Suggested order minimizes risk: local fixes first, cross-cutting refactor last.
---
**End of Phase 4 Bug Fixes — High-Level Plan**