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:
@@ -1,5 +1,6 @@
|
||||
# Godot 4+ specific ignores
|
||||
.godot/
|
||||
.images/
|
||||
/android/
|
||||
addons/
|
||||
*.tmp
|
||||
|
||||
@@ -372,6 +372,19 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
`state == RigState.RAGDOLL` (so `RECOVERING` reads as "Stickman"). Runtime diagnosis is
|
||||
gated behind `const DEBUG_RECOVERY := false` (off by default; `_recovery_dbg()` traces the
|
||||
stack + bone-following state — the temporary per-step debug prints have been stripped).
|
||||
- **Phase 4.5 collision bodies and reactions:** while ANIMATED, `_build_collision_bodies()` creates
|
||||
ten metadata-tagged `AnimatableBody2D` proxies (head, torso, and eight limbs), stored in
|
||||
`_collision_bodies`. Proxies are top-level relative to their `Body/*` visuals,
|
||||
`sync_to_physics = false`, layer/mask 1, and `_sync_collision_bodies()` copies each visual's
|
||||
global transform every physics frame so RemoteTransform2D/IK poses and whole-rig LEFT
|
||||
mirroring cannot leave stale shapes. Torso/limb capsules use fitted lengths in the hanging
|
||||
frame; the head circle is centered from mounted geometry and expands beyond its 90 px minimum
|
||||
when needed. `set_collision_bodies_enabled()` disables both layer and mask for ghosts/RAGDOLL
|
||||
and restores them for RECOVERING/ANIMATED. `apply_collision_reaction()` uses contact position
|
||||
plus local force quadrant to choose mirrored side/upward/downward flinch direction, applies a
|
||||
capped `REACTION_MAX_ANGLE` offset from a captured base rotation, and decays without cumulative
|
||||
full rotations. PLAY→DIRECT restoration also resets ANIMATED rigs' authored transform, facing,
|
||||
IK markers, and torso rotation. Null-guards + `push_warning` prefixed `StickmanRig:` throughout.
|
||||
- **Phase 3a director functionality:** adds navigation/walking, speech, an action queue, and a
|
||||
queue-runner state machine. Enums `RunnerState { IDLE, EXECUTING }` and `ActionPhase { NONE,
|
||||
WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }`. Constants `FOOT_OFFSET := (0, -385)`
|
||||
@@ -591,11 +604,12 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
None: mass 1.0, friction 0.5, bounce 0.05) and recolor fill/outline for non-`NONE`. Unified
|
||||
live-update setters push geometry/color changes to all children (null-guarded for `@tool`
|
||||
editor safety); `_apply_shape()` enables exactly one collision node.
|
||||
- **Phase 4 collision signal:** new `signal collided(other: Node)`; `_ready()` sets
|
||||
`contact_monitor = true`, `max_contacts_reported = 8`, and connects the guarded
|
||||
`body_entered` signal → `_on_body_entered` → `collided.emit(body)`, so **prop-vs-prop**
|
||||
collisions are reported by the physics engine (the stage's geometric feet-point test covers
|
||||
stickman-vs-prop separately).
|
||||
- **Phase 4 / Phase 4.5 collision signals:** `collided(other, contact_normal, contact_position)`
|
||||
and `separated(other)`; `_ready()` sets `contact_monitor = true`,
|
||||
`max_contacts_reported = 32`, and shape-cast CCD. The guarded `body_entered` path reads a
|
||||
matching direct-state contact row as world-space normal/position, with a center-direction
|
||||
fallback when timing leaves no row. The physics signal covers prop-vs-prop and Phase 4.5
|
||||
stickman-prop contacts; the old geometric feet-point stickman test is removed.
|
||||
- `scripts/trigger_area.gd` — `class_name TriggerArea`, `extends Node2D`; a **placeable sensor**
|
||||
(Phase 4, **not used by the editor**). `@export size: Vector2` (default 96×96), `get_area_rect()
|
||||
-> Rect2` (centered local-space rectangle), and `_draw()` (translucent green fill +
|
||||
@@ -714,10 +728,10 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
Array[Dictionary]` of `{id, trigger, actions}` rules (trigger types: `arrived_at_waypoint`,
|
||||
`action_finished`, `speech_finished`, `entered_area`, `collided`; action types reuse the Phase
|
||||
3a set: `walk_to`/`speak`/`wait`/`ragdoll`/`recover`). Rules persist across Play/Edit mode
|
||||
toggles but are **not** saved to disk (Phase 5). A geometric **event engine** runs in PLAY
|
||||
toggles but are **not** saved to disk (Phase 5). A geometric **area-entry engine** runs in PLAY
|
||||
(`_update_area_entry` tests each movable's visible world AABB against the current transformed
|
||||
`TriggerArea` world AABB; `_update_stickman_prop_collision` continues to test a stickman's feet
|
||||
point against prop AABBs and vice-versa),
|
||||
`TriggerArea` world AABB); Phase 4.5 routes stickman-prop `collided` events from `PropBlock`
|
||||
physics contacts instead of polling stickman feet points,
|
||||
with **edge-triggered** dicts (already-fired events) reset on mode entry. A **rule-builder UI
|
||||
state machine** (`RuleStep` enum: `IDLE`, `SELECT_TRIGGER`, `TRIGGER_TARGET`, `SELECT_ACTION`,
|
||||
`ACTION_TARGET`, `ACTION_POSITION`, `PARAMS`) is driven from a **"⚡ When..."** item in the Direct
|
||||
|
||||
@@ -345,7 +345,7 @@ A **unified live-update setter** drives geometry to all children live — a chan
|
||||
| **2** | Bouncy Ball | `create_ball()` | `RUBBER` | `(-80, 0)` |
|
||||
| **3** | Heavy Plank | `create_plank()` | `METAL` | `(30, -40)` |
|
||||
|
||||
**Rig collision proxy caveat:** the standing `master_rig.tscn` figure has **no physics bodies of its own**, so a best-effort code-only `StaticBody2D` proxy (`RigCollisionProxy`) provides a static collision surface matching the figure's world bounds — a 240×1000 px `RectangleShape2D` box centered at `(0, -500)`. Props bounce/rest against it. The proxy is a stand-in for the rig's eventual physics bodies and is not part of the rig itself. In ragdoll mode (see §17) the proxy is removed on entry so the physical ragdoll collides directly with the terrain, and re-added on exit.
|
||||
**Rig collision proxy caveat:** the standalone physics harness still uses a best-effort code-only `StaticBody2D` proxy (`RigCollisionProxy`) for its simple standing figure. The Sandbox Stage uses the Phase 4.5 per-part collision bodies documented in §20.2 instead. In ragdoll mode (see §17) the harness proxy is removed on entry so the physical ragdoll collides directly with the terrain, and re-added on exit.
|
||||
|
||||
### 17. Kinematic-to-Ragdoll State System
|
||||
|
||||
@@ -553,9 +553,9 @@ The rule concept is **When → Then**: a *trigger* (an event on some object) fir
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `res://scripts/trigger_area.gd` | NEW `class_name TriggerArea`, `extends Node2D` — a placeable sensor (`@export size: Vector2`, default 96×96) with a centered **local-space** `get_area_rect() -> Rect2` and a translucent green `_draw()` fill + dashed border. **No physics, no signals** — it is a pure geometric region evaluated by the stage. |
|
||||
| `res://scripts/sandbox_stage.gd` | Extended with the rule system: `_event_rules`, the geometric event engine (`_update_area_entry`, `_update_stickman_prop_collision`), the rule-builder UI state machine, and rule auto-cleanup. |
|
||||
| `res://scripts/sandbox_stage.gd` | Extended with the rule system: `_event_rules`, the geometric area-entry engine, physics contact routing for stickman/prop collisions (Phase 4.5), the rule-builder UI state machine, and rule auto-cleanup. |
|
||||
| `res://scripts/stickman_rig.gd` | `arrived` gained a `target: Vector2` payload; new `enqueue_reactive(actions)` appends reactive actions without replaying the queue. |
|
||||
| `res://scripts/prop_block.gd` | NEW `signal collided(other: Node)` (physics-based, prop-vs-prop). |
|
||||
| `res://scripts/prop_block.gd` | NEW physics contact signals `collided(other, contact_normal, contact_position)` and `separated(other)`; prop-vs-prop and Phase 4.5 stickman contacts share the contact-monitoring path. |
|
||||
| `res://scripts/stage_director_visuals.gd` | Extended with rule visualization + `set_rules()` / `hit_test_rule()` / `hit_test_waypoint()`. |
|
||||
| `res://scripts/stage_spawner.gd` | New `"area"` palette registry entry (label **"Area"**); duck-typed `get_area_rect` AABB branch in `get_world_aabb`. |
|
||||
| `res://scripts/stage_selection.gd` | Same duck-typed `get_area_rect` AABB branch for selecting/clicking trigger areas. |
|
||||
@@ -568,7 +568,7 @@ The rule concept is **When → Then**: a *trigger* (an event on some object) fir
|
||||
| `action_finished` | Stickman | A queue action completes (the rig's `action_finished` signal). |
|
||||
| `speech_finished` | Stickman | A `speak` bubble auto-hides (the rig's `speech_finished` signal). |
|
||||
| `entered_area` | Trigger area | A movable (stickman/prop) visibly overlaps the area's transformed world AABB (geometric, `_update_area_entry`). |
|
||||
| `collided` | Stickman/Prop | A stickman's feet point enters another object's AABB (geometric, `_update_stickman_prop_collision`) **or** a prop physically collides with another prop (`PropBlock.collided` physics signal). |
|
||||
| `collided` | Stickman/Prop | Phase 4.5: a `PropBlock` physically contacts a stickman's per-part collision proxy; prop-prop contacts continue through `PropBlock.collided`. |
|
||||
|
||||
**Action types** are the Phase 3a set, reused verbatim: `walk_to`, `speak`, `wait`, `ragdoll`, `recover`. Actions always target a **stickman**.
|
||||
|
||||
@@ -599,10 +599,7 @@ The event remains edge-triggered per `(area instance ID, movable instance ID)` p
|
||||
|
||||
Verified coverage in `tests/test_phase4c_area.gd`: **19 assertions passed**, including source-specific and wildcard matching, one-shot/re-entry edges, independent area/movable pairs, transformed areas, visible leg-level overlap with feet outside, speak queue normalization and visible speech execution, no-rule no-op behavior, stale action targets, and deletion cleanup. The editor verification also confirmed that `When Stickman A enters Area → Stickman B speaks` fires when A walks through the area.
|
||||
|
||||
**Prop collision detection (two mechanisms):**
|
||||
|
||||
- **Geometric (stage-driven):** `_update_stickman_prop_collision` tests whether a stickman's **feet point** lies inside another object's world AABB (and vice-versa), edge-triggered so `collided` fires once per entry.
|
||||
- **Physics (self-driven):** `PropBlock` enables `contact_monitor = true`, `max_contacts_reported = 8`, and connects the guarded `body_entered` signal → `_on_body_entered` → emits `collided(other)`, so **prop-vs-prop** collisions are detected by the physics engine rather than geometry.
|
||||
**Prop collision detection:** Phase 4.5 replaces the old stickman feet-point test with physics contacts between `PropBlock` and the rig's per-part collision bodies. `PropBlock` still reports prop-vs-prop contacts through its physics signal; see §20.2 for routing, debouncing, and reaction details.
|
||||
|
||||
Edge-triggered dictionaries (which events already fired) are reset on Play/Edit mode entry so a fresh simulation run re-arms every rule.
|
||||
|
||||
@@ -610,6 +607,19 @@ Edge-triggered dictionaries (which events already fired) are reset on Play/Edit
|
||||
|
||||
**Deferred (Phase 5 and beyond):** `explode_prop` / `spawn_prop` action types, rule **conditions** and AND/OR combinators, rule **variables**, and **disk save** of rules are explicitly out of scope for Phase 4.
|
||||
|
||||
### 20.2 Phase 4.5 — Per-Part Collision Bodies and Hit Reactions
|
||||
|
||||
Phase 4.5 gives an ANIMATED `StickmanRig` a physical collision surface in the Sandbox Stage. It is runtime-only and does not change `master_rig.tscn`, the `.stk` format, or the editor layout.
|
||||
|
||||
- `StickmanRig` builds ten metadata-tagged `CollisionBody` `AnimatableBody2D` proxies (head, torso, and eight limbs). They are top-level relative to their `Body/*` visual containers, use layer/mask `1`, set `sync_to_physics = false`, and copy each rendered visual's global transform in `_sync_collision_bodies()` every physics frame. This explicit synchronization keeps proxies aligned with `RemoteTransform2D`-driven geometry during poses and WALK-left mirroring.
|
||||
- Limb and torso proxies use fitted `CapsuleShape2D`s in the adapter's hanging frame. The head uses a `CircleShape2D` centered from the mounted head geometry's local bounds, with a minimum radius of `90 px` and growth when the mounted geometry is larger. These are intentionally approximate collision shapes, not per-shape polygons.
|
||||
- `PropBlock` enables contact monitoring and shape-cast CCD (`continuous_cd = CCD_MODE_CAST_SHAPE`). Its `collided` signal includes the normalized world-space contact normal and world-space contact position; a guarded direct-state lookup has a center-direction fallback when the contact row is unavailable. `separated` mirrors body exit.
|
||||
- `SandboxStage` routes a proxy's `stickman_rig` / `part_name` metadata to `StickmanRig.apply_collision_reaction()`. A `(rig, prop)` pair tracks active proxy body instance IDs: each newly entered part may react, while the authored rig→prop `collided` rule fires only on the empty-to-nonempty edge. Ragdoll bodies are ignored for animated reactions and rule dispatch; prop-prop ordering remains prop→other.
|
||||
- Reactions are bounded offsets from the bone rotation captured at impact, capped at `0.6 rad`, then decayed back to that base rotation. Contact position and force direction in the proxy's local frame select the correct side/quadrant, including mirrored left-facing and upward/downward impacts; the old cumulative full-rotation behavior is not used.
|
||||
- Switching PLAY back to DIRECT restores each rig's authored position and rotation, facing profile, IK markers, and torso rotation, including rigs that were still ANIMATED. Collision proxies are disabled for placement ghosts and during RAGDOLL, then restored for RECOVERING/ANIMATED.
|
||||
|
||||
Focused verification: `tests/test_phase45_collision_bodies.gd` (223 assertions), with Phase 4b (53), Phase 4c (19), and Phase 3c (96) regression suites also green after the final fixes.
|
||||
|
||||
### 21. Phase 4b Polish — Mode Switcher, Theme, Terrain Painting, Director UX, Walk Jitter Fix
|
||||
|
||||
**Phase 4b** is a **polish + bugfix** pass over the Sandbox Stage Builder. It does **not** add new gameplay systems; it restructures the top bar into a **3-segment mode switcher** with contextual toolbars, adds a **bottom status bar** with live mouse coordinates, a **mode badge/frame** and per-mode cursors, upgrades **terrain placement** from single-click into **drag-to-paint** with a grid spatial dictionary, makes the **director's "pick a target" flows** kid-friendly (cursor-attached tooltip, rubber-band trajectory, reticle cursor), introduces a hand-editable **`res://sandbox_theme.json`**, and fixes the **walk-waypoint arrival jitter**. Run via **F6** on `res://scenes/sandbox_stage.tscn`; not wired into the editor. See §18 for the mode-switcher / status-bar / badge / cursor surface; this section details the placement, styling, director-UX, and bugfix internals.
|
||||
@@ -949,7 +959,7 @@ Behavior:
|
||||
| `res://scripts/stickman_editor.gd` | Editor controller — File/Edit/View menu actions, save/load/clear, JSON v1.5 serialization with multi-shape/rotation/scale, `part_order`, Phase 8 `proportions`/`pivot`/`length`, and Phase 9 Round 5 per-part `guide_offset` export, `settings.json` load/save, editor-wide shape clipboard (Copy/Paste across panels), broadcast of grid/snap settings to panels, Reset Views, populates panels, coordinates selection across panels. |
|
||||
| `res://scripts/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9 (Rounds 4–6 bugfix).** Standalone runtime adapter (`class_name StkRigAdapter`, `static func apply(stk_data, rig)`): fits an instantiated `master_rig.tscn` to a loaded `.stk` by re-fitting the 8 limb bones (`Skeleton2D/Torso/...` `Bone2D` lengths + lower-bone origins), recalibrating the IK targets (`IK_Targets/Left|Right_Hand`, `Left|Right_Leg`), and mounting the `.stk` shapes onto the `Body/*` visual nodes (**one node per shape**: closed → single `Polygon2D` fill, open → single `Line2D` width 2). Shape mounting recomputes each part's bounding box at mount time (file `pivot`/`length` are no longer trusted) and derives a mount transform in the rig's **hanging convention** (joint anchor at the local origin, far end along local `+Y`) via `_compute_mount_transform()`: the part's preview transform `E(P) = C + R(rot)·S·(P − C)` (rotation + scale about the bbox center — the editor's exact Whole-Stickman-preview transform) is composed **first**, then the anchor/alignment θ/bone-fit scale are computed on the **transformed geometry**; rotations near ±180° (`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint). Anchors (raw family rules): head/torso bottom-center `(cx, max_y)`, left horizontal limbs `(max_x, cy)`, right horizontal limbs `(min_x, cy)`, vertically drawn limbs top-center `(cx, min_y)`; alignment rotation θ maps the far end onto `+Y`; scaling is **anisotropic** — only the **auto-detected drawn long axis** (`width >= height`) scales to the bone length (`bone_length/extent`, guard `extent <= 0.0001` → `1.0`), cross-axis thickness stays 1:1. The `RemoteTransform2D` drivers keep `update_rotation = true`, so mounted shapes follow their bones under IK flexing. (Phase 9 Round 5) when a part dict carries `guide_offset`, the mounted geometry is translated by `t = (guide_offset + (A − C)).rotated(−c_node)`; (Phase 9 Round 6) when `guide_offset` is present, the joint anchor is whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is nearest the part's guide joint (`center − guide_offset`), replacing the per-side family choice + 180° flip heuristic for that case (fixing the lower-left-leg and lower-right-arm, which were mounted 180° off their bones) — old files without the key keep the family rules + flip heuristic as the fallback in the driver's bone frame (A = mount anchor incl. the 180° flip rule, C = raw bbox center, `c_node` = driver `RemoteTransform2D.global_rotation`), so the harness reproduces the editor's guide-relative placement 1:1; old files without the key keep the offset-0 behavior (head falls back to `HEAD_CHIN_DROP`). Each `Body/*` container's scale is reset to `(1,1)` / rotation `0` (position untouched). (Phase 9) also fits the head bone (`Head.position.y = -proportions.torso_length`) while mounting the head as **full geometry** — it clears the head's inline `@tool` circle script and mounts `.stk` head shapes as `Line2D`/`Polygon2D`, and zeroes the Head driver's local position so the chin sits on the neck joint; the head mounts upright (`θ = 0`, `s = 1`) but still applies the part scale via `E` (face ≈160 px). `_mount_shapes()` also handles **v1.0/v1.1 single-shape** part dicts (wraps the part dict as one shape when it carries `points` but no `shapes` array), so older `.stk` files mount as visible geometry instead of being cleared. **Not used by the editor** — consumed by the runtime pipeline. |
|
||||
| `res://scripts/stickman_factory.gd` | **Phase 9.** Runtime entry point (`class_name StickmanFactory`, `extends RefCounted`); a static factory that turns a `.stk` file into a live, rigged `master_rig.tscn` instance. `load_stk(path)` reads + parses the file (`{}` + `push_warning` on failure); `spawn_from_data(stk_data)` instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, and returns the rig root **typed as `StickmanRig`** (the rig now carries the `StickmanRig` root script); `spawn(path)` chains them (`null` on empty data). **Not used by the editor.** |
|
||||
| `res://scripts/stickman_rig.gd` | **Phase 9 Task 4.** `class_name StickmanRig`, `extends Node2D`; the runtime owner of facing direction, per-joint bone bend, `Body/*` z-order, and (Phase 10/11) the **kinematic-to-ragdoll** state switch with instant handoff + stand-up recovery, attached to the `master_rig.tscn` root `Master`. Exports a `facing_profile` preset (`FacingProfile` LEFT/RIGHT/FORWARD, default FORWARD) and four `@export_enum("Normal","Inverted")` per-joint bend vars (`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend`), plus (Phase 11) `rest_timeout` (2.0 s) and `auto_recover` (true) exports. Non-`@tool`: resolves `Skeleton2D`/`Body`/bend joints at runtime, enables its own modification stack, and applies the profile (flag writes + `Body/*` reorder) in `_ready()` and setters. Signals `facing_profile_changed` / `bend_flag_changed` / `state_changed`; public API `set_facing_profile`/`get_facing_profile`, `set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, `get_bend_joint_global_position()`, plus the ragdoll API `set_ragdoll(enabled)`/`toggle_ragdoll()`/`is_in_ragdoll()`/`request_recovery()` with `state` / `enum RigState { ANIMATED, RAGDOLL, RECOVERING }`. The recovery cycle is **pre-capture → ragdoll pose capture (7-ray ground probe) → mirror-aware re-anchor → snap (entry-captured chain offsets, `(−π, π]`-normalized bones, ankle clamp) → 2.0 s tween to the pre-ragdoll pose (arms/head bone-driven with `_natural_joint_target` for elbows; legs IK-driven) → finish (profile + bend flags + LookAt restored)**, with every per-joint bend decision editable in the `RAGDOLL_JOINT_BAND_LIMITS` / `RECOVERY_LEG_SIDE_FORCE` / `RECOVERY_JOINT_PATH` tables (Joint bend configuration (EDIT ME)). Diagnostics are gated behind `DEBUG_RECOVERY := false`. (Phase 4b) `_update_walking()` latches `_walk_mode` once per walk (`LATCH_PROBE_MAX_FRAMES` 6), unifies arrival on the final target at `ARRIVE_DISTANCE` (snap-on-arrive), steers to the final target when close, and re-asserts standing markers one frame after stop — fixing the walk-waypoint arrival jitter. Null-guarded (`push_warning` + skip). **Not used by the editor.** |
|
||||
| `res://scripts/stickman_rig.gd` | **Phase 9 Task 4.** `class_name StickmanRig`, `extends Node2D`; the runtime owner of facing direction, per-joint bone bend, `Body/*` z-order, and (Phase 10/11) the **kinematic-to-ragdoll** state switch with instant handoff + stand-up recovery, attached to the `master_rig.tscn` root `Master`. Exports a `facing_profile` preset (`FacingProfile` LEFT/RIGHT/FORWARD, default FORWARD) and four `@export_enum("Normal","Inverted")` per-joint bend vars (`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend`), plus (Phase 11) `rest_timeout` (2.0 s) and `auto_recover` (true) exports. Non-`@tool`: resolves `Skeleton2D`/`Body`/bend joints at runtime, enables its own modification stack, and applies the profile (flag writes + `Body/*` reorder) in `_ready()` and setters. Signals `facing_profile_changed` / `bend_flag_changed` / `state_changed`; public API `set_facing_profile`/`get_facing_profile`, `set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, `get_bend_joint_global_position()`, plus the ragdoll API `set_ragdoll(enabled)`/`toggle_ragdoll()`/`is_in_ragdoll()`/`request_recovery()` with `state` / `enum RigState { ANIMATED, RAGDOLL, RECOVERING }`. The recovery cycle is **pre-capture → ragdoll pose capture (7-ray ground probe) → mirror-aware re-anchor → snap (entry-captured chain offsets, `(−π, π]`-normalized bones, ankle clamp) → 2.0 s tween to the pre-ragdoll pose (arms/head bone-driven with `_natural_joint_target` for elbows; legs IK-driven) → finish (profile + bend flags + LookAt restored)**, with every per-joint bend decision editable in the `RAGDOLL_JOINT_BAND_LIMITS` / `RECOVERY_LEG_SIDE_FORCE` / `RECOVERY_JOINT_PATH` tables (Joint bend configuration (EDIT ME)). Diagnostics are gated behind `DEBUG_RECOVERY := false`. (Phase 4b) `_update_walking()` latches `_walk_mode` once per walk (`LATCH_PROBE_MAX_FRAMES` 6), unifies arrival on the final target at `ARRIVE_DISTANCE` (snap-on-arrive), steers to the final target when close, and re-asserts standing markers one frame after stop — fixing the walk-waypoint arrival jitter. (Phase 4.5) builds ten top-level `AnimatableBody2D` collision proxies, explicitly synchronizes them to the rendered `Body/*` transforms each physics frame, derives the head circle center/radius from mounted geometry (90 px minimum), and applies bounded, contact-quadrant-aware bone flinches that decay to captured base rotations. `snap_to_standing()` and the stage's PLAY→DIRECT reset restore authored transforms and pose state. Null-guarded (`push_warning` + skip). **Not used by the editor.** |
|
||||
| `res://scripts/create_animations.gd` | **Phase 11.** `@tool extends EditorScript`; a **standalone editor utility** (run manually with `master_rig.tscn` open; not auto-loaded or referenced at runtime) that supersedes the deleted `scripts/create_walk.gd`. `_run()` bakes `walk_left`/`walk_right` (same keyframes as the old script) and a one-shot `stand_up` (`POSE_DOWN` → `POSE_STANDING`, `STAND_UP_DURATION` 2.0, `loop_mode = LOOP_NONE`) into the open scene's default `AnimationLibrary`. The baked `stand_up` is an **authored reference only** — runtime recovery does not play it (`StickmanRig` tweens the IK targets directly from the captured ragdoll pose, since a fixed first keyframe can never match an arbitrary rest pose). `walk_right` is the **canonical walk** clip; `walk_left` remains baked in the library but is **not used at runtime** (facing is set explicitly and the rig root X-mirrors for LEFT). |
|
||||
| `res://scripts/test_harness.gd` | **Phase 9.** Standalone staging scene (run via **F6** on `res://scenes/test_harness.tscn`, not wired into the editor) for debugging bone scales, vector-drawing offsets, and IK limits in isolation. Top UI bar: "Open .stk…" / quick-select buttons (`stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`), "Show Bones" / "Show IK Handles" toggles, loaded-filename label. `SubViewport` world + enabled `Camera2D` (middle-mouse pan, wheel zoom, recenter on spawn); each load frees the previous rig and spawns a fresh one via `StickmanFactory.spawn()`. A world-space debug overlay draws true bone segments (joint dots + parent→child lines, with limb leaf bones drawn out to their IK targets so wrist/ankle joints are visible; the **Head** leaf is the exception — its target is a LookAt aim point, not a joint, so it draws a ~90 px segment along the bone's own direction instead) and colored IK-target markers (hands green, feet blue, head yellow, torso magenta) plus a semi-transparent yellow head-aim line; the **6** `Marker2D` IK targets are click-draggable — the 4 limb targets flex limbs live via `SkeletonModificationStack2D` TwoBoneIK (the rig self-enables its stack), the Torso target translates the whole rig via its `RemoteTransform2D`, and the Head target drives the head's LookAt aim rotation (Phase 9 Round 7). |
|
||||
| `res://scenes/test_harness.tscn` | **Phase 9.** Standalone staging scene backing `scripts/test_harness.gd` (run via **F6**; not wired into the editor). |
|
||||
@@ -957,9 +967,9 @@ Behavior:
|
||||
| `res://scripts/terrain_utils.gd` | **Vector Terrain System.** `class_name TerrainUtils`, `extends RefCounted` — static `sanitize_points()` (grid snap → local `_simplify_polyline()` → clockwise enforcement) and a `spawn_block()` factory. |
|
||||
| `res://scripts/physics_test_harness.gd` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene root building flat/ramp/step terrain via `TerrainUtils`, instantiating `master_rig.tscn`, spawning props via **1/2/3** (`PropUtils`), and adding a rig collision proxy (run via **F6**; not wired into the editor). A toggle-mode button flips the rig's kinematic-to-ragdoll mode via `_rig.set_ragdoll()`, plus (Phase 11) a **Rest** `SpinBox` (writes `_rig.rest_timeout`) and **"Recover Now"** button (`_rig.request_recovery()`); the rig's `state_changed` signal removes the proxy on `RAGDOLL` and re-adds it (idempotently) on `ANIMATED`/`RECOVERING`. |
|
||||
| `res://scenes/physics_test_harness.tscn` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene backing `scripts/physics_test_harness.gd` (run via **F6**; not wired into the editor). |
|
||||
| `res://scripts/prop_block.gd` | **Dynamic Vector Props.** `class_name PropBlock`, `extends RigidBody2D` — a reusable physical prop building `Polygon2D` (fill) + `Line2D` (outline) + `CollisionPolygon2D`/`CollisionShape2D` (polygon/circle collision) children in code, with material presets (mass + friction/bounce) and live-updating exports. |
|
||||
| `res://scripts/prop_block.gd` | **Dynamic Vector Props / Phase 4.5.** `class_name PropBlock`, `extends RigidBody2D` — a reusable physical prop building `Polygon2D` (fill) + `Line2D` (outline) + `CollisionPolygon2D`/`CollisionShape2D` (polygon/circle collision) children in code, with material presets (mass + friction/bounce), live-updating exports, contact monitoring, shape-cast CCD, and `collided(other, contact_normal, contact_position)` / `separated(other)` signals carrying world-space contact data. |
|
||||
| `res://scripts/prop_utils.gd` | **Dynamic Vector Props.** `class_name PropUtils`, `extends RefCounted` — static `create_box()` / `create_ball()` / `create_plank()` / `create_triangle()` primitive generators and a `spawn_prop()` factory (sanitizes polygon points via `TerrainUtils`). |
|
||||
| `res://scripts/sandbox_stage.gd` | **Sandbox Stage Builder.** `class_name SandboxStage`, `extends Node2D` — root controller: `enum StageMode { EDIT, DIRECT, PLAY }` state machine (freezes props with `FREEZE_MODE_KINEMATIC`; runs stickman queues + rags props/areas in PLAY), placement mode + terrain drag-painting, a grid spatial dictionary, camera pan/zoom, deletion, bottom status bar, mode badge/frame/cursors, the `res://sandbox_theme.json` loader, and signal fan-out (`mode_changed(mode: int)` / `object_placed` / `object_selected` / `object_deselected` / `object_deleted`). **Phase 3b** instantiates the `AssetSelector` popup + thumbnail renderers, owns the selector open/close flow and lazy per-frame thumbnail drain (§22). **Phase 3c** instantiates the queue/rule panels + editors + waypoint context, owns the unified `CaptureKind` target-capture system, the "Edit Queue…"/"Edit Rules…" + right-click context entry points, the shared confirmation dialog, and consequence-only rule editing (§23). Standalone staging scene run via **F6**; not wired into the editor. |
|
||||
| `res://scripts/sandbox_stage.gd` | **Sandbox Stage Builder / Phase 4.5.** `class_name SandboxStage`, `extends Node2D` — root controller: `enum StageMode { EDIT, DIRECT, PLAY }` state machine (freezes props with `FREEZE_MODE_KINEMATIC`; runs stickman queues + rags props/areas in PLAY), placement mode + terrain drag-painting, a grid spatial dictionary, camera pan/zoom, deletion, bottom status bar, mode badge/frame/cursors, the `res://sandbox_theme.json` loader, and signal fan-out (`mode_changed(mode: int)` / `object_placed` / `object_selected` / `object_deselected` / `object_deleted`). **Phase 3b** instantiates the `AssetSelector` popup + thumbnail renderers, owns the selector open/close flow and lazy per-frame thumbnail drain (§22). **Phase 3c** instantiates the queue/rule panels + editors + waypoint context, owns the unified `CaptureKind` target-capture system, the "Edit Queue…"/"Edit Rules…" + right-click context entry points, the shared confirmation dialog, and consequence-only rule editing (§23). **Phase 4.5** routes `PropBlock` physics contacts through proxy metadata, tracks per-part body IDs for pair debouncing, preserves rig→prop rule ordering, purges stale pairs on separation/deletion/ragdoll, and restores authored rig state when returning PLAY→DIRECT. Standalone staging scene run via **F6**; not wired into the editor. |
|
||||
| `res://scripts/stage_spawner.gd` | **Sandbox Stage Builder.** `class_name StageSpawner`, `extends RefCounted` — registry-driven factory (`Array[Dictionary]`, no id `match`); reuses `TerrainUtils` / `PropUtils` / `StickmanFactory`; centers terrain on its origin. Exposes `is_terrain_id()` / `get_template_aabb()` and tags spawned terrain with a `spawn_id`. `get_template_aabb(id)` mirrors `_spawn_terrain()`'s sanitize pass (`TerrainUtils.sanitize_points` at `TERRAIN_GRID_SIZE` 16), so the returned extent matches the real placed footprint — e.g. the 200-px-wide Ground template returns a **192-px** stride — and drives the block-unit paint stride, ghost sizing, and cell rasterization. **Phase 3b:** registry ids `ground/ramp/step/prop/stickman/area` (separate `crate`/`ball` removed); holds `selected_stickman_path` / `selected_prop_id` session state + a per-path `_stickman_cache`; `prop`/`stickman` spawn the **selected** asset (§22). |
|
||||
| `res://scripts/stickman_library.gd` | **Asset Library (Phase 3b).** `class_name StickmanLibrary`, `extends RefCounted` — scans `res://stickmen/*.stk` into `{path, name, data}` entries (corrupt/missing-`body_parts` skipped; name = `stickman_name` else filename basename); `make_entry(path)` for Browse-chosen paths. |
|
||||
| `res://scripts/prop_library.gd` | **Asset Library (Phase 3b).** `class_name PropLibrary`, `extends RefCounted` — static registry of the 4 prop templates (Crate/Wood, Ball/Rubber, Plank/Metal, Triangle/Cardboard); `get_default_id()` = `"crate"`. |
|
||||
|
||||
@@ -10,11 +10,11 @@ This document tracks known technical debt, optimization opportunities, and minor
|
||||
|
||||
| # | Issue | Priority | Status | Notes |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | **Neck Joint Free Rotation** — The head can spin 360° during violent ragdoll tumbles, which looks unnatural. | Medium | Open | Consider adding `angular_limit_enabled = true` with ±45° limits and low stiffness to allow natural lolling while preventing full rotations. |
|
||||
| 1 | **Ragdoll Neck Angular Limits** — The ragdoll neck `PinJoint2D` remains unrestricted, so a violent physical tumble can still rotate the head freely. | Medium | Open | Phase 4.5 fixes the separate ANIMATED collision-flinch accumulation bug by bounding offsets from a captured base rotation; it does not change ragdoll neck limits. Consider a tuned angular band/stiffness when natural ragdoll head motion is revisited. |
|
||||
| 2 | **Torso Capsule Origin** — The torso capsule's local origin should be at its midpoint for natural rotation. Currently derived from bone distance; verify alignment. | Low | Open | Test by rotating the torso body in ragdoll mode — it should spin about its center, not its top. Adjust `position` offset if needed. |
|
||||
| 3 | **Collision Layers Separation** — Bodies and terrain share layer 1/mask 1. This may cause self‑collision issues (limbs clipping through each other) under high stress. | Low | Open | Future enhancement: assign ragdoll limbs to layer 2, terrain to layer 1, and use masks to allow limb‑limb collision only where desired. |
|
||||
| 4 | **Performance (Ragdoll Pooling)** — Spawning 10 bodies + 9 joints procedurally is fine for a single rig. If the scene ever contains dozens of ragdolls, consider a pooling system to avoid allocation spikes. | Low | Open | Not needed now, but worth noting if scaling to large crowds. |
|
||||
| 5 | **Line2D ↔ Capsule Radius Match** — Limbs use `Line2D` width 16, ragdoll capsules radius 8. These align visually. | Low | ✅ Resolved | Verified during implementation. No action needed. |
|
||||
| 5 | **Animated Collision Proxy Approximation** — Stickman collision proxies use fixed-radius capsules for torso and limbs rather than exact mounted-shape polygons. | Low | Open | Phase 4.5 synchronizes ten proxies to the rendered parts and grows the head circle to cover mounted geometry (minimum radius 90 px), but torso/limb capsule radii remain approximate and do not adapt to arbitrary custom widths. Consider per-shape collision generation only if visible misses justify the complexity. (2026-09-12) |
|
||||
| 6 | **Recovery Animation Starting Pose** — The `stand_up` animation must work from any captured ragdoll pose. Currently uses a fixed start frame. | High | ✅ Resolved | Phase 11: `_start_recovery()` captures the 10 bodies' rig‑local pose, snap‑solves the skeleton via the 6 IK targets, then `_play_stand_up()` tweens the markers **directly** from the captured values to `STAND_POSE` (`STAND_UP_DURATION`, sine ease‑in‑out) — the baked `stand_up` animation is **not** played (a fixed first keyframe can never match an arbitrary rest pose; the earlier bridge‑into‑the‑animation approach caused a visible jump and was removed). Revision: the snap now derives the hip (`pos − dir·half`) and wrist/ankle (`pos + dir·half`) from the capsule ends and subtracts the Torso bone's `bone_angle` for the marker rotation, so recovery starts from the ragdoll's exact final pose (e.g. sitting stays sitting). (2026‑08‑27) |
|
||||
| 7 | **Transition Visual Pop** — The crossfade between kinematic and ragdoll currently uses a simple `modulate.a` lerp. This may cause ghosting if the kinematic and ragdoll poses are misaligned. | Medium | ✅ Resolved | Phase 11: the entry is now an **instant handoff** — the ragdoll is built from the **current solved bone positions** (`AnimationPlayer.stop(true)` keeps the pose), then `Body/*` is hidden and the IK stack disabled in the same call. The earlier opacity crossfade + pin‑softness ramp was **removed on director feedback** (it read as ghosting, since both poses are identical). Recovery snap‑solves the kinematic skeleton to the captured ragdoll pose before re‑showing `Body/*`, eliminating the pop on both directions. (2026‑08‑27) |
|
||||
| 8 | **Rest Timeout UI** — The director can adjust `rest_timeout` via inspector, but there is no in‑world UI in the physics harness yet. | Low | ✅ Resolved | Phase 11: added a Rest `SpinBox` (0.1–10 s, step 0.1) to the harness UI that writes `_rig.rest_timeout` (runtime‑only), plus a "Recover Now" button → `_rig.request_recovery()`. (2026‑08‑27) |
|
||||
@@ -34,6 +34,9 @@ This document tracks known technical debt, optimization opportunities, and minor
|
||||
| 22 | **`--check-only --script` needs a prior `--editor` rescan for new `class_name` scripts** — a freshly added `class_name` (e.g. `QueuePanel`) is not resolvable by `--check-only --script res://scripts/sandbox_stage.gd` until `--headless --editor --quit` regenerates `.godot/global_script_class_cache.cfg`. | Low | Open | Verification-workflow gotcha only (not a runtime bug): in-editor the cache is always current. CI/headless scripts that add new `class_name`s should run an `--editor` pass before per-script `--check-only`. (2026-09-04) |
|
||||
| 23 | **Phase 3c confirmation dialog is popped over an exclusive panel** — Queue delete / Clear All (`_on_queue_panel_delete_requested` / `_on_queue_panel_clear_requested`) and Rule delete / Clear All (`_on_rule_panel_delete_requested` / `_on_rule_panel_clear_requested`) call the shared `ConfirmationDialog` (`_ask_confirm`) **without first hiding the `exclusive = true` `QueuePanel` / `RulePanel`**. | Low | Open | Distinct facet of #19 (host-visibility discipline): popping a second exclusive window over a visible exclusive panel logs a **non-fatal engine warning** and the confirmation may render **non-modal**. It still functions (the panel is re-shown after the callback) — cosmetic. A fix would hide the host panel around `_ask_confirm` and re-show it in the confirm callback (and on the dialog's cancel/hide). Deliberate non-debt note: plan §11.2's separate confirmation dialog for editing `ragdoll`/`recover` actions was implemented instead as a paramless `ActionEditor` pre-fill — recorded in `docs/phase_3c_editor_spec.md` §2 decision 5, not as a debt row. (2026-09-04) |
|
||||
| 24 | **Sandbox trigger rules use session-only instance IDs** — `entered_area` source/target references and action targets are `Object.get_instance_id()` integers. | Low | Open | Correct for the current in-memory rule engine and shared by the builder, matcher, visualizer, cleanup, and action executor. Rules survive `EDIT ⇄ DIRECT ⇄ PLAY` but are not serialized; a future saved-stage format needs stable authored object IDs instead of runtime instance IDs. (2026‑09‑11) |
|
||||
| 25 | **Contact direct-state timing fallback** — `PropBlock` may receive `body_entered` before a matching direct-state contact row is available. | Low | Open | `_on_body_entered()` uses the documented center-direction / prop-position fallback when the row is absent, so the signal remains safe but the normal and position are less precise on that edge. (2026-09-12) |
|
||||
| 26 | **Impact speed is a coarse magnitude** — collision reactions currently scale from `prop.linear_velocity.length()`, not normal-relative velocity or an impulse computed from contact mass. | Low | Open | This keeps the signal contract simple and deterministic. Use the normal component and/or contact impulse if reaction strength needs to reflect glancing impacts accurately. (2026-09-12) |
|
||||
| 27 | **Post-IK reaction timing is engine-order-sensitive** — bounded flinch offsets are applied at the end of `StickmanRig._physics_process()`, after the IK/head-pin work. | Low | Open | The current slot is verified and prevents cumulative rotation, but future changes to Skeleton2D modification ordering could overwrite or delay the visible reaction. Revisit only with a targeted engine-order regression. (2026-09-12) |
|
||||
|
||||
---
|
||||
|
||||
@@ -75,6 +78,7 @@ This document tracks known technical debt, optimization opportunities, and minor
|
||||
| 2026-09-03 | **Rule-builder popup-anchor bugfix round** (`sandbox_stage.gd`, documented in `README.md` / `AGENTS.md`): the Phase 4 rule-builder context menus used to re-pop at the live mouse position on every re-open, so cycling "⚡ When…" → "⬅ Back to actions" → "When…" walked the menu down the screen. Now a **session anchor** records the first context menu's screen position (`_popup_anchor: Rect2i` / `_popup_anchor_set`; Direct first menu = right of the clicked stickman; rule-label edit entry = the click position) and all child popups reuse it via `_set_popup_anchor(rect)` / `_clear_popup_anchor()` / `_popup_anchor_rect()`, until cleared on confirm (`_finalize_rule`), cancel (`_cancel_rule_build`), or Direct-mode/flow exit (`_clear_director_pending`) — but **not** on `_reset_rule_builder()` (Back-to-actions reuses it). No numbered debt row described the old cursor-following behavior, so no row was flipped to Resolved and no duplicates introduced; recorded here in the Change Log only. |
|
||||
| 2026-09-04 | Phase 3c (Editor Tools — Action & Rule Editing) implemented: `scripts/action_registry.gd` / `trigger_registry.gd` (extensible action/trigger templates), `scripts/action_editor.gd` / `rule_editor.gd` / `queue_panel.gd` / `rule_panel.gd` + `scenes/*.tscn` shells, `scripts/waypoint_context.gd`, plus integration in `sandbox_stage.gd` (unified `CaptureKind` target-capture, "Edit Queue…/Edit Rules…" + right-click stickman/waypoint context menus, confirmation dialog, consequence-only rule editor) and `stage_director_visuals.gd` (`hit_test_waypoint_action`, waypoint edit highlight). Logged #19 (ad-hoc popup host-visibility discipline), #20 ("Add Rule" has no source in waypoint-filtered panels), #21 (drag-reorder nearest-row-center), #22 (`--check-only --script` needs a prior `--editor` rescan), and later the same day #23 (Phase 3c confirmation dialog popped over an exclusive panel — the delete/clear-all confirm paths do not hide the `QueuePanel`/`RulePanel` first). Documented the plan-§11.2 deviation (paramless `ActionEditor` pre-fill instead of a separate ragdoll/recover confirmation dialog) in `docs/phase_3c_editor_spec.md` §2 decision 5 — a design decision, so no debt row. Static-verified via headless `--editor --quit` + per-script `--check-only`; runtime F6 flows (queue/rule panel edit-delete-reorder, waypoint context menu, visual walk edit, full vs consequence-only rule editor) still need manual verification. |
|
||||
| 2026-09-11 | Phase 4c (Trigger Area Reliability) verified: `sandbox_stage.gd` `entered_area` now requires source + area instance IDs and detects visible world-AABB overlap for ANIMATED stickmen/unfrozen props, edge-triggered per area/movable pair. The prop enumeration scope bug was corrected. `tests/test_phase4c_area.gd` passed all 19 assertions, including transformed-area, leg-level overlap, wildcard/re-entry, speech consequence, stale-target, and deletion-cleanup coverage; editor verification confirmed the speech bubble fires for the configured source stickman. Logged #24 (session-only instance-ID rule references). |
|
||||
| 2026-09-12 | Phase 4.5 collision bodies verified: ten explicitly synchronized `AnimatableBody2D` proxies replace the old feet-point stickman collision poll; `PropBlock` supplies world-space contact data with shape-cast CCD; stage routing preserves rig→prop rules with per-part pair debouncing; bounded quadrant-aware flinches and PLAY→DIRECT authored-pose restoration were verified. `tests/test_phase45_collision_bodies.gd` passed 223 assertions; Phase 4b (53), Phase 4c (19), and Phase 3c (96) remained green. Reconciled #1/#5 and retained #25–#27 as intentional implementation limitations. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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**
|
||||
+26
-3
@@ -33,6 +33,7 @@ const CIRCLE_SEGMENTS: int = 48
|
||||
const DEFAULT_FILL_COLOR := Color(0.55, 0.35, 0.15, 1.0)
|
||||
const DEFAULT_OUTLINE_COLOR := Color(0.15, 0.08, 0.02, 1.0)
|
||||
const DEFAULT_OUTLINE_WIDTH: float = 2.0
|
||||
const MAX_CONTACTS_REPORTED := 32
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exported properties
|
||||
@@ -94,7 +95,8 @@ var _circle_shape: CircleShape2D
|
||||
|
||||
## Emitted when this prop's body makes contact with another physics body.
|
||||
## `other` is the body that entered contact (Phase 4 trigger source).
|
||||
signal collided(other: Node)
|
||||
signal collided(other: Node, contact_normal: Vector2, contact_position: Vector2)
|
||||
signal separated(other: Node)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
@@ -106,13 +108,34 @@ func _ready() -> void:
|
||||
_apply_style()
|
||||
# Enable contact reporting so the body_entered signal fires (Phase 4).
|
||||
contact_monitor = true
|
||||
max_contacts_reported = 8
|
||||
max_contacts_reported = MAX_CONTACTS_REPORTED
|
||||
continuous_cd = RigidBody2D.CCD_MODE_CAST_SHAPE
|
||||
if not body_entered.is_connected(_on_body_entered):
|
||||
body_entered.connect(_on_body_entered)
|
||||
if not body_exited.is_connected(_on_body_exited):
|
||||
body_exited.connect(_on_body_exited)
|
||||
|
||||
|
||||
func _on_body_entered(body: Node) -> void:
|
||||
collided.emit(body)
|
||||
var center: Vector2 = (body as Node2D).global_position if body is Node2D else global_position
|
||||
var normal: Vector2 = (center - global_position).normalized()
|
||||
var position: Vector2 = global_position
|
||||
var direct_state := PhysicsServer2D.body_get_direct_state(get_rid())
|
||||
if direct_state != null:
|
||||
for index: int in direct_state.get_contact_count():
|
||||
if direct_state.get_contact_collider_id(index) == body.get_instance_id():
|
||||
var reported: Vector2 = direct_state.get_contact_local_normal(index)
|
||||
if not reported.is_zero_approx():
|
||||
normal = reported.normalized()
|
||||
if normal.dot(center - global_position) < 0.0:
|
||||
normal = -normal
|
||||
position = direct_state.get_contact_local_position(index)
|
||||
break
|
||||
collided.emit(body, normal, position)
|
||||
|
||||
|
||||
func _on_body_exited(body: Node) -> void:
|
||||
separated.emit(body)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal build / apply
|
||||
|
||||
+77
-29
@@ -419,7 +419,6 @@ func _physics_process(_delta: float) -> void:
|
||||
_restore_authored_state()
|
||||
if current_mode == StageMode.PLAY:
|
||||
_update_area_entry()
|
||||
_update_stickman_prop_collision()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input
|
||||
@@ -682,11 +681,14 @@ func _enter_play_mode() -> void:
|
||||
|
||||
|
||||
func _save_object_state(node: Node2D) -> void:
|
||||
_authored[node.get_instance_id()] = {
|
||||
var entry := {
|
||||
"node": node,
|
||||
"position": node.global_position,
|
||||
"rotation": node.global_rotation,
|
||||
}
|
||||
if node is STICKMAN_RIG:
|
||||
entry["facing_profile"] = (node as STICKMAN_RIG).get_facing_profile()
|
||||
_authored[node.get_instance_id()] = entry
|
||||
|
||||
|
||||
func _clear_object_state(node: Node2D) -> void:
|
||||
@@ -702,6 +704,8 @@ func _restore_authored_state() -> void:
|
||||
continue
|
||||
node.global_position = entry.get("position", node.global_position)
|
||||
node.global_rotation = entry.get("rotation", node.global_rotation)
|
||||
if node is STICKMAN_RIG and entry.has("facing_profile"):
|
||||
(node as STICKMAN_RIG).set_facing_profile(int(entry["facing_profile"]))
|
||||
if node is RigidBody2D:
|
||||
var body := node as RigidBody2D
|
||||
body.linear_velocity = Vector2.ZERO
|
||||
@@ -753,10 +757,12 @@ func _finalize_placed_node(node: Node2D) -> void:
|
||||
rig.arrived.connect(_on_rig_arrived.bind(rig))
|
||||
rig.action_finished.connect(_on_rig_action_finished.bind(rig))
|
||||
rig.speech_finished.connect(_on_rig_speech_finished.bind(rig))
|
||||
rig.state_changed.connect(_on_rig_state_changed.bind(rig))
|
||||
_director_visuals.mark_dirty()
|
||||
if node is PropBlock:
|
||||
var prop := node as PropBlock
|
||||
prop.collided.connect(_on_prop_collided.bind(prop))
|
||||
prop.separated.connect(_on_prop_separated.bind(prop))
|
||||
|
||||
|
||||
func _spawn_ghost() -> void:
|
||||
@@ -797,6 +803,7 @@ func _configure_ghost_collision(ghost: Node2D) -> void:
|
||||
var anim := rig.get_node_or_null(NodePath("AnimationPlayer")) as AnimationPlayer
|
||||
if anim != null:
|
||||
anim.stop()
|
||||
rig.set_collision_bodies_enabled(false)
|
||||
|
||||
|
||||
func _free_ghost() -> void:
|
||||
@@ -1099,6 +1106,8 @@ func delete_selected() -> void:
|
||||
if node is TerrainBlock:
|
||||
nav_changed = true
|
||||
_clear_object_state(node)
|
||||
var deleted_ids: Array[int] = [node.get_instance_id()]
|
||||
_purge_collision_pairs_for_ids(deleted_ids)
|
||||
node.queue_free()
|
||||
_rebuild_grid_cells()
|
||||
_cleanup_rules_for_nodes(selected)
|
||||
@@ -2399,8 +2408,72 @@ func _on_rig_speech_finished(rig: StickmanRig) -> void:
|
||||
_handle_event({ "type": "speech_finished", "source": rig, "target": null, "position": rig.global_position, "action": {} })
|
||||
|
||||
|
||||
func _on_prop_collided(other: Node, prop: PropBlock) -> void:
|
||||
_handle_event({ "type": "collided", "source": prop, "target": other, "position": prop.global_position, "action": {} })
|
||||
func _on_prop_collided(other: Node, normal: Vector2, position: Vector2, prop: PropBlock) -> void:
|
||||
if other == null or not is_instance_valid(other):
|
||||
return
|
||||
if bool(other.get_meta("stickman_ragdoll_body", false)):
|
||||
return
|
||||
var has_rig_metadata := other.has_meta("stickman_rig") or other.has_meta("part_name")
|
||||
var rig := other.get_meta("stickman_rig") as StickmanRig if other.has_meta("stickman_rig") else null
|
||||
var part := String(other.get_meta("part_name")) if other.has_meta("part_name") else ""
|
||||
if rig == null or not is_instance_valid(rig) or part.is_empty():
|
||||
if has_rig_metadata:
|
||||
return
|
||||
_handle_event({ "type": "collided", "source": prop, "target": other, "position": position, "action": {} })
|
||||
return
|
||||
if rig.state == StickmanRig.RigState.RAGDOLL:
|
||||
return
|
||||
var key := "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()]
|
||||
var pair: Dictionary = _collision_pairs.get(key, {
|
||||
"rig_id": rig.get_instance_id(),
|
||||
"prop_id": prop.get_instance_id(),
|
||||
"bodies": {},
|
||||
})
|
||||
var bodies: Dictionary = pair.get("bodies", {})
|
||||
var body_id := other.get_instance_id()
|
||||
if bodies.has(body_id):
|
||||
return
|
||||
var first_contact := bodies.is_empty()
|
||||
bodies[body_id] = true
|
||||
pair["bodies"] = bodies
|
||||
_collision_pairs[key] = pair
|
||||
rig.apply_collision_reaction(part, normal, prop.linear_velocity.length(), position)
|
||||
if first_contact:
|
||||
_handle_event({ "type": "collided", "source": rig, "target": prop, "position": position, "action": {} })
|
||||
|
||||
|
||||
func _on_prop_separated(other: Node, prop: PropBlock) -> void:
|
||||
if other == null or not is_instance_valid(other):
|
||||
return
|
||||
if not other.has_meta("stickman_rig"):
|
||||
return
|
||||
var rig := other.get_meta("stickman_rig") as StickmanRig
|
||||
if rig == null or not is_instance_valid(rig):
|
||||
return
|
||||
var key := "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()]
|
||||
var pair: Dictionary = _collision_pairs.get(key, {})
|
||||
if pair.is_empty():
|
||||
return
|
||||
var bodies: Dictionary = pair.get("bodies", {})
|
||||
bodies.erase(other.get_instance_id())
|
||||
if bodies.is_empty():
|
||||
_collision_pairs.erase(key)
|
||||
else:
|
||||
pair["bodies"] = bodies
|
||||
_collision_pairs[key] = pair
|
||||
|
||||
|
||||
func _on_rig_state_changed(new_state: int, rig: StickmanRig) -> void:
|
||||
if new_state == StickmanRig.RigState.RAGDOLL:
|
||||
var rig_ids: Array[int] = [rig.get_instance_id()]
|
||||
_purge_collision_pairs_for_ids(rig_ids)
|
||||
|
||||
|
||||
func _purge_collision_pairs_for_ids(ids: Array[int]) -> void:
|
||||
for key: String in _collision_pairs.keys().duplicate():
|
||||
var pair: Dictionary = _collision_pairs[key]
|
||||
if ids.has(int(pair.get("rig_id", -1))) or ids.has(int(pair.get("prop_id", -1))):
|
||||
_collision_pairs.erase(key)
|
||||
|
||||
|
||||
## Iterates every rule in order; every matching rule executes (no short-circuit).
|
||||
@@ -2501,31 +2574,6 @@ func _update_area_entry() -> void:
|
||||
_area_overlap[key] = false
|
||||
|
||||
|
||||
## Geometric stickman-vs-prop collision (ANIMATED rigs x unfrozen props),
|
||||
## edge-triggered via the prop's world AABB containing the rig's feet/root point.
|
||||
func _update_stickman_prop_collision() -> void:
|
||||
var rigs: Array[StickmanRig] = []
|
||||
var props: Array[PropBlock] = []
|
||||
for node: Node2D in _world_children_selectable():
|
||||
if node is STICKMAN_RIG:
|
||||
var rig := node as STICKMAN_RIG
|
||||
if rig.state == StickmanRig.RigState.ANIMATED:
|
||||
rigs.append(rig)
|
||||
elif node is PropBlock and not (node as PropBlock).freeze:
|
||||
props.append(node as PropBlock)
|
||||
for rig: StickmanRig in rigs:
|
||||
for prop: PropBlock in props:
|
||||
var key := "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()]
|
||||
var feet: Vector2 = rig.global_position - StickmanRig.FOOT_OFFSET
|
||||
var overlap: bool = STAGE_SELECTION.get_world_aabb(prop).has_point(feet)
|
||||
var was: bool = bool(_collision_pairs.get(key, false))
|
||||
if overlap and not was:
|
||||
_collision_pairs[key] = true
|
||||
_handle_event({ "type": "collided", "source": rig, "target": prop, "position": rig.global_position, "action": {} })
|
||||
elif not overlap and was:
|
||||
_collision_pairs[key] = false
|
||||
|
||||
|
||||
## Stickman point = feet (root - FOOT_OFFSET); prop point = global position.
|
||||
func _movable_point(node: Node2D) -> Vector2:
|
||||
if node is STICKMAN_RIG:
|
||||
|
||||
+282
-5
@@ -100,6 +100,52 @@ const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
|
||||
const RAGDOLL_LIMB_RADIUS := 8.0
|
||||
const RAGDOLL_TORSO_RADIUS := 12.0
|
||||
const RAGDOLL_HEAD_RADIUS := 100.0
|
||||
const HEAD_COLLISION_RADIUS := 90.0
|
||||
const TORSO_COLLISION_RADIUS := 20.0
|
||||
const ARM_COLLISION_RADIUS := 12.0
|
||||
const LEG_COLLISION_RADIUS := 14.0
|
||||
const HEAD_COLLISION_FALLBACK_CENTER := Vector2(0.0, -72.0)
|
||||
const COLLISION_LAYER := 1
|
||||
const COLLISION_PARTS: Dictionary = {
|
||||
"head": {"visual": "Head", "bone": "Torso/Head", "shape": "circle", "radius": HEAD_COLLISION_RADIUS},
|
||||
"torso": {"visual": "Body", "bone": "Torso", "shape": "capsule", "radius": TORSO_COLLISION_RADIUS},
|
||||
"left_upper_arm": {"visual": "LeftUpperArm", "bone": "Torso/LeftUpperArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
||||
"left_lower_arm": {"visual": "LeftLowerArm", "bone": "Torso/LeftUpperArm/LeftLowerArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
||||
"right_upper_arm": {"visual": "RightUpperArm", "bone": "Torso/RightUpperArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
||||
"right_lower_arm": {"visual": "RightLowerArm", "bone": "Torso/RightUpperArm/RightLowerArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
||||
"left_upper_leg": {"visual": "LeftUpperLeg", "bone": "Torso/LeftUpperLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
||||
"left_lower_leg": {"visual": "LeftLowerLeg", "bone": "Torso/LeftUpperLeg/LeftLowerLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
||||
"right_upper_leg": {"visual": "RightUpperLeg", "bone": "Torso/RightUpperLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
||||
"right_lower_leg": {"visual": "RightLowerLeg", "bone": "Torso/RightUpperLeg/RightLowerLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
||||
}
|
||||
const REACTION_BASE_ANGLE := 0.35
|
||||
const REACTION_REFERENCE_SPEED := 400.0
|
||||
const REACTION_MAX_ANGLE := 0.6
|
||||
const REACTION_LOCAL_X_EPSILON := 0.05
|
||||
const REACTION_TARGETS: Dictionary = {
|
||||
"head": [{"joint": "Head", "weight": 1.0, "decay": 0.50}],
|
||||
"torso": [{"joint": "Torso", "weight": 0.5, "decay": 0.40}],
|
||||
"left_upper_arm": [{"joint": "LeftUpperArm", "weight": 1.0, "decay": 0.40}],
|
||||
"left_lower_arm": [
|
||||
{"joint": "LeftLowerArm", "weight": 1.0, "decay": 0.35},
|
||||
{"joint": "LeftUpperArm", "weight": 0.4, "decay": 0.25},
|
||||
],
|
||||
"right_upper_arm": [{"joint": "RightUpperArm", "weight": 1.0, "decay": 0.40}],
|
||||
"right_lower_arm": [
|
||||
{"joint": "RightLowerArm", "weight": 1.0, "decay": 0.35},
|
||||
{"joint": "RightUpperArm", "weight": 0.4, "decay": 0.25},
|
||||
],
|
||||
"left_upper_leg": [{"joint": "LeftUpperLeg", "weight": 1.0, "decay": 0.40}],
|
||||
"left_lower_leg": [
|
||||
{"joint": "LeftLowerLeg", "weight": 1.0, "decay": 0.35},
|
||||
{"joint": "LeftUpperLeg", "weight": 0.4, "decay": 0.25},
|
||||
],
|
||||
"right_upper_leg": [{"joint": "RightUpperLeg", "weight": 1.0, "decay": 0.40}],
|
||||
"right_lower_leg": [
|
||||
{"joint": "RightLowerLeg", "weight": 1.0, "decay": 0.35},
|
||||
{"joint": "RightUpperLeg", "weight": 0.4, "decay": 0.25},
|
||||
],
|
||||
}
|
||||
|
||||
## Visual mesh colors: the rig's authored part color (gray Line2D limbs) and the
|
||||
## head's filled white circle. Collision shapes are invisible in-game, so each
|
||||
@@ -415,6 +461,11 @@ var _pre_ragdoll_state: Dictionary = {} # captured at _enter_ragdoll; recov
|
||||
var _probe_top_y: float = 0.0 # ground-probe context retained for the ankle clamp
|
||||
var _probe_bottom_y: float = 0.0
|
||||
var _probe_exclude: Array[RID] = []
|
||||
var _collision_bodies: Dictionary = {}
|
||||
var _collision_lengths: Dictionary = {}
|
||||
var _collision_visuals: Dictionary = {}
|
||||
var _reaction_bones: Dictionary = {}
|
||||
var _impact_reactions: Dictionary = {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Director / navigation state (Phase 3a)
|
||||
@@ -479,6 +530,8 @@ func _ready() -> void:
|
||||
|
||||
_resolve_bend_modifications()
|
||||
_resolve_limb_bones()
|
||||
_resolve_reaction_bones()
|
||||
_build_collision_bodies()
|
||||
|
||||
# Enable the modification stack (IK solves only at runtime).
|
||||
if _skeleton != null:
|
||||
@@ -527,6 +580,8 @@ func _physics_process(delta: float) -> void:
|
||||
_update_speech(delta)
|
||||
_update_runner(delta)
|
||||
_pin_mirrored_head_rotation()
|
||||
_apply_impact_reactions(delta)
|
||||
_sync_collision_bodies()
|
||||
|
||||
|
||||
|
||||
@@ -676,25 +731,30 @@ func request_recovery() -> void:
|
||||
## tween. Used by the sandbox stage so a stickman "reappears" at its starting
|
||||
## position/state on return to EDIT (instead of animating the recovery glide).
|
||||
func snap_to_standing() -> void:
|
||||
if state == RigState.ANIMATED:
|
||||
return
|
||||
var previous_state := state
|
||||
if state == RigState.RAGDOLL:
|
||||
_destroy_ragdoll()
|
||||
else:
|
||||
elif state == RigState.RECOVERING:
|
||||
_cancel_recovery()
|
||||
_clear_impact_reactions(true)
|
||||
_restore_standing_markers()
|
||||
# Re-show the kinematic puppet and re-enable IK.
|
||||
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
|
||||
_skeleton.modification_stack.enabled = true
|
||||
_set_limb_mods_enabled(true)
|
||||
_restore_head_lookat_state()
|
||||
if previous_state == RigState.ANIMATED:
|
||||
_apply_head_lookat_mirror_mode()
|
||||
else:
|
||||
_restore_head_lookat_state()
|
||||
_recovery_bones.clear()
|
||||
_set_collision_bodies_enabled(true)
|
||||
|
||||
if _body_container != null and is_instance_valid(_body_container):
|
||||
_body_container.visible = true
|
||||
_body_container.modulate.a = 1.0
|
||||
state = RigState.ANIMATED
|
||||
state_changed.emit(int(state))
|
||||
if previous_state != RigState.ANIMATED:
|
||||
state_changed.emit(int(state))
|
||||
|
||||
|
||||
## Writes STAND_POSE onto the 6 IK-target markers (no tween, no state change).
|
||||
@@ -776,6 +836,216 @@ func _resolve_limb_bones() -> void:
|
||||
continue
|
||||
_limb_bones[joint] = { "upper": upper, "lower": lower }
|
||||
|
||||
func _resolve_reaction_bones() -> void:
|
||||
_reaction_bones.clear()
|
||||
if _skeleton == null:
|
||||
return
|
||||
for key: String in COLLISION_PARTS:
|
||||
var definition: Dictionary = COLLISION_PARTS[key]
|
||||
var bone := _skeleton.get_node_or_null(NodePath(str(definition["bone"]))) as Bone2D
|
||||
if bone == null or not is_instance_valid(bone):
|
||||
push_warning("StickmanRig: missing reaction bone '%s'." % str(definition["bone"]))
|
||||
continue
|
||||
var joint_name := "Torso" if str(definition["visual"]) == "Body" else str(definition["visual"])
|
||||
_reaction_bones[joint_name] = bone
|
||||
|
||||
|
||||
func _build_collision_bodies() -> void:
|
||||
_collision_bodies.clear()
|
||||
_collision_lengths.clear()
|
||||
_collision_visuals.clear()
|
||||
if _body_container == null or _skeleton == null:
|
||||
return
|
||||
for key: String in COLLISION_PARTS:
|
||||
var definition: Dictionary = COLLISION_PARTS[key]
|
||||
var visual := _body_container.get_node_or_null(NodePath(str(definition["visual"]))) as Node2D
|
||||
var bone := _skeleton.get_node_or_null(NodePath(str(definition["bone"]))) as Bone2D
|
||||
if visual == null or bone == null:
|
||||
push_warning("StickmanRig: missing collision visual or bone for '%s'." % key)
|
||||
continue
|
||||
var proxy := AnimatableBody2D.new()
|
||||
proxy.name = "CollisionBody"
|
||||
proxy.collision_layer = COLLISION_LAYER
|
||||
proxy.collision_mask = COLLISION_LAYER
|
||||
# RemoteTransform2D moves the Body/* visual after the physics body's own
|
||||
# synchronization step. Letting a child AnimatableBody2D synchronize
|
||||
# itself makes Godot counter-transform it, leaving capsules upright at
|
||||
# stale world positions. Keep the proxy top-level and copy the rendered
|
||||
# visual transform explicitly once per physics frame instead.
|
||||
proxy.sync_to_physics = false
|
||||
proxy.set_meta("stickman_rig", self)
|
||||
proxy.set_meta("part_name", key)
|
||||
var shape_node := CollisionShape2D.new()
|
||||
shape_node.name = "CollisionShape2D"
|
||||
if str(definition["shape"]) == "circle":
|
||||
var circle := CircleShape2D.new()
|
||||
var bounds := _mounted_visual_bounds_local(visual)
|
||||
if bool(bounds["valid"]):
|
||||
var rect := bounds["rect"] as Rect2
|
||||
shape_node.position = rect.get_center()
|
||||
circle.radius = maxf(float(definition["radius"]), maxf(rect.size.x, rect.size.y) * 0.5)
|
||||
else:
|
||||
shape_node.position = HEAD_COLLISION_FALLBACK_CENTER
|
||||
circle.radius = float(definition["radius"])
|
||||
shape_node.shape = circle
|
||||
else:
|
||||
var length: float = absf(_head_bone.position.y) if key == "torso" and _head_bone != null else bone.length
|
||||
_collision_lengths[key] = length
|
||||
var capsule := CapsuleShape2D.new()
|
||||
capsule.height = length
|
||||
capsule.radius = float(definition["radius"])
|
||||
shape_node.shape = capsule
|
||||
shape_node.position = Vector2(0.0, length * 0.5)
|
||||
proxy.add_child(shape_node)
|
||||
visual.add_child(proxy)
|
||||
proxy.top_level = true
|
||||
proxy.global_transform = visual.global_transform
|
||||
_collision_bodies[key] = proxy
|
||||
_collision_visuals[key] = visual
|
||||
|
||||
|
||||
func _sync_collision_bodies() -> void:
|
||||
for key: String in _collision_bodies:
|
||||
var proxy := _collision_bodies.get(key) as AnimatableBody2D
|
||||
var visual := _collision_visuals.get(key) as Node2D
|
||||
if proxy == null or visual == null or not is_instance_valid(proxy) or not is_instance_valid(visual):
|
||||
continue
|
||||
proxy.global_transform = visual.global_transform
|
||||
|
||||
|
||||
func _mounted_visual_bounds_local(part: Node2D) -> Dictionary:
|
||||
var points: Array[Vector2] = []
|
||||
var nodes: Array[Node] = [part]
|
||||
while not nodes.is_empty():
|
||||
var child: Node = nodes.pop_back()
|
||||
if child is Line2D:
|
||||
for point: Vector2 in (child as Line2D).points:
|
||||
points.append(part.to_local((child as Node2D).to_global(point)))
|
||||
elif child is Polygon2D:
|
||||
for point: Vector2 in (child as Polygon2D).polygon:
|
||||
points.append(part.to_local((child as Node2D).to_global(point)))
|
||||
for grandchild: Node in child.get_children():
|
||||
nodes.append(grandchild)
|
||||
if points.is_empty():
|
||||
return {"valid": false, "rect": Rect2()}
|
||||
var rect := Rect2(points[0], Vector2.ZERO)
|
||||
for point: Vector2 in points:
|
||||
rect = rect.expand(point)
|
||||
return {"valid": true, "rect": rect}
|
||||
|
||||
|
||||
func set_collision_bodies_enabled(enabled: bool) -> void:
|
||||
_set_collision_bodies_enabled(enabled)
|
||||
|
||||
|
||||
func _set_collision_bodies_enabled(enabled: bool) -> void:
|
||||
var layer := COLLISION_LAYER if enabled else 0
|
||||
for proxy: AnimatableBody2D in _collision_bodies.values():
|
||||
if proxy != null and is_instance_valid(proxy):
|
||||
proxy.collision_layer = layer
|
||||
proxy.collision_mask = layer
|
||||
|
||||
|
||||
func apply_collision_reaction(
|
||||
part_name: String,
|
||||
impact_direction: Vector2,
|
||||
impact_speed: float,
|
||||
impact_position: Vector2 = Vector2.INF
|
||||
) -> void:
|
||||
if state != RigState.ANIMATED or impact_direction.is_zero_approx() or impact_speed <= 0.0:
|
||||
return
|
||||
for target: Dictionary in REACTION_TARGETS.get(part_name, []):
|
||||
var joint := str(target["joint"])
|
||||
var bone := _reaction_bones.get(joint) as Bone2D
|
||||
if bone == null or not is_instance_valid(bone):
|
||||
continue
|
||||
var direction := _collision_contact_direction(part_name, impact_position, impact_direction)
|
||||
if is_zero_approx(direction):
|
||||
var local_dir := bone.global_transform.affine_inverse().basis_xform(impact_direction).normalized()
|
||||
direction = signf(local_dir.x) if absf(local_dir.x) > REACTION_LOCAL_X_EPSILON else 0.0
|
||||
if is_zero_approx(direction):
|
||||
direction = -1.0 if joint.begins_with("Left") else 1.0
|
||||
var angle := direction * minf(
|
||||
REACTION_MAX_ANGLE,
|
||||
REACTION_BASE_ANGLE * impact_speed / REACTION_REFERENCE_SPEED
|
||||
) * float(target["weight"])
|
||||
var old: Dictionary = _impact_reactions.get(joint, {})
|
||||
if old.is_empty():
|
||||
_impact_reactions[joint] = {
|
||||
"angle": angle,
|
||||
"strength": 1.0,
|
||||
"decay": float(target["decay"]),
|
||||
"base_rotation": bone.rotation,
|
||||
}
|
||||
else:
|
||||
if absf(angle) >= absf(float(old.get("angle", 0.0))):
|
||||
old["angle"] = angle
|
||||
old["decay"] = float(target["decay"])
|
||||
old["strength"] = 1.0
|
||||
_impact_reactions[joint] = old
|
||||
|
||||
|
||||
func _collision_contact_direction(
|
||||
part_name: String,
|
||||
impact_position: Vector2,
|
||||
impact_direction: Vector2
|
||||
) -> float:
|
||||
if not impact_position.is_finite():
|
||||
return 0.0
|
||||
var proxy := _collision_bodies.get(part_name) as AnimatableBody2D
|
||||
if proxy == null or not is_instance_valid(proxy):
|
||||
return 0.0
|
||||
var shape_node := proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
|
||||
if shape_node == null:
|
||||
return 0.0
|
||||
# Proxies use the hanging frame, so local X identifies which side was hit.
|
||||
# A downward hit on the upper-left must rotate opposite to an upward hit on
|
||||
# the lower-left. Combining the side with the force's local Y component
|
||||
# supplies that top/bottom inversion and remains mirror-correct.
|
||||
var local_hit := proxy.to_local(impact_position)
|
||||
var side := local_hit.x - shape_node.position.x
|
||||
if absf(side) <= REACTION_LOCAL_X_EPSILON:
|
||||
return 0.0
|
||||
var local_force := proxy.global_transform.affine_inverse().basis_xform(impact_direction).normalized()
|
||||
if absf(local_force.y) > REACTION_LOCAL_X_EPSILON:
|
||||
return signf(side * local_force.y)
|
||||
# A nearly horizontal impact has no useful top/bottom component; retain the
|
||||
# established side-only response for that case.
|
||||
return signf(side)
|
||||
|
||||
|
||||
func _clear_impact_reactions(restore_bones: bool = false) -> void:
|
||||
if restore_bones:
|
||||
for joint: String in _impact_reactions:
|
||||
var reaction: Dictionary = _impact_reactions[joint]
|
||||
var bone := _reaction_bones.get(joint) as Bone2D
|
||||
if bone != null and is_instance_valid(bone) and reaction.has("base_rotation"):
|
||||
bone.rotation = float(reaction["base_rotation"])
|
||||
_impact_reactions.clear()
|
||||
|
||||
|
||||
func _apply_impact_reactions(delta: float) -> void:
|
||||
if state != RigState.ANIMATED:
|
||||
_clear_impact_reactions()
|
||||
return
|
||||
for joint: String in _impact_reactions.keys().duplicate():
|
||||
var reaction: Dictionary = _impact_reactions[joint]
|
||||
var bone := _reaction_bones.get(joint) as Bone2D
|
||||
if bone == null or not is_instance_valid(bone):
|
||||
_impact_reactions.erase(joint)
|
||||
continue
|
||||
# Always pose relative to the rotation captured at impact. Adding the
|
||||
# whole offset every frame accumulates several complete revolutions during
|
||||
# a 0.5 s head flinch and bypasses the intended angular cap.
|
||||
var base_rotation := float(reaction.get("base_rotation", bone.rotation))
|
||||
bone.rotation = base_rotation + float(reaction["angle"]) * float(reaction["strength"])
|
||||
reaction["strength"] = float(reaction["strength"]) - delta / maxf(float(reaction["decay"]), 0.001)
|
||||
if float(reaction["strength"]) <= 0.0:
|
||||
bone.rotation = base_rotation
|
||||
_impact_reactions.erase(joint)
|
||||
else:
|
||||
_impact_reactions[joint] = reaction
|
||||
|
||||
|
||||
## Writes the four per-joint bend vars from PROFILE_FLAGS (through their setters,
|
||||
## so the live mods stay in sync), reorders Body/* children, then emits
|
||||
@@ -923,6 +1193,8 @@ func _enter_ragdoll() -> void:
|
||||
push_warning("StickmanRig: cannot enter ragdoll; missing rig nodes.")
|
||||
return
|
||||
_capture_pre_ragdoll_state()
|
||||
_impact_reactions.clear()
|
||||
_set_collision_bodies_enabled(false)
|
||||
_cancel_walking()
|
||||
_ragdoll_at_rest = false
|
||||
# Instant handoff: stop the player without resetting it (keep_state) and
|
||||
@@ -1032,6 +1304,7 @@ func _start_recovery() -> void:
|
||||
state = RigState.RECOVERING
|
||||
state_changed.emit(int(state))
|
||||
_snap_skeleton_to_pose()
|
||||
_set_collision_bodies_enabled(true)
|
||||
_play_stand_up()
|
||||
|
||||
|
||||
@@ -1473,6 +1746,7 @@ func _on_stand_up_finished() -> void:
|
||||
if _body_container != null and is_instance_valid(_body_container):
|
||||
_body_container.visible = true
|
||||
_body_container.modulate.a = 1.0
|
||||
_set_collision_bodies_enabled(true)
|
||||
state = RigState.ANIMATED
|
||||
state_changed.emit(int(state))
|
||||
|
||||
@@ -1513,6 +1787,9 @@ func _build_ragdoll_body(entry: Dictionary) -> void:
|
||||
body.freeze = false
|
||||
body.collision_layer = 1
|
||||
body.collision_mask = 1
|
||||
body.set_meta("stickman_rig", self)
|
||||
body.set_meta("part_name", key)
|
||||
body.set_meta("stickman_ragdoll_body", true)
|
||||
|
||||
var shape := CollisionShape2D.new()
|
||||
shape.name = "CollisionShape2D"
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
# Phase 4.5a — Collision Bodies, Physics Events, and Hit Reactions
|
||||
|
||||
Source plan: `plans/PHASE_4.5_COLLISION_BODIES.md`
|
||||
|
||||
## Scope and verified corrections
|
||||
|
||||
Implement the feature in `scripts/stickman_rig.gd`, `scripts/prop_block.gd`, and
|
||||
`scripts/sandbox_stage.gd`. Add a focused headless regression script, but no scene, `.stk`,
|
||||
registry, rule-payload, layout, or theme changes.
|
||||
|
||||
The implementation must correct these plan/code mismatches:
|
||||
|
||||
- `Body/*` mounted geometry is in the adapter's hanging frame: attachment at local origin,
|
||||
segment along local `+Y`. Animated capsules therefore need no `-PI / 2` rotation. The
|
||||
existing ragdoll capsules do rotate because their rigid-body frame is deliberately `+X`.
|
||||
- `Body/Head` is driven from the neck/chin attachment, not the visible head center. The adapter
|
||||
maps mounted head geometry above that origin (normally centered near local `(0, -72)`). A
|
||||
circle at `(0, 0)` misses much of the face. Derive its local center from mounted geometry;
|
||||
use `(0, -72)` only when no geometry is available.
|
||||
- The torso's fitted length is not `Torso.length`. The adapter expresses it as the Head bone's
|
||||
local Y displacement. Use `absf(_head_bone.position.y)`; the eight limb lengths come from the
|
||||
corresponding `Bone2D.length` values.
|
||||
- Disabling proxies on ragdoll entry and re-enabling them only after stand-up contradicts the
|
||||
requested RECOVERING behavior. Re-enable after the recovery snap and before its tween, and
|
||||
also restore them in `snap_to_standing()`.
|
||||
- Existing `collided` rules are authored as `source = StickmanRig`, `target = PropBlock`.
|
||||
Preserve that ordering. Prop-originated non-stickman contacts keep the existing
|
||||
`source = prop`, `target = other` ordering; do not make rule matching bidirectional.
|
||||
- One pair boolean is insufficient when a prop touches two part bodies. Track the active
|
||||
collision-body instance IDs per `(rig, prop)` pair. Only an empty-to-nonempty transition fires
|
||||
the rule, while each newly entered part may independently react.
|
||||
- In Godot 2D, despite the method names, `get_contact_local_position()` is already a world-space
|
||||
point and `get_contact_local_normal()` is reported in the physics/world frame. Do not apply
|
||||
`global_transform` again. Normalize and orient the normal into the documented signal contract
|
||||
below.
|
||||
|
||||
## Nodes and metadata
|
||||
|
||||
At `StickmanRig._ready()`, after the skeleton/body references and limb bones are resolved, build
|
||||
one proxy under each existing visual part node:
|
||||
|
||||
```text
|
||||
Body/<PartVisual> (existing Node2D/Line2D, RemoteTransform2D target)
|
||||
└── CollisionBody: AnimatableBody2D
|
||||
└── CollisionShape2D
|
||||
```
|
||||
|
||||
`CollisionBody` contract:
|
||||
|
||||
- `collision_layer = 1`, `collision_mask = 1`, `sync_to_physics = true`.
|
||||
- metadata `"stickman_rig" = self` and `"part_name" = <snake_case part key>`.
|
||||
- stored in `_collision_bodies: Dictionary` as `{String: AnimatableBody2D}`.
|
||||
- missing visual/bone nodes warn with the `StickmanRig:` prefix and skip only that part.
|
||||
|
||||
Use a single constant definition table so paths and shape families cannot drift:
|
||||
|
||||
```gdscript
|
||||
const COLLISION_PARTS: Dictionary = {
|
||||
"head": {"visual": "Head", "bone": "Torso/Head", "shape": "circle", "radius": HEAD_COLLISION_RADIUS},
|
||||
"torso": {"visual": "Body", "bone": "Torso", "shape": "capsule", "radius": TORSO_COLLISION_RADIUS},
|
||||
"left_upper_arm": {"visual": "LeftUpperArm", "bone": "Torso/LeftUpperArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
||||
"left_lower_arm": {"visual": "LeftLowerArm", "bone": "Torso/LeftUpperArm/LeftLowerArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
||||
"right_upper_arm": {"visual": "RightUpperArm", "bone": "Torso/RightUpperArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
||||
"right_lower_arm": {"visual": "RightLowerArm", "bone": "Torso/RightUpperArm/RightLowerArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
||||
"left_upper_leg": {"visual": "LeftUpperLeg", "bone": "Torso/LeftUpperLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
||||
"left_lower_leg": {"visual": "LeftLowerLeg", "bone": "Torso/LeftUpperLeg/LeftLowerLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
||||
"right_upper_leg": {"visual": "RightUpperLeg", "bone": "Torso/RightUpperLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
||||
"right_lower_leg": {"visual": "RightLowerLeg", "bone": "Torso/RightUpperLeg/RightLowerLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
||||
}
|
||||
```
|
||||
|
||||
Constants:
|
||||
|
||||
```gdscript
|
||||
const HEAD_COLLISION_RADIUS := 90.0
|
||||
const TORSO_COLLISION_RADIUS := 20.0
|
||||
const ARM_COLLISION_RADIUS := 12.0
|
||||
const LEG_COLLISION_RADIUS := 14.0
|
||||
const HEAD_COLLISION_FALLBACK_CENTER := Vector2(0.0, -72.0)
|
||||
const COLLISION_LAYER := 1
|
||||
```
|
||||
|
||||
Capsules use `height = fitted_length`, `radius = table radius`, `position = Vector2(0,
|
||||
fitted_length * 0.5)`, `rotation = 0`. Store fitted values in `_collision_lengths: Dictionary`
|
||||
for torso and the eight limbs. `CapsuleShape2D.height` is the total end-to-end height, so this
|
||||
centering spans the attachment-to-far-end interval.
|
||||
|
||||
For the head only, `_mounted_visual_bounds_local(part: Node2D) -> Dictionary` recursively visits
|
||||
the part and its `Line2D`/`Polygon2D` descendants, converts every geometry point back into
|
||||
`part` local space, and returns `{"valid": bool, "rect": Rect2}`. Put the fixed-radius circle at
|
||||
the valid rect center or `HEAD_COLLISION_FALLBACK_CENTER`. Geometry affects the center only; the
|
||||
90 px approximation remains intentionally fixed.
|
||||
|
||||
When procedural ragdoll bodies are built, also set `"stickman_rig" = self`,
|
||||
`"part_name" = entry["key"]`, and `"stickman_ragdoll_body" = true`. This lets the stage reject
|
||||
ragdoll contacts explicitly instead of accidentally dispatching a wildcard prop event.
|
||||
|
||||
## `StickmanRig` API and state transitions
|
||||
|
||||
Add:
|
||||
|
||||
```gdscript
|
||||
func set_collision_bodies_enabled(enabled: bool) -> void
|
||||
func _set_collision_bodies_enabled(enabled: bool) -> void
|
||||
func apply_collision_reaction(part_name: String, impact_direction: Vector2, impact_speed: float) -> void
|
||||
func _apply_impact_reactions(delta: float) -> void
|
||||
func _mounted_visual_bounds_local(part: Node2D) -> Dictionary
|
||||
```
|
||||
|
||||
The public enable method is a narrow stage/ghost hook and delegates to the private helper. The
|
||||
helper sets both layer and mask to `COLLISION_LAYER` or `0`; it does not free/rebuild proxies.
|
||||
`SandboxStage._configure_ghost_collision()` must call the public method with `false` for a
|
||||
stickman ghost, otherwise the now-physical placement preview blocks props.
|
||||
|
||||
State order:
|
||||
|
||||
1. `_ready()`: build proxies and enable them for initial `ANIMATED` state.
|
||||
2. `_enter_ragdoll()`: clear `_impact_reactions`, disable proxies before `_build_ragdoll()`, then
|
||||
retain the existing capture/build/hide/IK-disable/state-signal order.
|
||||
3. `_start_recovery()`: retain capture/destroy/reanchor/state/snap order; after
|
||||
`_snap_skeleton_to_pose()` has re-shown and rearmed the puppet, enable proxies, then start
|
||||
`_play_stand_up()`. They remain enabled throughout `RECOVERING`.
|
||||
4. `_on_stand_up_finished()`: idempotently enable proxies before publishing `ANIMATED`.
|
||||
5. `snap_to_standing()`: after restoring markers/body/IK, clear reactions and enable proxies
|
||||
before publishing `ANIMATED`.
|
||||
6. `RECOVERING -> RAGDOLL`: the existing cancellation calls `_enter_ragdoll()`, which disables
|
||||
the live proxies again before creating the new ragdoll.
|
||||
|
||||
## Reaction contract
|
||||
|
||||
Resolve `_reaction_bones: Dictionary` in `_ready()` under stable keys `Head`, `Torso`,
|
||||
`LeftUpperArm`, `LeftLowerArm`, `RightUpperArm`, `RightLowerArm`, `LeftUpperLeg`,
|
||||
`LeftLowerLeg`, `RightUpperLeg`, and `RightLowerLeg`. These keys name actual bone nodes; do not
|
||||
confuse them with the existing bend-group keys (`LeftArm`, `RightArm`, etc.).
|
||||
|
||||
Constants and mapping:
|
||||
|
||||
```gdscript
|
||||
const REACTION_BASE_ANGLE := 0.35
|
||||
const REACTION_REFERENCE_SPEED := 400.0
|
||||
const REACTION_MAX_ANGLE := 0.6
|
||||
const REACTION_LOCAL_X_EPSILON := 0.05
|
||||
const REACTION_TARGETS: Dictionary = {
|
||||
"head": [{"joint": "Head", "weight": 1.0, "decay": 0.50}],
|
||||
"torso": [{"joint": "Torso", "weight": 0.5, "decay": 0.40}],
|
||||
"left_upper_arm": [{"joint": "LeftUpperArm", "weight": 1.0, "decay": 0.40}],
|
||||
"left_lower_arm": [{"joint": "LeftLowerArm", "weight": 1.0, "decay": 0.35}, {"joint": "LeftUpperArm", "weight": 0.4, "decay": 0.25}],
|
||||
"right_upper_arm": [{"joint": "RightUpperArm", "weight": 1.0, "decay": 0.40}],
|
||||
"right_lower_arm": [{"joint": "RightLowerArm", "weight": 1.0, "decay": 0.35}, {"joint": "RightUpperArm", "weight": 0.4, "decay": 0.25}],
|
||||
"left_upper_leg": [{"joint": "LeftUpperLeg", "weight": 1.0, "decay": 0.40}],
|
||||
"left_lower_leg": [{"joint": "LeftLowerLeg", "weight": 1.0, "decay": 0.35}, {"joint": "LeftUpperLeg", "weight": 0.4, "decay": 0.25}],
|
||||
"right_upper_leg": [{"joint": "RightUpperLeg", "weight": 1.0, "decay": 0.40}],
|
||||
"right_lower_leg": [{"joint": "RightLowerLeg", "weight": 1.0, "decay": 0.35}, {"joint": "RightUpperLeg", "weight": 0.4, "decay": 0.25}],
|
||||
}
|
||||
```
|
||||
|
||||
`apply_collision_reaction()` is a no-op unless `state == RigState.ANIMATED`, the part/joint is
|
||||
known, `impact_direction` is nonzero, and `impact_speed > 0`. For each target:
|
||||
|
||||
1. Convert the world direction without translation using
|
||||
`bone.global_transform.affine_inverse().basis_xform(impact_direction).normalized()`. The
|
||||
affine inverse keeps the LEFT root reflection correct.
|
||||
2. Direction is `signf(local_dir.x)`. If `absf(local_dir.x) <= EPSILON`, use `-1` for left
|
||||
joints, `+1` for right joints, and `+1` for Head/Torso. Keep this as a single easily tunable
|
||||
branch; editor verification must confirm the visual sign.
|
||||
3. Candidate angle is `direction * minf(REACTION_MAX_ANGLE,
|
||||
REACTION_BASE_ANGLE * impact_speed / REACTION_REFERENCE_SPEED) * weight`.
|
||||
4. `_impact_reactions[joint]` stores `{"angle": float, "strength": 1.0, "decay": float}`.
|
||||
Compare angles by absolute magnitude (never signed `max`). Replace angle/decay when the new
|
||||
magnitude is greater or equal; otherwise retain the stronger existing angle. Every accepted
|
||||
hit resets strength to `1.0`.
|
||||
|
||||
Call `_apply_impact_reactions(delta)` last in `_physics_process()`, immediately after
|
||||
`_pin_mirrored_head_rotation()`. Iterate a duplicated key list so entries may be erased. For an
|
||||
ANIMATED, valid bone, apply `bone.rotation += angle * strength`, then reduce strength by
|
||||
`delta / maxf(decay, 0.001)` and erase at zero. Clear/skip outside ANIMATED. This ordering lets a
|
||||
LEFT-facing head pin establish its baseline before the flinch and otherwise preserves the
|
||||
current post-IK strategy. If runtime validation shows the modification stack overwrites this
|
||||
slot, moving both the existing head pin and reaction pass to a later internal callback requires
|
||||
separate approval; do not silently change skeleton timing.
|
||||
|
||||
## `PropBlock` physics-contact contract
|
||||
|
||||
Signals:
|
||||
|
||||
```gdscript
|
||||
signal collided(other: Node, contact_normal: Vector2, contact_position: Vector2)
|
||||
signal separated(other: Node)
|
||||
```
|
||||
|
||||
In `_ready()`, connect both inherited `body_entered` and `body_exited`. Increase
|
||||
`max_contacts_reported` from 8 to `MAX_CONTACTS_REPORTED := 32` so a contact involving terrain
|
||||
and several of the ten proxy bodies still has a useful direct-state entry.
|
||||
|
||||
`_on_body_entered(body: Node) -> void`:
|
||||
|
||||
1. Build a fallback direction from prop center toward `(body as Node2D).global_position` and a
|
||||
fallback position of `global_position`.
|
||||
2. Query `PhysicsServer2D.body_get_direct_state(get_rid())`. Match with
|
||||
`state.get_contact_collider_id(i) == body.get_instance_id()` (object equality may be used as
|
||||
a second guard).
|
||||
3. Read and normalize `state.get_contact_local_normal(i)` and read
|
||||
`state.get_contact_local_position(i)` directly. Both are already world-space in 2D; do not
|
||||
transform either value.
|
||||
4. Orient a nonzero normal so `normal.dot(body_center - prop_center) >= 0`; the emitted contract
|
||||
is therefore **world-space push direction from this prop toward the other body**, independent
|
||||
of the engine's local-contact naming/sign. If centers coincide, retain the reported normal.
|
||||
5. If no matching contact exists, emit the center-direction fallback (possibly zero) and
|
||||
fallback position. The rig safely ignores a zero direction.
|
||||
6. Emit `collided(body, normal, position)` once for the inherited entry edge.
|
||||
|
||||
`_on_body_exited(body: Node) -> void` only emits `separated(body)`.
|
||||
|
||||
## `SandboxStage` wiring and rule preservation
|
||||
|
||||
Keep the existing per-prop connection point in `_finalize_placed_node()`; no prop registry is
|
||||
needed:
|
||||
|
||||
```gdscript
|
||||
prop.collided.connect(_on_prop_collided.bind(prop))
|
||||
prop.separated.connect(_on_prop_separated.bind(prop))
|
||||
```
|
||||
|
||||
Bound-call signatures are therefore:
|
||||
|
||||
```gdscript
|
||||
func _on_prop_collided(other: Node, normal: Vector2, position: Vector2, prop: PropBlock) -> void
|
||||
func _on_prop_separated(other: Node, prop: PropBlock) -> void
|
||||
func _on_rig_state_changed(new_state: int, rig: StickmanRig) -> void
|
||||
func _purge_collision_pairs_for_ids(ids: Array[int]) -> void
|
||||
```
|
||||
|
||||
Also connect `rig.state_changed` in `_finalize_placed_node()`. On `RAGDOLL`, purge every active
|
||||
pair for that rig. In `delete_selected()`, purge selected rig/prop IDs before `queue_free()`.
|
||||
Existing PLAY/EDIT transition clears remain in place. `_collision_pairs` is repurposed from
|
||||
`{pair_key: bool}` to:
|
||||
|
||||
```gdscript
|
||||
{
|
||||
"<rig_id>:<prop_id>": {
|
||||
"rig_id": int,
|
||||
"prop_id": int,
|
||||
"bodies": { <collision_body_instance_id>: true },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Delete `_update_stickman_prop_collision()` and only its call from
|
||||
`SandboxStage._physics_process()`. Leave `_update_area_entry()` and all other event plumbing
|
||||
unchanged.
|
||||
|
||||
Collision dispatch:
|
||||
|
||||
- If `other` has `stickman_ragdoll_body == true`, return without a rule or reaction.
|
||||
- If `other` has valid `stickman_rig` and `part_name` metadata, cast/validate the rig. If its
|
||||
state is RAGDOLL, return. Add `other.get_instance_id()` to the pair's body set. If it was new,
|
||||
call `rig.apply_collision_reaction(part, normal, prop.linear_velocity.length())`, including
|
||||
when another part is already active. Only when the set was previously empty call the existing
|
||||
event engine with:
|
||||
|
||||
```gdscript
|
||||
{"type": "collided", "source": rig, "target": prop,
|
||||
"position": position, "action": {}}
|
||||
```
|
||||
|
||||
This preserves authored stickman->prop rule IDs and existing ordered/all-matches rule
|
||||
execution through `_handle_event()`; do not scan `_event_rules` in the new handler.
|
||||
- For every other body, preserve the existing path exactly, except use the contact position:
|
||||
`{"type":"collided", "source":prop, "target":other, "position":position, "action":{}}`.
|
||||
This includes prop-prop ordering and its current duplicate/wildcard characteristics.
|
||||
- On separation from a proxy, remove only that body ID. Erase the pair entry only after its body
|
||||
set becomes empty. Non-stickman separations require no bookkeeping.
|
||||
|
||||
No new mode gate is added: the current PropBlock signal path is not PLAY-gated. Frozen EDIT-mode
|
||||
props normally produce no impact, while the existing mode-transition clears prevent stale
|
||||
stage bookkeeping. Changing physics-event mode semantics is outside this phase.
|
||||
|
||||
## Signal flow and application order
|
||||
|
||||
```text
|
||||
RemoteTransform2D moves Body/<PartVisual>/CollisionBody
|
||||
-> physics contact against PropBlock
|
||||
-> PropBlock.body_entered
|
||||
-> PropBlock.collided(other, world_normal, world_position)
|
||||
-> SandboxStage._on_prop_collided(..., prop)
|
||||
-> new rig/prop pair edge -> existing _handle_event(rig -> prop)
|
||||
-> every new part edge -> StickmanRig.apply_collision_reaction(...)
|
||||
-> next StickmanRig physics tick
|
||||
existing momentum/rest/walk/speech/runner/head-pin order
|
||||
-> _apply_impact_reactions(delta) last
|
||||
```
|
||||
|
||||
## Cleanup and failure behavior
|
||||
|
||||
- Proxy nodes free with the rig; stage pair entries are proactively purged on deletion and
|
||||
ragdoll entry, and defensively tolerate late exit signals.
|
||||
- Deleting a prop retains existing authored-state and rule cleanup, plus pair purge.
|
||||
- Invalid metadata, stale objects, missing nodes, missing direct state, and absent contact rows
|
||||
are null-guarded; no crash and no rule with a fake rig.
|
||||
- A prop touching two parts fires one rule until all parts separate, but each newly entered part
|
||||
can flinch. Re-entering one part while another remains touching can flinch again without
|
||||
re-firing the pair rule.
|
||||
- Ragdoll bodies continue normal physical collision but never dispatch a stickman collision
|
||||
rule or animated reaction.
|
||||
|
||||
## Layout/theme
|
||||
|
||||
None. Collision nodes are runtime-only and invisible. No editor controls, labels, menus, scene
|
||||
nodes, or theme tokens are added.
|
||||
|
||||
## Validation
|
||||
|
||||
Add `tests/test_phase45_collision_bodies.gd`, following existing stage/rig headless suites.
|
||||
Required deterministic checks:
|
||||
|
||||
1. A spawned rig has ten `CollisionBody` proxies with correct metadata, layer/mask, shape type,
|
||||
radii, hanging-frame capsule orientation/centering, and fitted lengths.
|
||||
2. Head collision center equals mounted head geometry's local bbox center and is visibly above
|
||||
the driven origin; empty geometry uses `(0, -72)`.
|
||||
3. LEFT mirroring leaves proxy count/shape sizes intact and yields no new normal-conversion
|
||||
error; ghost rigs have all proxies disabled.
|
||||
4. RAGDOLL disables proxies and clears reactions; `_start_recovery()` re-enables after snap;
|
||||
`snap_to_standing()` restores them; RECOVERING->RAGDOLL disables again.
|
||||
5. `PropBlock` reports entered/exited payloads, direct-state position is not double-transformed,
|
||||
emitted normal is normalized and points prop->other, and fallback is safe.
|
||||
6. Stickman contact emits an internal event with source rig/target prop/contact position. A
|
||||
matching existing rule executes through `_handle_event`; reversed IDs do not match.
|
||||
7. Prop-prop contact preserves source prop/target other.
|
||||
8. Two part entries for one pair fire one rule but two reaction dispatches. First exit retains
|
||||
the pair; last exit erases it; full separation then re-entry fires the rule again.
|
||||
9. Ragdoll-body contact fires no rule/reaction. RECOVERING contact physically resolves and may
|
||||
fire the rule, but `apply_collision_reaction()` remains a no-op.
|
||||
10. Reaction magnitude scales with speed, caps at `0.6`, secondary weight is `0.4`, stronger
|
||||
signed merge uses absolute magnitude, and decay erases state. Manual editor validation must
|
||||
confirm the sign is visually away from left/right impacts and that animation catches up.
|
||||
11. Existing `entered_area`, arrival, action-finished, speech-finished, reactive queue,
|
||||
deletion-cleanup, and ragdoll/recovery tests remain green; exercise 5 rigs + 10 props for
|
||||
obvious errors or unacceptable frame loss.
|
||||
|
||||
Developer validation order after approval:
|
||||
|
||||
1. Run `--check-only` for the three modified scripts and the new test script with Godot 4.7.1.
|
||||
2. Run the new headless suite and the directly relevant Phase 4c/ragdoll suites.
|
||||
3. Hand off for Godot Editor testing; do not update README/docs until the user confirms the
|
||||
feature is working.
|
||||
|
||||
## Approval decisions / trade-offs
|
||||
|
||||
Approval authorizes these plan corrections: geometry-derived head center with fixed 90 px
|
||||
radius; torso length from Head-bone displacement; RECOVERING re-enable before the tween;
|
||||
source-rig/target-prop rule ordering; multi-body pair sets; world-space direct-contact values;
|
||||
and a public collision-enable hook solely for ghost setup. It also accepts approximate
|
||||
capsule/circle proxies rather than per-shape polygons and keeps reaction timing in the current
|
||||
post-head-pin physics slot.
|
||||
|
||||
After editor verification, append these deferred trade-offs to
|
||||
`docs/tech_debt_and_optimizations.md`: fixed proxy radii do not follow arbitrary mounted widths;
|
||||
the head radius is fixed although its center follows geometry; signal-time direct state may use
|
||||
the documented fallback on rare timing edges; contact speed is total prop speed rather than
|
||||
normal relative speed/impulse; and the post-IK reaction slot remains engine-order sensitive.
|
||||
@@ -0,0 +1,376 @@
|
||||
extends SceneTree
|
||||
|
||||
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var stage := STAGE_SCENE.instantiate() as Node2D
|
||||
root.add_child(stage)
|
||||
await process_frame
|
||||
|
||||
var rig := _spawn_rig(stage, Vector2.ZERO)
|
||||
if rig == null:
|
||||
_finish()
|
||||
return
|
||||
|
||||
_test_collision_proxy_geometry(rig)
|
||||
await _test_collision_proxy_physics_alignment(rig)
|
||||
_test_reactions(rig)
|
||||
await _test_direct_restores_animated_pose(stage, rig)
|
||||
_test_ghost_collision(stage)
|
||||
await _test_stage_contact_routing(stage, rig)
|
||||
_test_ragdoll_transitions(rig)
|
||||
|
||||
stage.queue_free()
|
||||
await process_frame
|
||||
_finish()
|
||||
|
||||
|
||||
func _spawn_rig(stage: Node2D, position: Vector2) -> StickmanRig:
|
||||
var rig := stage._spawner.spawn("stickman", position) as StickmanRig
|
||||
_check(rig != null, "stage spawns a stickman rig")
|
||||
if rig != null:
|
||||
stage._finalize_placed_node(rig)
|
||||
return rig
|
||||
|
||||
|
||||
func _test_collision_proxy_geometry(rig: StickmanRig) -> void:
|
||||
_check(rig._collision_bodies.size() == 10, "rig builds ten collision proxies")
|
||||
_check(rig._collision_lengths.size() == 9, "rig records nine fitted capsule lengths")
|
||||
for part_name: String in rig.COLLISION_PARTS:
|
||||
var definition: Dictionary = rig.COLLISION_PARTS[part_name]
|
||||
var body := rig._collision_bodies.get(part_name) as AnimatableBody2D
|
||||
_check(body != null, "%s proxy exists" % part_name)
|
||||
if body == null:
|
||||
continue
|
||||
_check(body.name == "CollisionBody", "%s proxy uses the standard name" % part_name)
|
||||
_check(not body.sync_to_physics, "%s proxy uses explicit transform synchronization" % part_name)
|
||||
_check(body.top_level, "%s proxy is isolated from RemoteTransform2D parent updates" % part_name)
|
||||
_check(body.collision_layer == 1 and body.collision_mask == 1, "%s proxy starts enabled" % part_name)
|
||||
_check(body.get_meta("stickman_rig", null) == rig, "%s proxy stores its rig" % part_name)
|
||||
_check(String(body.get_meta("part_name", "")) == part_name, "%s proxy stores its part name" % part_name)
|
||||
|
||||
var shape_node := body.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
|
||||
_check(shape_node != null, "%s proxy owns a collision shape" % part_name)
|
||||
if shape_node == null:
|
||||
continue
|
||||
if part_name == "head":
|
||||
var circle := shape_node.shape as CircleShape2D
|
||||
_check(circle != null, "head uses a circle")
|
||||
if circle != null:
|
||||
_check(circle.radius >= rig.HEAD_COLLISION_RADIUS, "head radius covers at least the fallback radius")
|
||||
var head_visual := rig.get_node_or_null(NodePath("Body/Head")) as Node2D
|
||||
var bounds: Dictionary = rig._mounted_visual_bounds_local(head_visual)
|
||||
_check(bool(bounds.get("valid", false)), "mounted head has measurable geometry")
|
||||
if bool(bounds.get("valid", false)):
|
||||
_check(shape_node.position.is_equal_approx((bounds["rect"] as Rect2).get_center()), "head collider follows the mounted bbox center")
|
||||
_check(shape_node.position.y < 0.0, "head collider is above its neck attachment")
|
||||
else:
|
||||
var capsule := shape_node.shape as CapsuleShape2D
|
||||
_check(capsule != null, "%s uses a capsule" % part_name)
|
||||
if capsule == null:
|
||||
continue
|
||||
var length := float(rig._collision_lengths.get(part_name, -1.0))
|
||||
_check(is_equal_approx(capsule.height, length), "%s capsule uses its fitted length" % part_name)
|
||||
_check(is_equal_approx(capsule.radius, float(definition["radius"])), "%s capsule uses its configured radius" % part_name)
|
||||
_check(shape_node.position.is_equal_approx(Vector2(0.0, length * 0.5)), "%s capsule is centered along +Y" % part_name)
|
||||
_check(is_zero_approx(shape_node.rotation), "%s capsule needs no hanging-frame rotation" % part_name)
|
||||
|
||||
var empty_visual := Node2D.new()
|
||||
var empty_bounds: Dictionary = rig._mounted_visual_bounds_local(empty_visual)
|
||||
_check(not bool(empty_bounds.get("valid", true)), "empty visual bounds use the fallback path")
|
||||
empty_visual.free()
|
||||
|
||||
rig.set_collision_bodies_enabled(false)
|
||||
_check(_all_proxy_layers_equal(rig, 0), "public collision hook disables every proxy")
|
||||
rig.set_collision_bodies_enabled(true)
|
||||
_check(_all_proxy_layers_equal(rig, 1), "public collision hook re-enables every proxy")
|
||||
|
||||
var sizes_before := _proxy_shape_sizes(rig)
|
||||
rig.set_facing_profile(StickmanRig.FacingProfile.LEFT)
|
||||
_check(_proxy_shape_sizes(rig) == sizes_before, "LEFT mirroring preserves symmetric proxy sizes")
|
||||
rig.set_facing_profile(StickmanRig.FacingProfile.FORWARD)
|
||||
|
||||
|
||||
func _test_collision_proxy_physics_alignment(rig: StickmanRig) -> void:
|
||||
rig.walk_to(rig.global_position + Vector2(-400.0, 0.0), 300.0)
|
||||
for _frame in 8:
|
||||
await physics_frame
|
||||
await process_frame
|
||||
rig._sync_collision_bodies()
|
||||
await physics_frame
|
||||
await process_frame
|
||||
_check(rig.facing_profile == StickmanRig.FacingProfile.LEFT, "left walk uses the mirrored collision-proxy path")
|
||||
for part_name: String in rig.COLLISION_PARTS:
|
||||
var definition: Dictionary = rig.COLLISION_PARTS[part_name]
|
||||
var visual := rig.get_node_or_null(NodePath("Body/" + String(definition["visual"]))) as Node2D
|
||||
var body := rig._collision_bodies.get(part_name) as AnimatableBody2D
|
||||
if visual == null or body == null:
|
||||
_check(false, "%s has nodes for PhysicsServer alignment" % part_name)
|
||||
continue
|
||||
var server_transform := PhysicsServer2D.body_get_state(body.get_rid(), PhysicsServer2D.BODY_STATE_TRANSFORM) as Transform2D
|
||||
_check(server_transform.origin.distance_to(visual.global_position) < 0.01, "%s PhysicsServer origin follows its walking visual" % part_name)
|
||||
_check(server_transform.x.distance_to(visual.global_transform.x) < 0.001, "%s PhysicsServer X basis follows its walking visual" % part_name)
|
||||
_check(server_transform.y.distance_to(visual.global_transform.y) < 0.001, "%s PhysicsServer Y basis follows its walking visual" % part_name)
|
||||
rig._cancel_walking()
|
||||
rig.set_facing_profile(StickmanRig.FacingProfile.FORWARD)
|
||||
|
||||
|
||||
func _test_reactions(rig: StickmanRig) -> void:
|
||||
rig._impact_reactions.clear()
|
||||
rig.apply_collision_reaction("unknown", Vector2.RIGHT, 400.0)
|
||||
rig.apply_collision_reaction("head", Vector2.ZERO, 400.0)
|
||||
rig.apply_collision_reaction("head", Vector2.RIGHT, 0.0)
|
||||
_check(rig._impact_reactions.is_empty(), "invalid reactions are ignored")
|
||||
|
||||
var head_proxy := rig._collision_bodies["head"] as AnimatableBody2D
|
||||
var head_shape := head_proxy.get_node("CollisionShape2D") as CollisionShape2D
|
||||
var head_circle := head_shape.shape as CircleShape2D
|
||||
var upper_left := head_proxy.to_global(head_shape.position + Vector2(-head_circle.radius * 0.7, -head_circle.radius * 0.7))
|
||||
rig.apply_collision_reaction("head", Vector2(0.5, 0.5).normalized(), 400.0, upper_left)
|
||||
_check(float((rig._impact_reactions["Head"] as Dictionary)["angle"]) < 0.0, "upper-left head contact lowers the struck side")
|
||||
rig._impact_reactions.clear()
|
||||
var upper_right := head_proxy.to_global(head_shape.position + Vector2(head_circle.radius * 0.7, -head_circle.radius * 0.7))
|
||||
rig.apply_collision_reaction("head", Vector2(-0.5, 0.5).normalized(), 400.0, upper_right)
|
||||
_check(float((rig._impact_reactions["Head"] as Dictionary)["angle"]) > 0.0, "upper-right head contact lowers the struck side")
|
||||
rig._impact_reactions.clear()
|
||||
var lower_left := head_proxy.to_global(head_shape.position + Vector2(-head_circle.radius * 0.7, head_circle.radius * 0.7))
|
||||
rig.apply_collision_reaction("head", Vector2.UP, 400.0, lower_left)
|
||||
_check(float((rig._impact_reactions["Head"] as Dictionary)["angle"]) > 0.0, "lower-left upward contact inverts the upper-left response")
|
||||
rig._impact_reactions.clear()
|
||||
var lower_right := head_proxy.to_global(head_shape.position + Vector2(head_circle.radius * 0.7, head_circle.radius * 0.7))
|
||||
rig.apply_collision_reaction("head", Vector2.UP, 400.0, lower_right)
|
||||
_check(float((rig._impact_reactions["Head"] as Dictionary)["angle"]) < 0.0, "lower-right upward contact inverts the upper-right response")
|
||||
rig._impact_reactions.clear()
|
||||
|
||||
rig.apply_collision_reaction("left_lower_arm", Vector2.RIGHT, 10000.0)
|
||||
var lower: Dictionary = rig._impact_reactions.get("LeftLowerArm", {})
|
||||
var upper: Dictionary = rig._impact_reactions.get("LeftUpperArm", {})
|
||||
_check(is_equal_approx(absf(float(lower.get("angle", 0.0))), 0.6), "primary reaction caps at 0.6 radians")
|
||||
_check(is_equal_approx(absf(float(upper.get("angle", 0.0))), 0.24), "secondary reaction applies 0.4 weight")
|
||||
|
||||
var strong_angle := float(lower.get("angle", 0.0))
|
||||
(rig._impact_reactions["LeftLowerArm"] as Dictionary)["strength"] = 0.25
|
||||
rig.apply_collision_reaction("left_lower_arm", Vector2.LEFT, 40.0)
|
||||
lower = rig._impact_reactions["LeftLowerArm"]
|
||||
_check(is_equal_approx(float(lower["angle"]), strong_angle), "weaker opposite hit retains the stronger signed angle")
|
||||
_check(is_equal_approx(float(lower["strength"]), 1.0), "weaker hit refreshes reaction strength")
|
||||
|
||||
rig.apply_collision_reaction("left_lower_arm", Vector2.LEFT, 10000.0)
|
||||
lower = rig._impact_reactions["LeftLowerArm"]
|
||||
_check(signf(float(lower["angle"])) != signf(strong_angle), "equal capped hit may replace the reaction direction")
|
||||
var lower_bone := rig._reaction_bones["LeftLowerArm"] as Bone2D
|
||||
var base_rotation := lower_bone.rotation
|
||||
var max_offset := 0.0
|
||||
for frame in 30:
|
||||
rig._apply_impact_reactions(1.0 / 60.0)
|
||||
max_offset = maxf(max_offset, absf(angle_difference(base_rotation, lower_bone.rotation)))
|
||||
_check(max_offset <= rig.REACTION_MAX_ANGLE + 0.001, "repeated reaction frames never accumulate beyond the angular cap")
|
||||
rig._apply_impact_reactions(1.0)
|
||||
_check(rig._impact_reactions.is_empty(), "expired reactions are erased")
|
||||
_check(is_zero_approx(angle_difference(base_rotation, lower_bone.rotation)), "expired reaction restores its captured base rotation")
|
||||
|
||||
|
||||
func _test_direct_restores_animated_pose(stage: Node2D, rig: StickmanRig) -> void:
|
||||
var authored: Dictionary = stage._authored[rig.get_instance_id()]
|
||||
stage.set_mode(stage.StageMode.PLAY)
|
||||
rig.set_facing_profile(StickmanRig.FacingProfile.LEFT)
|
||||
rig.global_position += Vector2(220.0, -90.0)
|
||||
rig.global_rotation = PI
|
||||
var torso_marker := rig.get_node("IK_Targets/Torso") as Marker2D
|
||||
torso_marker.position = Vector2(75.0, -130.0)
|
||||
torso_marker.rotation = PI
|
||||
var head_marker := rig.get_node("IK_Targets/Head") as Marker2D
|
||||
head_marker.position += Vector2(120.0, 80.0)
|
||||
stage.set_mode(stage.StageMode.DIRECT)
|
||||
for _frame in 4:
|
||||
await physics_frame
|
||||
await process_frame
|
||||
_check(rig.global_position.distance_to(authored["position"] as Vector2) < 0.01, "PLAY to DIRECT restores the authored stickman position")
|
||||
_check(absf(angle_difference(float(authored["rotation"]), rig.global_rotation)) < 0.001, "PLAY to DIRECT restores the authored stickman rotation")
|
||||
_check(rig.get_facing_profile() == int(authored["facing_profile"]), "PLAY to DIRECT restores the authored facing profile")
|
||||
for marker_name: String in rig.STAND_POSE:
|
||||
var marker := rig.get_node("IK_Targets/" + marker_name) as Node2D
|
||||
var expected: Dictionary = rig.STAND_POSE[marker_name]
|
||||
_check(marker.position.distance_to(expected["pos"] as Vector2) < 0.01, "%s marker returns to standing after PLAY" % marker_name)
|
||||
_check(absf(angle_difference(torso_marker.rotation, float(rig.STAND_POSE["Torso"]["rot"]))) < 0.001, "torso marker cannot retain an upside-down PLAY frame")
|
||||
|
||||
|
||||
func _test_ghost_collision(stage: Node2D) -> void:
|
||||
var ghost := _spawn_rig(stage, Vector2(1000.0, 0.0))
|
||||
if ghost == null:
|
||||
return
|
||||
stage._configure_ghost_collision(ghost)
|
||||
_check(_all_proxy_layers_equal(ghost, 0), "stickman placement ghosts have no collision")
|
||||
ghost.queue_free()
|
||||
|
||||
|
||||
func _test_stage_contact_routing(stage: Node2D, rig: StickmanRig) -> void:
|
||||
var prop := PropBlock.new()
|
||||
prop.position = Vector2(50.0, 25.0)
|
||||
prop.linear_velocity = Vector2(400.0, 0.0)
|
||||
stage._world.add_child(prop)
|
||||
await process_frame
|
||||
_check(prop.continuous_cd == RigidBody2D.CCD_MODE_CAST_SHAPE, "moving props use shape-cast CCD for thin limbs")
|
||||
|
||||
var emitted_collisions: Array[Dictionary] = []
|
||||
var emitted_separations: Array[Node] = []
|
||||
prop.collided.connect(func(other: Node, normal: Vector2, position: Vector2) -> void:
|
||||
emitted_collisions.append({"other": other, "normal": normal, "position": position})
|
||||
)
|
||||
prop.separated.connect(func(other: Node) -> void:
|
||||
emitted_separations.append(other)
|
||||
)
|
||||
var fallback_body := Node2D.new()
|
||||
fallback_body.position = prop.position + Vector2.RIGHT * 20.0
|
||||
stage._world.add_child(fallback_body)
|
||||
prop._on_body_entered(fallback_body)
|
||||
prop._on_body_exited(fallback_body)
|
||||
_check(emitted_collisions.size() == 1, "PropBlock emits its extended collided signal")
|
||||
if emitted_collisions.size() == 1:
|
||||
_check((emitted_collisions[0]["normal"] as Vector2).is_equal_approx(Vector2.RIGHT), "PropBlock fallback normal points prop-to-other")
|
||||
_check((emitted_collisions[0]["position"] as Vector2).is_equal_approx(prop.global_position), "PropBlock fallback position is world-space")
|
||||
_check(emitted_separations == [fallback_body], "PropBlock mirrors body_exited with separated")
|
||||
|
||||
var lower := rig._collision_bodies["left_lower_arm"] as AnimatableBody2D
|
||||
var upper := rig._collision_bodies["left_upper_arm"] as AnimatableBody2D
|
||||
var pair_key := "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()]
|
||||
var rule := {
|
||||
"id": 4500,
|
||||
"trigger": {
|
||||
"type": "collided",
|
||||
"source": rig.get_instance_id(),
|
||||
"target": prop.get_instance_id(),
|
||||
"params": {},
|
||||
},
|
||||
"actions": [{"type": "wait", "target": rig.get_instance_id(), "params": {"duration": 10.0}}],
|
||||
}
|
||||
stage._event_rules.clear()
|
||||
stage._event_rules.append(rule)
|
||||
var initial_queue_size := rig.queue_size()
|
||||
stage._on_prop_collided(lower, Vector2.RIGHT, Vector2(10.0, 20.0), prop)
|
||||
_check(stage._collision_pairs.has(pair_key), "first part contact creates a rig/prop pair")
|
||||
_check(rig.queue_size() == initial_queue_size + 1, "first pair contact dispatches a rig-to-prop rule")
|
||||
stage._on_prop_collided(upper, Vector2.RIGHT, Vector2(11.0, 20.0), prop)
|
||||
var pair: Dictionary = stage._collision_pairs[pair_key]
|
||||
_check((pair.get("bodies", {}) as Dictionary).size() == 2, "second part contact joins the active body set")
|
||||
_check(rig.queue_size() == initial_queue_size + 1, "second active part does not repeat the pair rule")
|
||||
_check(rig._impact_reactions.has("LeftLowerArm") and rig._impact_reactions.has("LeftUpperArm"), "each newly contacted part can react")
|
||||
|
||||
stage._on_prop_separated(lower, prop)
|
||||
_check(stage._collision_pairs.has(pair_key), "first part exit retains the pair")
|
||||
stage._on_prop_separated(upper, prop)
|
||||
_check(not stage._collision_pairs.has(pair_key), "last part exit clears the pair")
|
||||
stage._on_prop_collided(lower, Vector2.RIGHT, Vector2(12.0, 20.0), prop)
|
||||
_check(rig.queue_size() == initial_queue_size + 2, "full separation permits a new collision rule")
|
||||
var prop_ids: Array[int] = [prop.get_instance_id()]
|
||||
stage._purge_collision_pairs_for_ids(prop_ids)
|
||||
_check(not stage._collision_pairs.has(pair_key), "prop deletion purge removes active pairs")
|
||||
|
||||
var forward_event := {"type": "collided", "source": rig, "target": prop, "position": Vector2.ZERO, "action": {}}
|
||||
var reverse_event := {"type": "collided", "source": prop, "target": rig, "position": Vector2.ZERO, "action": {}}
|
||||
_check(stage._rule_matches(rule, forward_event), "collided rules preserve rig-to-prop ordering")
|
||||
_check(not stage._rule_matches(rule, reverse_event), "collided rule matching is not bidirectional")
|
||||
|
||||
var generic_rule := {
|
||||
"id": 4501,
|
||||
"trigger": {
|
||||
"type": "collided",
|
||||
"source": prop.get_instance_id(),
|
||||
"target": fallback_body.get_instance_id(),
|
||||
"params": {},
|
||||
},
|
||||
"actions": [{"type": "wait", "target": rig.get_instance_id(), "params": {"duration": 10.0}}],
|
||||
}
|
||||
stage._event_rules.clear()
|
||||
stage._event_rules.append(generic_rule)
|
||||
var before_generic := rig.queue_size()
|
||||
stage._on_prop_collided(fallback_body, Vector2.RIGHT, Vector2(13.0, 20.0), prop)
|
||||
_check(rig.queue_size() == before_generic + 1, "non-stickman contacts preserve prop-to-other event ordering")
|
||||
|
||||
var invalid_proxy := Node2D.new()
|
||||
invalid_proxy.set_meta("stickman_rig", rig)
|
||||
stage._world.add_child(invalid_proxy)
|
||||
var before_invalid := rig.queue_size()
|
||||
stage._on_prop_collided(invalid_proxy, Vector2.RIGHT, Vector2.ZERO, prop)
|
||||
_check(rig.queue_size() == before_invalid, "incomplete stickman metadata dispatches no generic rule")
|
||||
|
||||
var ragdoll_proxy := Node2D.new()
|
||||
ragdoll_proxy.set_meta("stickman_ragdoll_body", true)
|
||||
stage._world.add_child(ragdoll_proxy)
|
||||
var before_suppressed := rig.queue_size()
|
||||
stage._on_prop_collided(ragdoll_proxy, Vector2.RIGHT, Vector2.ZERO, prop)
|
||||
_check(rig.queue_size() == before_suppressed, "ragdoll contacts dispatch no rule")
|
||||
|
||||
fallback_body.queue_free()
|
||||
invalid_proxy.queue_free()
|
||||
ragdoll_proxy.queue_free()
|
||||
prop.queue_free()
|
||||
stage._event_rules.clear()
|
||||
rig.stop_queue()
|
||||
rig.clear_queue()
|
||||
|
||||
|
||||
func _test_ragdoll_transitions(rig: StickmanRig) -> void:
|
||||
rig.apply_collision_reaction("head", Vector2.RIGHT, 400.0)
|
||||
_check(not rig._impact_reactions.is_empty(), "animated rig accepts a reaction before ragdoll")
|
||||
rig.set_ragdoll(true)
|
||||
_check(rig.state == StickmanRig.RigState.RAGDOLL, "rig enters ragdoll")
|
||||
_check(_all_proxy_layers_equal(rig, 0), "ragdoll disables animated collision proxies")
|
||||
_check(rig._impact_reactions.is_empty(), "ragdoll entry clears reactions")
|
||||
|
||||
rig.request_recovery()
|
||||
_check(rig.state == StickmanRig.RigState.RECOVERING, "recovery request starts the stand-up state")
|
||||
_check(_all_proxy_layers_equal(rig, 1), "collision proxies stay enabled throughout recovery")
|
||||
rig.apply_collision_reaction("head", Vector2.RIGHT, 400.0)
|
||||
_check(rig._impact_reactions.is_empty(), "recovering rig ignores visual reactions")
|
||||
|
||||
rig.set_ragdoll(true)
|
||||
_check(rig.state == StickmanRig.RigState.RAGDOLL, "recovery can be interrupted by ragdoll")
|
||||
_check(_all_proxy_layers_equal(rig, 0), "recovery interruption disables proxies again")
|
||||
rig.snap_to_standing()
|
||||
_check(rig.state == StickmanRig.RigState.ANIMATED, "snap returns the rig to animated")
|
||||
_check(_all_proxy_layers_equal(rig, 1), "snap restores animated collision proxies")
|
||||
|
||||
|
||||
func _all_proxy_layers_equal(rig: StickmanRig, expected: int) -> bool:
|
||||
for body: AnimatableBody2D in rig._collision_bodies.values():
|
||||
if body.collision_layer != expected or body.collision_mask != expected:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _proxy_shape_sizes(rig: StickmanRig) -> Dictionary:
|
||||
var sizes := {}
|
||||
for part_name: String in rig._collision_bodies:
|
||||
var body := rig._collision_bodies[part_name] as AnimatableBody2D
|
||||
var shape_node := body.get_node(NodePath("CollisionShape2D")) as CollisionShape2D
|
||||
if shape_node.shape is CircleShape2D:
|
||||
sizes[part_name] = (shape_node.shape as CircleShape2D).radius
|
||||
else:
|
||||
var capsule := shape_node.shape as CapsuleShape2D
|
||||
sizes[part_name] = Vector2(capsule.radius, capsule.height)
|
||||
return sizes
|
||||
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
_checks += 1
|
||||
if not condition:
|
||||
_failures += 1
|
||||
push_error("FAIL: " + message)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if _failures == 0:
|
||||
print("RESULT: ALL PASSED (%d assertions)" % _checks)
|
||||
quit(0)
|
||||
else:
|
||||
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://btt85kp10h5mk
|
||||
Reference in New Issue
Block a user