feat: Implement kinematic-to-ragdoll transition system
- Added KINEMATIC_BLENDING_AND_RECOVERY.md to outline features for smooth transitions between kinematic and ragdoll states, including visual and physical blending, and ragdoll recovery. - Introduced KINEMATIC_TO_RAGDOLL.md detailing the objectives, scope, and core architecture for transitioning the stickman from kinematic to ragdoll mode. - Created KINEMATIC_TO_RAGDOLL_SPEC.md as an implementation specification, verifying codebase facts and correcting the initial plan based on Godot 4.4 source. - Enhanced StickmanRig with state management for animated and ragdoll modes, including momentum preservation and ragdoll construction. - Updated physics_test_harness to support toggling between kinematic and ragdoll states with user input.
This commit is contained in:
@@ -82,6 +82,8 @@ These bias toward caution over speed — use judgment on trivial tasks.
|
||||
- **Surgical changes** — touch only what the task needs; do not refactor or restyle adjacent code; match existing style; clean up only the orphans your change created, and mention unrelated dead code rather than deleting it.
|
||||
- **Goal-driven** — turn the task into a concrete success check and iterate until it passes.
|
||||
|
||||
You must never combine phases 3–5 in a single response. Always stop after presenting questions or choices and wait for the user’s next message.
|
||||
|
||||
## Phase 1: Discovery
|
||||
|
||||
Goal: Understand what needs to be built.
|
||||
@@ -106,39 +108,46 @@ Goal: Understand relevant existing code at both high and low levels.
|
||||
|
||||
## Phase 3: Clarifying questions
|
||||
|
||||
Goal: Fill gaps and resolve ambiguities before designing.
|
||||
**This is a mandatory stop point.**
|
||||
|
||||
**This is one of the most important phases. Do not skip.**
|
||||
|
||||
1. Review the codebase findings and the original feature request.
|
||||
2. Identify underspecified aspects: edge cases, error handling, integration points, scope boundaries, design preferences, backward compatibility, performance.
|
||||
3. Present all questions to the user as a clear, organized list.
|
||||
4. **Wait for answers** before moving to architecture.
|
||||
- Output a numbered list of questions.
|
||||
- **Do NOT include any architecture, code, or spec content in this response.**
|
||||
- End your response with: “Please reply with answers to these questions before I proceed.”
|
||||
|
||||
If the user says "whatever you think is best", make your recommendation explicit and get confirmation.
|
||||
|
||||
## Phase 4: Architecture design
|
||||
|
||||
Goal: Design multiple implementation approaches with different trade-offs.
|
||||
**This is a mandatory stop point.**
|
||||
|
||||
1. Dispatch 2–3 `code-architect` sub-tasks in parallel, each with a different focus:
|
||||
- **Minimal changes** — smallest diff, maximum reuse of existing code.
|
||||
- **Clean architecture** — maintainability, elegant abstractions.
|
||||
- **Pragmatic balance** — speed plus quality.
|
||||
2. Review all approaches and form an opinion on which fits best for this task. Consider scope (small fix vs. large feature), urgency, complexity, and team context.
|
||||
3. Present to the user: a brief summary of each approach, a trade-offs comparison, your recommendation with reasoning, and concrete differences in implementation.
|
||||
4. **Ask the user which approach they prefer.**
|
||||
- Present 2–3 approaches with trade‑offs.
|
||||
- State your recommendation.
|
||||
- **Do NOT choose or implement anything.**
|
||||
- End with: “Which approach do you prefer? Reply with your choice.”
|
||||
|
||||
### 📝 Tech-Debt & Future-Optimization Logging
|
||||
|
||||
During architecture design, if you identify:
|
||||
|
||||
- Trade-offs that will cause friction later (e.g., "we're using a quick O(n²) loop here because the list is small now, but it will scale poorly").
|
||||
- Obvious refactoring opportunities that are out of scope (e.g., "this legacy singleton should be replaced with an event bus").
|
||||
- Missing tests or error handling that are not critical for the current feature.
|
||||
|
||||
**Append** a new entry to `docs/tech_debt_and_optimizations.md` using this format:
|
||||
|
||||
```markdown
|
||||
## [YYYY-MM-DD] - [Feature Name]
|
||||
|
||||
- **Debt**: [Clear description]
|
||||
- **Impact**: [What breaks/degrades if ignored]
|
||||
- **Suggested Fix**: [Actionable improvement]
|
||||
- **Context**: [Link to spec file or relevant code path]
|
||||
|
||||
## Phase 5: Create Spec
|
||||
|
||||
Goal: Build the spec.
|
||||
**Do not start without explicit user approval.**
|
||||
**Do not proceed until the user explicitly approves the chosen approach.**
|
||||
|
||||
1. Wait for approval.
|
||||
2. Re-read all relevant files identified earlier.
|
||||
3. Spec following the chosen architecture. We are not writing code, just the specification.
|
||||
4. Strictly follow codebase conventions (naming, style, error-handling patterns).
|
||||
5. Update todos as you progress.
|
||||
- Once they approve, you may write the spec in the next turn.
|
||||
|
||||
## Phase 6: Summary
|
||||
|
||||
@@ -151,3 +160,4 @@ Goal: Document what was accomplished.
|
||||
- Key decisions made
|
||||
- Files modified
|
||||
- Suggest running the @feature-pipeline skill to begin implementation
|
||||
```
|
||||
|
||||
@@ -78,6 +78,16 @@ _Brief 1-2 sentence overview of what the reviewed component does._
|
||||
- **Current**: [Describe bottleneck/tight coupling]
|
||||
- **Recommendation**: [Describe optimized approach]
|
||||
|
||||
### 🔧 Runtime Tech-Debt Discovery
|
||||
|
||||
While writing code, if you encounter:
|
||||
|
||||
- Ugly workarounds forced by existing code.
|
||||
- Performance pitfalls you have to code around.
|
||||
- Unused imports, dead code, or outdated comments that are confusing.
|
||||
|
||||
**Immediately** append to `docs/tech_debt_and_optimizations.md` with the same format.
|
||||
|
||||
### 🎨 Style & Readability Refactors
|
||||
|
||||
- _Bullet points pointing out missing type hints, naming violations, or dead code._
|
||||
|
||||
@@ -10,7 +10,7 @@ I orchestrate a sequential feature implementation and verification pipeline:
|
||||
1. **Architect**: Explores codebase, asks clarifying questions, and drafts the spec. \*_Waits for user approval before handoff._
|
||||
2. **Developer**: Implements the feature based on the spec.
|
||||
3. **Tester**: Runs full unit test suites; repairs failures if found.
|
||||
4. **Writer**: Updates README and API docs based on verified code.
|
||||
4. **Writer**: After updating README and API docs, review `docs/tech_debt_and_optimizations.md` for formatting consistency and ensure no duplicate entries exist.
|
||||
|
||||
## Execution Rules
|
||||
|
||||
|
||||
@@ -236,6 +236,27 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
`_nodes_ready`; a `_nodes_ready` guard makes pre-`_ready` setters store-only (robust against
|
||||
setter timing during `PackedScene.instantiate()`). Null-guards + `push_warning` prefixed
|
||||
`"StickmanRig: "` throughout; never crashes.
|
||||
- **Phase 10 ragdoll state system:** `StickmanRig` owns a reversible `ANIMATED ⇄ RAGDOLL`
|
||||
physics mode switch. `enum RigState { ANIMATED, RAGDOLL }`, `var state: RigState`
|
||||
(default `ANIMATED`), `signal state_changed(new_state: int)`, and public API
|
||||
`set_ragdoll(enabled: bool)` / `toggle_ragdoll()` / `is_in_ragdoll() -> bool`.
|
||||
`_physics_process()` → `_track_momentum(delta)` caches the rig root's linear/angular
|
||||
velocity from per-frame `global_position`/`global_rotation` deltas. `_enter_ragdoll()`
|
||||
disables the IK modification stack, `stop()`s the `AnimationPlayer` (`ANIMATION_PLAYER_PATH`
|
||||
const), hides `Body/*`, builds the ragdoll, then sets `state` + emits `state_changed`.
|
||||
`_build_ragdoll()` creates a `Node2D` container `"RagdollBodyContainer"` under the rig's
|
||||
**parent** (world root; fallback `get_tree().current_scene`) and populates it from the
|
||||
`RAGDOLL_BODIES` table (**10** `RigidBody2D`: torso `CapsuleShape2D` radius 12 mass 8.0,
|
||||
head `CircleShape2D` radius 100 mass 2.0, limb capsules radius 8 masses 1.0–2.0;
|
||||
`collision_layer`/`collision_mask` = 1) and the `RAGDOLL_JOINTS` table (**9** `PinJoint2D`,
|
||||
one per non-root body pinned at the child bone's origin, `softness` 0.0). Angular limits via
|
||||
`_apply_ragdoll_joint_limits()`: `elbow_knee` folds +CW `-5°..+150°`, `elbow_knee_ccw`
|
||||
`-150°..+5°`, `shoulder_hip` ±160°, default (neck) free. Cached momentum is applied to the
|
||||
torso body. `apply_ragdoll_velocity_boost(velocity)` applies the same velocity delta
|
||||
(mass-scaled `apply_central_impulse`) to every ragdoll body — used by the harness "Knock
|
||||
Up" button. `_exit_ragdoll()` `queue_free()`s the container, re-shows `Body/*`, re-enables
|
||||
the IK stack, `stop()`s the animation, and reverts `state` to `ANIMATED`. All ragdoll nodes
|
||||
are spawned procedurally — `master_rig.tscn` is **not** modified.
|
||||
- `scripts/stickman_factory.gd` — `class_name StickmanFactory`, `extends RefCounted`; a **static
|
||||
factory** and the **runtime entry point** (Phase 9, **not used by the editor**) that turns a
|
||||
`.stk` file into a live, rigged `master_rig.tscn` instance:
|
||||
@@ -414,14 +435,29 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
staging scene root (Vector Terrain System / Dynamic Vector Props, **not wired into the editor**;
|
||||
run via **F6** on `res://scenes/physics_test_harness.tscn`). Builds flat ground, angled ramps, and stepped
|
||||
`TerrainBlock` instances via `TerrainUtils`, instantiates `res://master_rig.tscn` standing on the
|
||||
flat ground, and handles camera input. **Keys 1/2/3** spawn dynamic props above the angled ramp
|
||||
via `PropUtils.spawn_prop()` (`PROP_SPAWN_POSITION = (300, -300)`): **1** Wood Crate
|
||||
(`create_box()`, `WOOD`, velocity `(60,0)`), **2** Bouncy Ball (`create_ball()`, `RUBBER`,
|
||||
`(-80,0)`), **3** Heavy Plank (`create_plank()`, `METAL`, `(30,-40)`). Adds a **best-effort
|
||||
flat ground, and handles camera input. A top-bar UI (`_build_ui()`, a `CanvasLayer` +
|
||||
`PanelContainer` matching `test_harness`'s style) replaces the old key bindings: **Spawn
|
||||
Crate** / **Spawn Ball** / **Spawn Plank** buttons spawn dynamic props above the angled ramp
|
||||
via `PropUtils.spawn_prop()` (`PROP_SPAWN_POSITION = (300, -300)`: Wood Crate
|
||||
(`create_box()`, `WOOD`, velocity `(60,0)`), Bouncy Ball (`create_ball()`, `RUBBER`,
|
||||
`(-80,0)`), Heavy Plank (`create_plank()`, `METAL`, `(30,-40)`). Adds a **best-effort
|
||||
`StaticBody2D` collision proxy** (`RigCollisionProxy`, `_add_rig_collision_proxy()`) since the
|
||||
rig has **no physics bodies of its own** — a 240×1000 px `RectangleShape2D` centered at `(0,-500)`
|
||||
(`RIG_PROXY_SIZE`/`RIG_PROXY_CENTER`) matching the standing figure's world bounds, so props
|
||||
( `RIG_PROXY_SIZE`/`RIG_PROXY_CENTER`) matching the standing figure's world bounds, so props
|
||||
bounce/rest against it; the proxy is a code-only stand-in, not part of the rig.
|
||||
- **Phase 10 ragdoll trigger:** the harness stores the spawned rig in `_rig: StickmanRig`
|
||||
and shows a toggle-mode `Button` (`_ragdoll_toggle`) whose text flips
|
||||
**"Stickman"** ↔ **"Ragdoll"** (`_update_ragdoll_toggle()`, `set_pressed_no_signal`
|
||||
keeps the label in sync without retriggering). Toggling calls `_rig.set_ragdoll(pressed)`,
|
||||
then on entry calls `_remove_rig_collision_proxy()` (the ragdoll collides directly with the
|
||||
terrain) and on exit `_add_rig_collision_proxy()`. `_add_rig_collision_proxy()` is idempotent
|
||||
— it first `_find_rig_collision_proxy()` (a direct child named `"RigCollisionProxy"`) and
|
||||
returns early if one exists, so rapid toggling leaves no duplicate proxies.
|
||||
- **Phase 10 external forces:** a **"Knock Up"** button (`_knock_up()`) tests impulses beyond
|
||||
gravity: when the rig is in RAGDOLL mode it calls `StickmanRig.apply_ragdoll_velocity_boost(
|
||||
KNOCK_UP_VELOCITY = (0, -450))` (mass-scaled `apply_central_impulse` on every ragdoll body,
|
||||
preserving internal structure), and applies the same upward velocity delta to every dynamic
|
||||
prop (`RigidBody2D` child of `_environment`) so the whole pile flies up together.
|
||||
- Scenes:
|
||||
- `scenes/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used
|
||||
for typed `@onready` access: `%MenuBar`, `%StickmanNameEdit`, `%LeftColumn`,
|
||||
@@ -443,9 +479,11 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
Dynamic Vector Props, not wired into the editor; run via **F6**). Backed by
|
||||
`scripts/physics_test_harness.gd`. Root `Node2D` + script, `Camera2D` at position `(0, -400)`
|
||||
zoom `0.5`, empty Environment container. Wheel-zoom scales between `0.25x` and `3.0x`
|
||||
(resolution independence / vector outline thickness); middle-drag pans. Keys **1/2/3** spawn
|
||||
dynamic props (`PropUtils`) above the angled ramp; a best-effort `StaticBody2D` rig collision
|
||||
proxy provides a surface for props to bounce/rest against.
|
||||
(resolution independence / vector outline thickness); middle-drag pans. Top-bar buttons
|
||||
**Spawn Crate / Spawn Ball / Spawn Plank** spawn dynamic props (`PropUtils`) above the
|
||||
angled ramp; a toggle button flips the rig **Stickman** ↔ **Ragdoll** (removing/restoring
|
||||
the best-effort `StaticBody2D` rig collision proxy); a **Knock Up** button impulses the
|
||||
ragdoll and all props upward.
|
||||
|
||||
### Body-part data model
|
||||
- 10 internal part keys (ordered): `head`, `torso`, `left_upper_arm`, `left_lower_arm`,
|
||||
|
||||
@@ -289,7 +289,7 @@ The **Vector Terrain System** is a standalone, reusable component for building *
|
||||
3. **Clockwise enforcement** — `Geometry2D.is_polygon_clockwise()` reverses the winding if it is not already clockwise, **guaranteeing clockwise output**.
|
||||
- `spawn_block(...)` — factory that sanitizes the raw input vectors (`sanitize_points`), creates a `TerrainBlock`, applies the cleaned points, and adds it to the target container.
|
||||
|
||||
**`physics_test_harness.tscn` / `scripts/physics_test_harness.gd`** — `class_name PhysicsTestHarness`, `extends Node2D`; a **standalone staging scene** (run via **F6**; not wired into the editor). It builds flat ground, angled ramps, and stepped `TerrainBlock` instances via `TerrainUtils`, then instantiates `res://master_rig.tscn` standing on the flat ground. The scene root is a `Node2D` + script with a `Camera2D` at position `(0, -400)` zoom `0.5` and an empty `Environment` container. The camera wheel-zoom scales between **0.25x and 3.0x** (to test resolution independence / vector outline thickness); middle-drag pans. It also hosts the **Dynamic Vector Props** spawner (see below): press **1/2/3** to drop physics props above the angled ramp, plus a best-effort `StaticBody2D` collision proxy for the rig (which has no physics bodies of its own).
|
||||
**`physics_test_harness.tscn` / `scripts/physics_test_harness.gd`** — `class_name PhysicsTestHarness`, `extends Node2D`; a **standalone staging scene** (run via **F6**; not wired into the editor). It builds flat ground, angled ramps, and stepped `TerrainBlock` instances via `TerrainUtils`, then instantiates `res://master_rig.tscn` standing on the flat ground. The scene root is a `Node2D` + script with a `Camera2D` at position `(0, -400)` zoom `0.5` and an empty `Environment` container. The camera wheel-zoom scales between **0.25x and 3.0x** (to test resolution independence / vector outline thickness); middle-drag pans. It also hosts the **Dynamic Vector Props** spawner (see below): press **1/2/3** to drop physics props above the angled ramp, plus a best-effort `StaticBody2D` collision proxy for the rig (which has no physics bodies of its own). Pressing **R** toggles the rig's **kinematic-to-ragdoll** mode (see §17): on entry the proxy is removed so the ragdoll collides directly with the terrain, and on exit it is re-added.
|
||||
|
||||
### 16. Dynamic Vector Props
|
||||
|
||||
@@ -345,7 +345,45 @@ 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.
|
||||
**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.
|
||||
|
||||
### 17. Kinematic-to-Ragdoll State System
|
||||
|
||||
`StickmanRig` (the runtime root of `master_rig.tscn`) can switch between two physics modes via a reversible state machine:
|
||||
|
||||
- **`ANIMATED`** (default) — the kinematic `Skeleton2D` + IK puppet is visible and driven by the `AnimationPlayer` / `SkeletonModificationStack2D`. The rig root's momentum is cached each physics frame (`_physics_process` → `_track_momentum()`) so it can be handed off on transition.
|
||||
- **`RAGDOLL`** — the kinematic rig is frozen (IK stack disabled, `AnimationPlayer` stopped) and the `Body/*` visuals hidden; a procedural network of `RigidBody2D` + `PinJoint2D` nodes spawned in code takes over, letting the figure fall/tumble against the terrain and props.
|
||||
|
||||
**Public API (`StickmanRig`):**
|
||||
|
||||
| Member | Signature | Behavior |
|
||||
|---|---|---|
|
||||
| `state` | `var state: RigState` | Current mode — `RigState.ANIMATED` or `RigState.RAGDOLL`. |
|
||||
| `state_changed` | `signal state_changed(new_state: int)` | Emitted on every transition with the `RigState` enum value. |
|
||||
| `is_in_ragdoll()` | `func is_in_ragdoll() -> bool` | `state == RigState.RAGDOLL`. |
|
||||
| `set_ragdoll(enabled)` | `func set_ragdoll(enabled: bool) -> void` | Enters/exits ragdoll; idempotent (no-op when already in the target state). |
|
||||
| `toggle_ragdoll()` | `func toggle_ragdoll() -> void` | `set_ragdoll(not is_in_ragdoll())`. |
|
||||
|
||||
**Momentum handoff:** `_track_momentum(delta)` computes the rig root's linear/angular velocity from per-frame `global_position` / `global_rotation` deltas and caches them. On entering ragdoll, the cached velocities are applied directly to the ragdoll **Torso** body (`linear_velocity` + `angular_velocity`), so the figure continues its current motion seamlessly.
|
||||
|
||||
**Ragdoll construction (`_build_ragdoll()`):** bodies and joints are built in code and reparented into a `Node2D` container named `"RagdollBodyContainer"` added as a child of the rig's **parent** (the world root) — not the rig, which carries its spawn offset — so all bodies/joints are placed in world coordinates.
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Body count | **10** `RigidBody2D` |
|
||||
| Torso | `CapsuleShape2D` (radius 12, length = distance between the Torso and Head bone origins), mass **8.0** |
|
||||
| Head | `CircleShape2D` (radius 100), mass **2.0** |
|
||||
| 8 limbs | `CapsuleShape2D` (radius 8, length = `bone.length`), masses 1.0–2.0 |
|
||||
| Joint count | **9** `PinJoint2D` (one per non-root body, pinned at the child bone's origin), `softness` 0.0 |
|
||||
| Collision | `collision_layer` / `collision_mask` = **1** (matches `TerrainBlock` / `PropBlock`) |
|
||||
|
||||
**Angular limits** (`_apply_ragdoll_joint_limits()`): elbows/knees fold **only** — the natural bend goes toward +CW (`-5°..+150°`) or its mirrored CCW variant (`-150°..+5°`) depending on limb side/facing, so limbs never hyperextend; shoulders/hips allow ±160°; the neck is free (`angular_limit_enabled = false`).
|
||||
|
||||
**Cleanup / reversion (`_exit_ragdoll()`):** the ragdoll container is `queue_free()`d (freed bodies + joints together), `Body/*` are re-shown, the IK modification stack is re-enabled, and the `AnimationPlayer` is stopped. No orphaned physics nodes remain.
|
||||
|
||||
**Harness trigger (`physics_test_harness.gd`):** pressing **R** calls `_rig.toggle_ragdoll()`. On entering ragdoll the static `RigCollisionProxy` is removed (`_remove_rig_collision_proxy()`); on exit it is re-added. `_add_rig_collision_proxy()` is idempotent (skips when a `"RigCollisionProxy"` child already exists), so rapid toggling leaves no duplicate proxies.
|
||||
|
||||
**No scene edits:** `master_rig.tscn` is unchanged — all ragdoll nodes are spawned procedurally at runtime.
|
||||
|
||||
## File format (`.stk`)
|
||||
|
||||
@@ -485,12 +523,12 @@ 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). **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, and `Body/*` z-order, 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`). 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`; public API `set_facing_profile`/`get_facing_profile`, `set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, `get_bend_joint_global_position()`. 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) the **kinematic-to-ragdoll** state switch, 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`). 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()` with `state` / `enum RigState { ANIMATED, RAGDOLL }`. Null-guarded (`push_warning` + skip). **Not used by the editor.** |
|
||||
| `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). |
|
||||
| `res://scripts/terrain_block.gd` | **Vector Terrain System.** `class_name TerrainBlock`, `extends StaticBody2D` — a reusable vector terrain component building `Polygon2D` (fill) + `Line2D` (border) + `CollisionPolygon2D` (`BUILD_SOLIDS`, supports concave) children in code. |
|
||||
| `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). |
|
||||
| `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). Pressing **R** toggles the rig's kinematic-to-ragdoll mode via `_rig.toggle_ragdoll()`, removing the proxy on entry and re-adding it (idempotently) on exit. |
|
||||
| `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_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`). |
|
||||
@@ -590,3 +628,5 @@ BodyPartPanel.shape_selected() ---(bound to part_name)---> stickman_editor
|
||||
> **Phase 9 Round 6 bugfix:** `StkRigAdapter._compute_mount_transform()` now selects the joint anchor as whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is **nearest the part's stored guide joint** (`center − guide_offset`) when a part carries `guide_offset`. This replaces the per-side family choice and the 180° flip heuristic for that case, fixing the **lower left leg** and **lower right arm**, which were mounted 180° off their bones (the far end attached at the joint) because the user's drawn-side conventions are inconsistent across parts — the stored guide placement is the ground truth for which drawn end is the joint. The nearest-end rule naturally preserves the 180° flip behavior (a flipped part's far end lands nearest the joint), the head chin, and every previously-correct case. Old files without the key keep the family rules + flip heuristic exactly as before. `theta`, `s`, the Round 5 offset `t`, and the head `HEAD_CHIN_DROP` fallback are unchanged. Per docs/phase9_round6_bugfix_spec.md; verified with a 32-assertion headless smoke test.
|
||||
|
||||
> **Phase 9 Round 7:** the test harness (`scripts/test_harness.gd`) now exposes **6** draggable IK handles. `IK_HANDLE_PATHS` gains `"Head"` (`IK_Targets/Head`, the `SkeletonModification2DLookAt` aim point) and `"Torso"` (`IK_Targets/Torso`, whose child `RemoteTransform2D` moves the hip bone). Dragging the **Torso** handle moves **bones only** (no target following) — the marker's `RemoteTransform2D` translates the hip bone, and the whole skeleton + `Body/*` visuals follow rigidly, while the limb/head targets stay put so dragging the figure away from them stretches the limbs toward the stationary targets (per user decision). Dragging the **Head** handle drives the Head bone's LookAt rotation (clamped at the authored ~55° constraint); `Body/Head` follows. `_handle_color()` colors the head marker yellow (`HANDLE_COLOR_HEAD`) and the torso marker magenta (`HANDLE_COLOR_TORSO`); hands stay green, feet blue. The IK overlay additionally draws a null-guarded semi-transparent yellow aim line from the Head bone origin to the head marker (visual aid for the LookAt test). **No `.stk` format change.** Per docs/phase9_round7_feature_spec.md; verified with a 17-assertion headless test.
|
||||
|
||||
> **Phase 10 (Kinematic-to-Ragdoll):** adds a reversible **kinematic-to-ragdoll** state switch to the runtime rig. `StickmanRig` gains `enum RigState { ANIMATED, RAGDOLL }`, `var state: RigState`, `signal state_changed(new_state)`, and the `set_ragdoll(enabled)` / `toggle_ragdoll()` / `is_in_ragdoll()` API. In `RAGDOLL` mode the IK modification stack is disabled, the `AnimationPlayer` stopped, and the `Body/*` visuals hidden; a procedural network of **10** `RigidBody2D` (torso `CapsuleShape2D` mass 8.0, head `CircleShape2D` radius 100, limb capsules radius 8) + **9** `PinJoint2D` (elbow/knee fold-only ±bands, shoulder/hip ±160°, neck free) is built in code and reparented into a `"RagdollBodyContainer"` under the rig's **parent** (world root), layer 1/mask 1 so it collides with terrain and props. The rig root's momentum (tracked in `_physics_process`) is applied to the ragdoll Torso body for a seamless handoff. Exiting frees the ragdoll, re-shows `Body/*`, re-enables IK, and stops the animation. The physics harness presses **R** to toggle, removing the `RigCollisionProxy` on entry and re-adding it (idempotently) on exit. `master_rig.tscn` is **not** modified.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Technical Debt & Optimization Log
|
||||
|
||||
## Overview
|
||||
|
||||
This document tracks known technical debt, optimization opportunities, and minor improvements for the Stickman Studio project. Items are prioritized by impact and effort. Update this log as issues are identified, resolved, or deprioritized.
|
||||
|
||||
---
|
||||
|
||||
## Current Observations
|
||||
|
||||
| # | 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. |
|
||||
| 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. | ✅ Resolved | Closed | Verified during implementation. No action needed. |
|
||||
| 6 | **Recovery Animation Starting Pose** — The `stand_up` animation must work from any captured ragdoll pose. Currently uses a fixed start frame. | High | Open | Investigate blending the captured pose with the animation's first keyframe using an additive blend or a `SkeletonModification` that interpolates. |
|
||||
| 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 | Open | Ensure the kinematic skeleton is snapped to the ragdoll pose _before_ the fade begins (or vice versa) to avoid double‑exposure. |
|
||||
| 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 | Open | Add a slider or spinbox to the harness UI for easier tuning. |
|
||||
| 9 | **Animation Generation DRY** — `create_walk.gd` is a standalone script. It should be merged into a unified `create_animations.gd` that also generates `stand_up` and idle animations. | Medium | Open | Refactor to support parameterized generation (profile, duration, target pose). |
|
||||
| 10 | **Rig Collision Proxy Re‑addition** — The proxy is re‑added on ragdoll exit, but may cause a brief visual pop if it appears while the kinematic rig is visible. | Low | Open | Consider delaying proxy re‑addition until after the recovery animation completes, or fading it in. |
|
||||
|
||||
---
|
||||
|
||||
## Suggested Future Improvements (Beyond Current Scope)
|
||||
|
||||
| Improvement | Description | Priority |
|
||||
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------- |
|
||||
| **Soft Transition Blending** | Add physical blending (joint stiffness ramp) to complement visual crossfade. | Medium |
|
||||
| **Ragdoll Recovery Interruptibility** | Allow the director to force a mode switch mid‑recovery (e.g., if they want the character to ragdoll again immediately). | Low |
|
||||
| **Multiple Rig Support** | Ensure all state variables are instance‑specific (already true) and that the harness can manage multiple rigs. | Future |
|
||||
| **Animation Blending (IK vs. Physics)** | Blend between the animation‑driven pose and the ragdoll pose during transition to prevent snapping. | Future |
|
||||
| **Save/Load for Ragdoll State** | Save the current ragdoll pose to `.stk` (e.g., for storyboarding a fall). | Future |
|
||||
|
||||
---
|
||||
|
||||
## How to Use This Log
|
||||
|
||||
1. **New Issues:** Add a row with `#`, description, `Priority` (Low/Medium/High), `Status` (Open/In Progress/Resolved/Deprecated), and `Notes`.
|
||||
2. **Resolving Issues:** Change `Status` to `Resolved` and add a note with the date and fix details.
|
||||
3. **Deprioritizing:** If an issue is no longer relevant, mark it `Deprecated` with a brief reason.
|
||||
4. **Quarterly Review:** Revisit this log every major milestone to reassess priorities.
|
||||
|
||||
---
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Change |
|
||||
| ---------- | -------------------------------------------------------------- |
|
||||
| 2026-08-26 | Initial creation — migrated observations from Phase 10 review. |
|
||||
|
||||
---
|
||||
|
||||
_End of Log_
|
||||
@@ -0,0 +1,205 @@
|
||||
# Ragdoll ↔ Kinematic Blending & Recovery
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Extend the existing kinematic-to-ragdoll system with two major features:
|
||||
|
||||
- **Blended Transition:** A smooth, visually appealing fade between the kinematic puppet and the ragdoll, eliminating the abrupt "pop" when switching modes.
|
||||
- **Ragdoll Recovery:** The ability for the stickman to autonomously stand back up after falling, transitioning from ragdoll back to animated mode with a "get up" animation.
|
||||
|
||||
These features are essential for a director-driven sandbox where characters can fall, recover, and continue performing actions.
|
||||
|
||||
---
|
||||
|
||||
## 2. Feature 1: Ragdoll ↔ Kinematic Blending (Soft Transition)
|
||||
|
||||
### 2.1. Objective
|
||||
|
||||
Replace the instant mode switch with a gradual transition that blends the visual appearance and physical behavior over a configurable duration (e.g., 0.5–1.0 seconds). This avoids jarring pops and creates a more polished, film‑like effect.
|
||||
|
||||
### 2.2. Approach
|
||||
|
||||
#### 2.2.1. Visual Blending (Opacity Crossfade)
|
||||
|
||||
- During the transition, both the kinematic `Body/*` nodes and the ragdoll `RigidBody2D` bodies are visible.
|
||||
- The kinematic nodes start at full opacity and fade out; the ragdoll bodies start at zero opacity and fade in.
|
||||
- Use a `Tween` or `_process` lerp to drive the `modulate.a` of all relevant nodes over the transition duration.
|
||||
|
||||
#### 2.2.2. Physical Blending (Joint Stiffness Ramp)
|
||||
|
||||
- When entering ragdoll, start with the `PinJoint2D` stiffness (`softness`/`bias`) at a high value (near‑rigid).
|
||||
- Gradually reduce stiffness over the transition period so the limbs become floppy.
|
||||
- Conversely, when exiting ragdoll, ramp stiffness from floppy to rigid before freezing the pose.
|
||||
|
||||
#### 2.2.3. Implementation Outline
|
||||
|
||||
- `StickmanRig` gains a `transition_duration` property (export, default 0.6s).
|
||||
- `_enter_ragdoll()` spawns the ragdoll bodies with `modulate.a = 0.0` and joints at high stiffness.
|
||||
- A `_transition_process(delta)` runs during the blend, updating opacities and joint properties.
|
||||
- Upon completion, the kinematic nodes are hidden (or vice versa) and the system settles into the target state.
|
||||
|
||||
### 2.3. Acceptance Criteria
|
||||
|
||||
- No visible pop when switching modes.
|
||||
- The transition duration is configurable (tunable per director preference).
|
||||
- Both visual and physical blending are synchronized.
|
||||
|
||||
---
|
||||
|
||||
## 3. Feature 2: Ragdoll Recovery (Getting Back Up)
|
||||
|
||||
### 3.1. Objective
|
||||
|
||||
Allow the ragdoll to automatically stand up after it has come to rest. This involves detecting rest, capturing the ragdoll’s final pose, applying that pose to the kinematic skeleton, playing a "stand up" animation, and transitioning back to animated mode.
|
||||
|
||||
### 3.2. Core Components
|
||||
|
||||
#### 3.2.1. Rest Detection
|
||||
|
||||
- Monitor the ragdoll Torso body’s linear and angular velocities.
|
||||
- When both remain below a small threshold (e.g., `0.1 m/s` and `0.1 rad/s`) for a continuous **timeout** (set by the director), trigger recovery.
|
||||
- The timeout must be configurable per character (or globally).
|
||||
|
||||
#### 3.2.2. Pose Capture
|
||||
|
||||
- After rest is detected, read the global positions and rotations of all 10 `RigidBody2D` bodies.
|
||||
- Convert these into local transforms relative to the `StickmanRig` root (or the `Skeleton2D` root).
|
||||
- This captured pose becomes the target for the kinematic bones.
|
||||
|
||||
#### 3.2.3. Kinematic Snap
|
||||
|
||||
- Temporarily disable IK (`SkeletonModificationStack2D.enabled = false`).
|
||||
- Set each `Bone2D` node’s global position and rotation to match the captured ragdoll pose.
|
||||
- This ensures the skeleton matches the ragdoll’s final resting posture.
|
||||
|
||||
#### 3.2.4. Stand-Up Animation
|
||||
|
||||
- Play a "stand up" animation (e.g., `stand_up`) that transitions the skeleton from the captured pose to a neutral standing pose.
|
||||
- The animation should be authored/generated to work from any reasonable rest pose.
|
||||
- Once the animation finishes, re‑enable IK and set the rig back to `ANIMATED` mode.
|
||||
|
||||
### 3.3. Implementation Outline
|
||||
|
||||
- Add a new state `RECOVERING` to `RigState`.
|
||||
- In `_physics_process`, when in `RAGDOLL` mode, track the Torso’s velocity and a rest timer.
|
||||
- When rest timer exceeds `rest_timeout`, call `_start_recovery()`.
|
||||
- `_start_recovery()`:
|
||||
1. Capture ragdoll pose.
|
||||
2. Delete ragdoll bodies (or hide them).
|
||||
3. Snap kinematic skeleton to captured pose.
|
||||
4. Start the `AnimationPlayer` with the `stand_up` animation.
|
||||
5. On animation end, re‑enable IK, show `Body/*`, transition to `ANIMATED`.
|
||||
- The recovery process should be interruptible (e.g., if the director toggles back to ragdoll during recovery).
|
||||
|
||||
### 3.4. Acceptance Criteria
|
||||
|
||||
- Ragdoll automatically stands up after resting for the configured timeout.
|
||||
- The stand‑up motion is smooth and visually convincing.
|
||||
- The character resumes animated behavior after recovery.
|
||||
|
||||
---
|
||||
|
||||
## 4. Animation Generation for Recovery
|
||||
|
||||
### 4.1. Current State
|
||||
|
||||
You have a `create_walk.gd` editor script that generates `walk_left` and `walk_right` animations by keyframing IK target positions.
|
||||
|
||||
### 4.2. Proposed Enhancement: `create_animations.gd`
|
||||
|
||||
Refactor the animation generation into a unified script that can generate:
|
||||
|
||||
- **Walk cycles** (already done)
|
||||
- **Stand‑up animation** (from a "down" pose to standing)
|
||||
- **Idle / breathing** (optional)
|
||||
|
||||
#### 4.2.1. Architecture
|
||||
|
||||
- The script should define a set of **pose templates** (e.g., `POSE_DOWN`, `POSE_STANDING`).
|
||||
- Each template maps IK target names to positions (relative to the rig root).
|
||||
- The stand‑up animation is a blend between the captured pose (first frame) and the standing pose (last frame), with intermediate frames interpolated using a curve.
|
||||
|
||||
#### 4.2.2. Integration
|
||||
|
||||
- The recovery logic will reference a pre‑generated `stand_up` animation stored in the `AnimationPlayer`.
|
||||
- The same animation library (`""`) holds all animations.
|
||||
- The generator script can be run once at authoring time to create the default animations.
|
||||
|
||||
#### 4.2.3. Future‑Proofing
|
||||
|
||||
- The generator could also accept parameters (e.g., `animation_name`, `profile`, `duration`) to make it reusable.
|
||||
|
||||
### 4.3. Acceptance Criteria
|
||||
|
||||
- A single script (`create_animations.gd`) generates `walk_left`, `walk_right`, and `stand_up`.
|
||||
- The `stand_up` animation works from any reasonable rest pose (i.e., it starts from the current skeleton pose, not a fixed start).
|
||||
|
||||
---
|
||||
|
||||
## 5. Director‑Controlled Rest Timeout
|
||||
|
||||
### 5.1. Requirement
|
||||
|
||||
The director (user) should be able to adjust how long the ragdoll stays on the ground before attempting recovery. This is crucial for storytelling—some scenes need a quick recovery, others need a long pause.
|
||||
|
||||
### 5.2. Implementation
|
||||
|
||||
- `StickmanRig` gains an `@export var rest_timeout: float = 2.0` (seconds).
|
||||
- The `PhysicsTestHarness` UI (and ultimately the director UI) will provide a slider or spinbox to modify this value on the selected rig.
|
||||
- The value is read during `_physics_process` to determine when to start recovery.
|
||||
|
||||
### 5.3. Acceptance Criteria
|
||||
|
||||
- The rest timeout is editable in the inspector (or via a UI control).
|
||||
- Changes take effect immediately (no need to reload the rig).
|
||||
|
||||
---
|
||||
|
||||
## 6. Integration Timeline (Suggested Order)
|
||||
|
||||
| Step | Task | Notes |
|
||||
| ---- | ---------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| 1 | Add `transition_duration` and `rest_timeout` exports to `StickmanRig`. | Low risk, sets foundation. |
|
||||
| 2 | Implement rest detection timer in `_physics_process`. | Test by printing when rest is detected. |
|
||||
| 3 | Implement pose capture and kinematic snap. | Manual trigger for testing. |
|
||||
| 4 | Create `create_animations.gd` with a `stand_up` placeholder. | Even a simple interpolation is fine for first pass. |
|
||||
| 5 | Integrate recovery flow (snap → play animation → re‑enable IK). | End‑to‑end test. |
|
||||
| 6 | Implement visual blending (opacity crossfade). | Polish. |
|
||||
| 7 | Implement physical blending (joint stiffness ramp). | Advanced polish. |
|
||||
| 8 | Add UI control for `rest_timeout` in the harness. | Director‑facing. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Risks & Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Recovery animation looks unnatural because the starting pose varies. | Use a generic "push‑up" style animation that starts from a prone position; blend the first frame from the captured pose to the animation’s first keyframe. |
|
||||
| Kinematic snap may cause jitter if the ragdoll pose is unstable. | Add a small stabilization delay (0.1s) after rest detection before capturing. |
|
||||
| Blending physics (joint stiffness) may cause limbs to twitch. | Use a smooth interpolation curve (ease‑in‑out) rather than linear. |
|
||||
| Multiple rigs in the scene may compete for recovery timers. | Each `StickmanRig` manages its own state independently. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Summary of New/Modified Files
|
||||
|
||||
| File | Action |
|
||||
| ---------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `scripts/stickman_rig.gd` | Add transition logic, rest detection, pose capture, recovery state machine. |
|
||||
| `scripts/create_animations.gd` | New file: unified animation generator for walk and stand‑up. |
|
||||
| `scenes/physics_test_harness.tscn` | Add UI controls (slider/spinbox) for `rest_timeout`. |
|
||||
| `scripts/physics_test_harness.gd` | Connect UI to the rig’s `rest_timeout` property. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Acceptance Criteria (Full Feature Set)
|
||||
|
||||
- Switching between animated and ragdoll modes is visually smooth (crossfade).
|
||||
- The ragdoll automatically stands up after resting for the director‑defined duration.
|
||||
- The stand‑up animation is generated procedurally and plays seamlessly.
|
||||
- The director can adjust the rest timeout at runtime.
|
||||
- The system is robust and does not produce orphaned nodes or crashes.
|
||||
|
||||
---
|
||||
|
||||
_End of Plan_
|
||||
@@ -0,0 +1,72 @@
|
||||
# Kinematic-to-Ragdoll Translation
|
||||
|
||||
## 1. Objective
|
||||
|
||||
We want dynamic state-switching system that transitions the `master_rig.tscn` stickman from a kinematic, IK-driven puppet (`ANIMATED` mode) into a fully physical, ragdoll-driven entity (`RAGDOLL` mode). This enables natural falling, tumbling, and collision responses with the physics environment (terrain and props) while preserving the stickman’s current momentum.
|
||||
|
||||
## 2. Scope & Impact
|
||||
|
||||
- **Primary Target:** `scripts/stickman_rig.gd` (the runtime controller of `master_rig.tscn`).
|
||||
- **Secondary Target:** `scripts/physics_test_harness.gd` (adds trigger method).
|
||||
- **Scene Impact:** The `master_rig.tscn` scene file will **not** be modified. All ragdoll nodes (bodies and joints) will be spawned procedurally in code, keeping the scene file clean and maintaining the separation between visual authoring and runtime physics.
|
||||
|
||||
## 3. Core Architecture / Behavior
|
||||
|
||||
### 3.1. State Management
|
||||
|
||||
`StickmanRig` gains a new `state` property with two modes: `ANIMATED` and `RAGDOLL`.
|
||||
|
||||
- **`ANIMATED` (Default):** The skeleton and visual `Body/*` nodes are visible and driven by the `AnimationPlayer` and `SkeletonModificationStack2D` (IK). The script tracks the root node’s velocity every frame to cache momentum.
|
||||
- **`RAGDOLL`:** The kinematic rig is frozen (IK disabled, `AnimationPlayer` stopped), the `Body/*` visual nodes are hidden, and a new network of `RigidBody2D` nodes (with matching collision shapes) is spawned and reparented to the world root. Physical `PinJoint2D` nodes connect these bodies, simulating the bone hierarchy.
|
||||
|
||||
### 3.2. Velocity Handoff (Momentum Preservation)
|
||||
|
||||
To avoid unnatural "freeze" effects when switching, the system captures the `linear_velocity` and `angular_velocity` of the rig root in `ANIMATED` mode (using `_process`). Upon entering `RAGDOLL` mode, these cached velocities are applied directly to the ragdoll’s **Torso** body, ensuring the stickman continues its current motion (falling, sliding, etc.) seamlessly.
|
||||
|
||||
## 4. Implementation Breakdown
|
||||
|
||||
### 4.1. Ragdoll Construction
|
||||
|
||||
A dedicated builder function iterates over a predefined set of `Bone2D` nodes from the `Skeleton2D` hierarchy. For each bone, it spawns a `RigidBody2D` with the following specifications:
|
||||
|
||||
- **Position:** The midpoint of the bone (calculated from `global_position` and `bone.length`).
|
||||
- **Rotation:** Matches the `global_rotation` of the bone.
|
||||
- **Collision Shapes:**
|
||||
- **Head:** Uses a `CircleShape2D` with a radius of **100px** (matching the visual head graphic).
|
||||
- **Other Bones (Limbs & Torso):** Uses a `CapsuleShape2D`. The height matches the bone’s `.length` property, and the radius matches the visual line width (approximately **14px**).
|
||||
- **Mass Distribution:** The Torso is given a significantly higher mass (e.g., `8.0`) compared to limbs (e.g., `1.5`) to create realistic inertia and prevent limbs from dragging the body into orbit upon collision.
|
||||
- **Damping:** `Linear` and `Angular` damping are applied to prevent excessively bouncy or "spaghetti-like" behavior.
|
||||
|
||||
### 4.2. Jointing (Parent-Child Constraints)
|
||||
|
||||
For every child bone that has a valid parent bone, the system spawns a `PinJoint2D`.
|
||||
|
||||
- **Positioning:** The joint is placed at the **bottom of the parent bone**, which aligns with the **top of the child bone**.
|
||||
- **Rotation Limits (Critical for realism):**
|
||||
- **Elbows and Knees:** Angular limits are enforced (strictly restricted to prevent backward bending/hyperextension).
|
||||
- **Head/Neck:** Loose limits or free rotation to allow natural lolling.
|
||||
- **Torso/Shoulders/Hips:** Moderate limits to maintain structural integrity while allowing dynamic twisting.
|
||||
- **Stiffness:** The `softness` and `bias` properties are tuned to prevent limb separation (stretching) under stress.
|
||||
|
||||
### 4.3. Cleanup & Reversion
|
||||
|
||||
The `RAGDOLL` mode is designed to be reversible:
|
||||
|
||||
- When exiting `RAGDOLL` (or reloading the rig), all dynamically spawned `RigidBody2D` and `PinJoint2D` nodes are queued for deletion.
|
||||
- The kinematic `Body/*` nodes are made visible again.
|
||||
- The `SkeletonModificationStack2D` (IK) is re-enabled, and the `AnimationPlayer` is reset to a neutral state.
|
||||
|
||||
## 5. Integration with Test Harness
|
||||
|
||||
The existing `PhysicsTestHarness` scene will be updated to include a user trigger:
|
||||
|
||||
- **Key Binding:** Pressing the **R** key will call the `toggle_ragdoll()` method.
|
||||
- **Collision Proxy Management:** The harness currently places a `RigCollisionProxy` (a large static box) around the rig to allow props to interact with it. When entering `RAGDOLL` mode, this proxy **must be destroyed** immediately; otherwise, the physical ragdoll will spawn inside the box and float unrealistically. The ragdoll will instead collide directly with the actual terrain geometry.
|
||||
|
||||
## 6. Acceptance Criteria (For QA/Testing)
|
||||
|
||||
- **Transition Seamlessness:** Switching to ragdoll mid-walk or mid-air applies the correct linear/angular momentum so the stickman continues the trajectory naturally.
|
||||
- **Terrain Interaction:** The ragdoll must rest, slide, or tumble naturally on flat ground, ramps, and stairs (colliding with the `TerrainBlock` static bodies).
|
||||
- **Structural Integrity:** Limbs should not stretch or detach under gravity or moderate impact. Elbows and knees must not bend backwards.
|
||||
- **Visual Fidelity:** The physical collision shapes should align visually with the hidden `Line2D` bones (no floating collision boxes).
|
||||
- **Cleanup:** Rapid switching between modes (spawning/deleting ragdolls) does not cause memory leaks or orphaned nodes.
|
||||
@@ -0,0 +1,429 @@
|
||||
# Kinematic-to-Ragdoll Translation — Implementation Spec
|
||||
|
||||
> Status: implementation-ready. This spec replaces the plan's open questions with
|
||||
> **verified** facts from the codebase and Godot 4.4 source. Everything below is
|
||||
> checked against the actual files; corrections to the plan are called out in §3.
|
||||
|
||||
---
|
||||
|
||||
## 1. Objective
|
||||
|
||||
Add a reversible `ANIMATED ⇄ RAGDOLL` state switch to the runtime rig
|
||||
(`master_rig.tscn` + `scripts/stickman_rig.gd`). In `RAGDOLL` mode the kinematic
|
||||
`skeleton + IK` puppet is frozen and hidden, and a procedurally-built network of
|
||||
`RigidBody2D` + `PinJoint2D` nodes (spawned in code, never in the scene file)
|
||||
takes over, so the figure tumbles/falls against the terrain and props. The
|
||||
`physics_test_harness` scene gets an **R** key trigger.
|
||||
|
||||
---
|
||||
|
||||
## 2. Verified codebase facts (read these as ground truth)
|
||||
|
||||
### 2.1 Scene tree of `master_rig.tscn` (root = `Master`, script `StickmanRig`)
|
||||
|
||||
```
|
||||
Master (Node2D, script = stickman_rig.gd, class StickmanRig)
|
||||
├── Body (Node2D — sibling of Skeleton2D)
|
||||
│ ├── Body (Line2D 0→400, width 16 — TORSO visual)
|
||||
│ ├── LeftUpperLeg (Line2D 0→200 w16)
|
||||
│ ├── RightUpperLeg (Line2D 0→200 w16)
|
||||
│ ├── LeftLowerLeg (Line2D 0→200 w16)
|
||||
│ ├── RightLowerLeg (Line2D 0→200 w16)
|
||||
│ ├── LeftUpperArm (Line2D 0→175 w16)
|
||||
│ ├── RightUpperArm (Line2D 0→175 w16)
|
||||
│ ├── LeftLowerArm (Line2D 0→200 w16)
|
||||
│ ├── RightLowerArm (Line2D 0→200 w16)
|
||||
│ └── Head (Node2D + inline @tool circle script, radius 100)
|
||||
├── Skeleton2D (modification_stack assigned)
|
||||
│ ├── Torso (Bone2D, root — NO length)
|
||||
│ │ ├── Head (Bone2D, pos (0,-391.5) rel Torso, length 90, bone_angle -90)
|
||||
│ │ │ ├── RayCast_Aim
|
||||
│ │ │ └── Pivot → RemoteTransform2D (→ Body/Head)
|
||||
│ │ ├── LeftUpperArm (Bone2D pos (0,-248), length 168)
|
||||
│ │ │ ├── LeftLowerArm (Bone2D pos (-168,0), length 200)
|
||||
│ │ │ │ └── RemoteTransform2D (→ Body/LeftLowerArm)
|
||||
│ │ │ └── RemoteTransform2D (→ Body/LeftUpperArm)
|
||||
│ │ ├── RightUpperArm (Bone2D pos (0,-248), length 168)
|
||||
│ │ │ ├── RightLowerArm (Bone2D pos (168,0), length 200)
|
||||
│ │ │ │ └── RemoteTransform2D (→ Body/RightLowerArm)
|
||||
│ │ │ └── RemoteTransform2D (→ Body/RightUpperArm)
|
||||
│ │ ├── LeftUpperLeg (Bone2D pos (0,0), length 200)
|
||||
│ │ │ ├── LeftLowerLeg (Bone2D pos (0,200), length 200)
|
||||
│ │ │ │ └── RemoteTransform2D (→ Body/LeftLowerLeg)
|
||||
│ │ │ └── RemoteTransform2D (→ Body/LeftUpperLeg)
|
||||
│ │ ├── RightUpperLeg (Bone2D, length 200) ← NOTE: current scene is 200,
|
||||
│ │ │ ├── RightLowerLeg (Bone2D pos (0,200), length 200) not the stale "90"
|
||||
│ │ │ │ └── RemoteTransform2D (→ Body/RightLowerLeg) mentioned in old docs
|
||||
│ │ │ └── RemoteTransform2D (→ Body/RightUpperLeg)
|
||||
│ │ └── RemoteTransform2D (→ Body/Body, torso driver, rotation π)
|
||||
├── RayCast_Ground
|
||||
├── IK_Targets (Right_Hand, Left_Hand, Right_Leg, Left_Leg, Head, Torso→RT2D, RayCasts)
|
||||
├── AnimationPlayer (libraries: RESET / walk_left / walk_right)
|
||||
└── AnimationTree (active=false, placeholder — out of scope)
|
||||
```
|
||||
|
||||
**Key answers to "where things live":**
|
||||
- `Body/*` visual nodes are **siblings of `Skeleton2D`**, both children of `Master`.
|
||||
They are **not** children of the bones. Each `Body/*` node is driven by a
|
||||
`RemoteTransform2D` that is a **child of the matching bone** (via `remote_path`).
|
||||
- The `RemoteTransform2D` drivers live under the bones, not on the `Body/*` nodes.
|
||||
- The **torso visual** is named `Body/Body` (not `Body/Torso`). The **torso bone**
|
||||
(`Skeleton2D/Torso`) has **no `length`** — it is the hip joint root. The actual
|
||||
spine segment runs from the Torso bone origin (hips) to the Head bone origin
|
||||
(neck), distance ≈ 391.5 px.
|
||||
|
||||
### 2.2 Bone semantics (for ragdoll geometry)
|
||||
|
||||
`Bone2D` extends along its **local +X** axis by `length`. So for any bone:
|
||||
- **origin** = `bone.global_position`
|
||||
- **tip** (far end / child joint) = `bone.to_global(Vector2(bone.length, 0.0))`
|
||||
- **midpoint** = `(origin + tip) / 2`
|
||||
- **rotation** = `bone.global_rotation` (the bone points along +X locally)
|
||||
|
||||
The `Body/*` `Line2D`s are authored along local +Y and their `RemoteTransform2D`
|
||||
drivers add `±π/2` rotation to align them with the bone's +X. This is why
|
||||
`Body/LeftUpperArm` is 175 px but its bone `length` is 168 — **use the Bone2D
|
||||
`length` for collision, not the Line2D points length**.
|
||||
|
||||
### 2.3 Harness integration points (`physics_test_harness.gd`)
|
||||
|
||||
- Scene root `PhysicsTestHarness (Node2D)` at world origin, children `Camera2D`,
|
||||
`Environment` (empty Node2D). Rig is spawned in **code**, not the scene file.
|
||||
- `_spawn_rig()` (line ~146): `RIG_SCENE.instantiate()` → `rig.position =
|
||||
RIG_SPAWN_POSITION (0,-385)` → `add_child(rig)` → `_add_rig_collision_proxy()`.
|
||||
**It does not store the rig reference** — a `_rig` member must be added.
|
||||
- `_add_rig_collision_proxy()` (line ~191): creates `StaticBody2D` named
|
||||
**`"RigCollisionProxy"`** as a direct child of the harness root, `position =
|
||||
RIG_PROXY_CENTER`, with a `RectangleShape2D` child (240×1000). No removal
|
||||
function exists yet — add `_remove_rig_collision_proxy()`.
|
||||
- Key handling: `_input()` → `_handle_key(InputEventKey)` (line ~76) already
|
||||
matches `KEY_1/KEY_2/KEY_3` (guarding `key.pressed` and `key.echo`). **R goes
|
||||
here** as a new `match` arm. `R` is currently unused — no conflict.
|
||||
- `PhysicsTestHarness` does **not** play any animation; the rig stands in rest
|
||||
pose with the modification stack enabled. Momentum at toggle will therefore be
|
||||
≈ 0 in this harness (the momentum path is generic and still specified).
|
||||
|
||||
### 2.4 `StickmanRig` (`scripts/stickman_rig.gd`) internals to reuse
|
||||
|
||||
Existing fields/methods (do not rename): `_nodes_ready`, `_skeleton: Skeleton2D`,
|
||||
`_body_container: Node2D`, `_bend_joint_bones`, `_bend_modifications`;
|
||||
`_ready()` enables `_skeleton.modification_stack.enabled = true`; `_apply_profile()`
|
||||
rewrites IK flags + `Body/*` z-order; `get_bend_joint_global_position()`.
|
||||
The ragdoll system adds **new** members and does not change these.
|
||||
|
||||
### 2.5 AnimationPlayer
|
||||
|
||||
Owned by the rig root (`Master`), direct child, named **`"AnimationPlayer"`.
|
||||
`test_harness.gd` confirms `const ANIMATION_PLAYER_PATH := "AnimationPlayer"`.
|
||||
Ragdoll code resolves it as `get_node_or_null(NodePath("AnimationPlayer"))`.
|
||||
|
||||
### 2.6 Collision layers
|
||||
|
||||
No script sets `collision_layer`/`collision_mask`. `TerrainBlock` (StaticBody2D),
|
||||
`PropBlock` (RigidBody2D), and `RigCollisionProxy` (StaticBody2D) all use Godot
|
||||
defaults: **layer 1, mask 1**. Ragdoll bodies must therefore also use **layer 1,
|
||||
mask 1** so they collide with terrain and props (and props still bounce off them).
|
||||
|
||||
---
|
||||
|
||||
## 3. Plan corrections (verified against Godot 4.4 source)
|
||||
|
||||
1. **`PinJoint2D` DOES have angle limits in Godot 4.4** — the plan's "no limits,
|
||||
must build a custom joint" assumption is wrong. Verified properties (4.4 docs
|
||||
+ `scene/2d/physics/joints/pin_joint_2d.{h,cpp}`):
|
||||
- `softness: float` (default 0)
|
||||
- `angular_limit_enabled: bool` (default false)
|
||||
- `angular_limit_lower: float` (radians, default 0, hint range −180°..180°)
|
||||
- `angular_limit_upper: float` (radians, default 0, hint range −180°..180°)
|
||||
- `motor_enabled: bool`, `motor_target_velocity: float` (rad/s)
|
||||
- Inherited from `Joint2D`: `node_a`, `node_b`, `bias`, `disable_collision`
|
||||
(default **true** — connected bodies won't self-collide, which is what we want).
|
||||
- The pin point = the joint node's **global position**
|
||||
(`joint_make_pin(joint, get_global_position(), …)`).
|
||||
|
||||
**Angle-limit semantics (critical, read carefully)** — from
|
||||
`modules/godot_physics_2d/godot_joints_2d.cpp`:
|
||||
- On joint construction the solver stores
|
||||
`initial_angle = angle_from(parent_body_origin → child_body_origin)` (world
|
||||
space, captured **once**).
|
||||
- Each step it computes
|
||||
`dist = angle( (child_origin − parent_origin).rotated(−initial_angle) )` and
|
||||
clamps `dist` to `[angular_limit_lower, angular_limit_upper]`.
|
||||
- **Therefore the limits are measured in WORLD space**, relative to the
|
||||
spawn-time direction of the parent→child center vector, and **do not follow
|
||||
the parent body's own rotation**.
|
||||
- Practical consequences (documented, accepted for v1):
|
||||
* The limit is an *approximation* of the child's swing angle (it uses body
|
||||
centers, so there is a ~parent_length/2 parallax — monotonic and fine).
|
||||
* Because it is world-frame, the "no backward bend" guarantee holds at the
|
||||
spawn orientation but degrades as the whole figure tumbles. This is the
|
||||
standard ragdoll trade-off; a local-frame custom joint is listed as a
|
||||
**deferred enhancement**, not v1.
|
||||
* The per-limb fold sign (+/−) depends on limb side and facing — the
|
||||
implementer must do a one-time visual check and swap/normalize the
|
||||
`lower`/`upper` pair per joint (the data table in §5 makes this a one-line
|
||||
edit).
|
||||
|
||||
2. **`CapsuleShape2D` semantics** (verified in `modules/godot_physics_2d/godot_shape_2d.cpp`):
|
||||
`height` = **total** capsule height (tip to tip), `radius` = cap radius; the
|
||||
straight section length = `height − 2·radius`; AABB spans local Y
|
||||
`[−height/2, +height/2]`. **Recommendation: `height = bone.length`, `radius =
|
||||
8.0`** so the capsule spans the bone exactly tip-to-tip (rounded caps at the
|
||||
joints), matching the Line2D's round caps. (`width 16 → radius 8`. The plan's
|
||||
"≈14" is a chunkier stability alternative; expose as a named constant.)
|
||||
|
||||
3. **Engine version discrepancy**: `AGENTS.md` says "Godot 4.4" but
|
||||
`project.godot` has `config/features=PackedStringArray("4.7", "Forward Plus")`
|
||||
(project last saved with 4.7). Disk has `Godot_v4.4-stable_win64{,_console}.exe`
|
||||
and `Godot_v4.7.1-stable_win64{,_console}.exe`. The angle-limit API verified
|
||||
here exists in 4.4 and later. Recommend the implementer confirm which binary is
|
||||
canonical (default to **4.4** per AGENTS/task, flag the 4.7 features string).
|
||||
|
||||
4. **`RightUpperLeg.length` is already 200** in the current `master_rig.tscn`
|
||||
(the "90" value in old docs/AGENTS is stale). The ragdoll builder reads
|
||||
`bone.length` live, so this is moot, but do not assume 90 anywhere.
|
||||
|
||||
5. **Momentum**: `StickmanRig` is a `Node2D` — it has no built-in velocity and
|
||||
no angular velocity. Track both from per-frame deltas (see §6.3). In the
|
||||
current harness the rig never moves, so values are ≈ 0; the mechanism is
|
||||
generic for future use.
|
||||
|
||||
---
|
||||
|
||||
## 4. Public API on `StickmanRig`
|
||||
|
||||
```gdscript
|
||||
enum RigState { ANIMATED, RAGDOLL }
|
||||
|
||||
signal state_changed(new_state: int) # emits RigState value
|
||||
|
||||
var state: RigState = RigState.ANIMATED # read-only outside; only setters change it
|
||||
|
||||
func is_in_ragdoll() -> bool # state == RigState.RAGDOLL
|
||||
func set_ragdoll(enabled: bool) -> void # enter if enabled & !in_ragdoll; exit if !enabled & in_ragdoll
|
||||
func toggle_ragdoll() -> void # set_ragdoll(not is_in_ragdoll())
|
||||
```
|
||||
|
||||
`set_ragdoll()` / `toggle_ragdoll()` are the only external entry points. The
|
||||
harness calls `rig.toggle_ragdoll()`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Ragdoll data tables
|
||||
|
||||
### 5.1 Body definitions (10 bodies)
|
||||
|
||||
Ordered so parents are built before children. `bone_path` is relative to the rig
|
||||
root. The torso and head are special-cased in the builder (see §6.2).
|
||||
|
||||
| key | bone_path (Skeleton2D-relative) | parent | shape | length source | radius | mass | lin damp | ang damp |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| `torso` | `Skeleton2D/Torso` | — | capsule | dist(Torso, Head) | 12 | 8.0 | 1.0 | 4.0 |
|
||||
| `head` | `Body/Head` (visual center) | `torso` | circle | radius 100 | 100| 2.0 | 0.5 | 2.0 |
|
||||
| `left_upper_arm` | `Skeleton2D/Torso/LeftUpperArm` | `torso` | capsule | `bone.length` (168)| 8 | 1.5 | 0.5 | 3.0 |
|
||||
| `left_lower_arm` | `Skeleton2D/Torso/LeftUpperArm/LeftLowerArm` | `left_upper_arm` | capsule | `bone.length` (200) | 8 | 1.0 | 0.5 | 3.0 |
|
||||
| `right_upper_arm`| `Skeleton2D/Torso/RightUpperArm` | `torso` | capsule | `bone.length` (168)| 8 | 1.5 | 0.5 | 3.0 |
|
||||
| `right_lower_arm`| `Skeleton2D/Torso/RightUpperArm/RightLowerArm` | `right_upper_arm` | capsule | `bone.length` (200) | 8 | 1.0 | 0.5 | 3.0 |
|
||||
| `left_upper_leg` | `Skeleton2D/Torso/LeftUpperLeg` | `torso` | capsule | `bone.length` (200)| 8 | 2.0 | 0.5 | 3.0 |
|
||||
| `left_lower_leg` | `Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg` | `left_upper_leg` | capsule | `bone.length` (200) | 8 | 1.5 | 0.5 | 3.0 |
|
||||
| `right_upper_leg`| `Skeleton2D/Torso/RightUpperLeg` | `torso` | capsule | `bone.length` (200)| 8 | 2.0 | 0.5 | 3.0 |
|
||||
| `right_lower_leg`| `Skeleton2D/Torso/RightUpperLeg/RightLowerLeg` | `right_upper_leg` | capsule | `bone.length` (200) | 8 | 1.5 | 0.5 | 3.0 |
|
||||
|
||||
Named constants: `RAGDOLL_LIMB_RADIUS := 8.0`, `RAGDOLL_TORSO_RADIUS := 12.0`,
|
||||
`RAGDOLL_HEAD_RADIUS := 100.0`. (Tune `RAGDOLL_LIMB_RADIUS` up to ~14 if limbs
|
||||
tunnel at high speed.)
|
||||
|
||||
### 5.2 Joint definitions (9 joints)
|
||||
|
||||
One `PinJoint2D` per non-root body. `pin_point` = the **child bone's origin**
|
||||
(`child_bone.global_position`), which coincides with the parent bone's tip.
|
||||
`node_a` = parent body, `node_b` = child body.
|
||||
|
||||
| joint | child | parent | pin_point (world) | limits |
|
||||
|---|---|---|---|---|
|
||||
| neck | `head` | `torso` | `Head.global_position` | **free** (`angular_limit_enabled=false`) |
|
||||
| left_shoulder | `left_upper_arm` | `torso` | `LeftUpperArm.global_position` | shoulder/hip band |
|
||||
| left_elbow | `left_lower_arm` | `left_upper_arm` | `LeftLowerArm.global_position` | elbow/knee band |
|
||||
| right_shoulder | `right_upper_arm`| `torso` | `RightUpperArm.global_position` | shoulder/hip band |
|
||||
| right_elbow | `right_lower_arm`| `right_upper_arm`| `RightLowerArm.global_position` | elbow/knee band |
|
||||
| left_hip | `left_upper_leg` | `torso` | `LeftUpperLeg.global_position` | shoulder/hip band |
|
||||
| left_knee | `left_lower_leg` | `left_upper_leg` | `LeftLowerLeg.global_position` | elbow/knee band |
|
||||
| right_hip | `right_upper_leg`| `torso` | `RightUpperLeg.global_position` | shoulder/hip band |
|
||||
| right_knee | `right_lower_leg`| `right_upper_leg`| `RightLowerLeg.global_position` | elbow/knee band |
|
||||
|
||||
Limit bands (radians, relative to spawn angle; **sign to be eyeballed per limb**):
|
||||
|
||||
| band | angular_limit_enabled | lower | upper |
|
||||
|---|---|---|---|
|
||||
| elbow/knee | true | `-deg_to_rad(5)` | `+deg_to_rad(150)` |
|
||||
| shoulder/hip| true | `-deg_to_rad(160)` | `+deg_to_rad(160)` |
|
||||
| neck | false | (n/a) | (n/a) |
|
||||
|
||||
If a joint folds the wrong way in testing, swap its `lower`/`upper` (or negate
|
||||
both) in the table. For a stricter "no hyperextension" at rest, lower
|
||||
elbow/knee to `-deg_to_rad(2)`.
|
||||
|
||||
All joints: `softness = 0.0` (stiff; raise to 0.05–0.2 only if jitter/stretch),
|
||||
`bias` left at default 0 (uses project `default_constraint_bias`),
|
||||
`disable_collision` left `true` (default).
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation design
|
||||
|
||||
### 6.1 State & lifecycle
|
||||
|
||||
- New members: `state: RigState`, `_ragdoll_root: Node2D` (container),
|
||||
`_ragdoll_bodies: Dictionary` (key → `RigidBody2D`), `_prev_global_pos:
|
||||
Vector2`, `_prev_global_rot: float`, `_cached_linear_velocity: Vector2`,
|
||||
`_cached_angular_velocity: float`, `_anim_player: AnimationPlayer`.
|
||||
- `_physics_process(delta)`: `_track_momentum(delta)` (only meaningful while
|
||||
`ANIMATED`; harmless otherwise).
|
||||
|
||||
### 6.2 `_enter_ragdoll()`
|
||||
|
||||
1. Freeze kinematic rig: `_skeleton.modification_stack.enabled = false`;
|
||||
`_anim_player.stop()` (null-guarded); `_body_container.visible = false`.
|
||||
(Leave `RemoteTransform2D` drivers as-is — hidden visuals, zero visual cost.)
|
||||
2. `_build_ragdoll()`:
|
||||
- Create `_ragdoll_root = Node2D.new()`, name `"RagdollBodyContainer"`.
|
||||
- **Reparent to world space**: add as child of `get_parent()` (the harness
|
||||
root, at world origin). Guard: if `get_parent() == null`, fall back to
|
||||
`get_tree().current_scene`. Bodies/joints are placed in **world** coords
|
||||
(their `position == global_position` under an origin parent). Do NOT parent
|
||||
the container under the rig — the rig carries `RIG_SPAWN_POSITION` offset.
|
||||
- For each entry in the body table (parent-before-child order):
|
||||
* Resolve the `Bone2D` via `_skeleton.get_node_or_null` (torso/limbs) or the
|
||||
`Body/Head` visual via `get_node_or_null`. Null → `push_warning` + skip
|
||||
that body (and any joints that reference it).
|
||||
* Compute origin/tip/midpoint/rotation:
|
||||
- limbs: origin=`bone.global_position`, tip=`bone.to_global(Vector2(bone.length,0))`.
|
||||
- torso: origin=`Torso.global_position`, tip=`Head.global_position`
|
||||
(length = that distance).
|
||||
- head: center=`Body/Head.global_position` (the circle center).
|
||||
* Build `RigidBody2D` (name `"Ragdoll_" + key`):
|
||||
- `mass`, `linear_damp`, `angular_damp` from table.
|
||||
- `gravity_scale = 1.0`, `lock_rotation = false`, `freeze = false`.
|
||||
- `collision_layer = 1`, `collision_mask = 1` (defaults; explicit for clarity).
|
||||
- `CollisionShape2D` child: `CapsuleShape2D(height=length, radius=r)` for
|
||||
capsules, or `CircleShape2D(radius=100)` for head.
|
||||
- Position body at midpoint, rotation = bone `global_rotation`
|
||||
(capsules); head rotation irrelevant (circle).
|
||||
- `_ragdoll_root.add_child(body)`; record in `_ragdoll_bodies[key]`.
|
||||
- For each joint entry (skip if either body missing):
|
||||
* `var pin := PinJoint2D.new()`, name `"RagdollPin_" + child_key`.
|
||||
* `pin.position = pin_point` (world). Add to `_ragdoll_root`.
|
||||
* `pin.node_a = pin.get_path_to(parent_body)`; `pin.node_b =
|
||||
pin.get_path_to(child_body)`.
|
||||
* Apply limit band + `softness`. (Set `node_a`/`node_b` **after** adding to
|
||||
the tree so `initial_angle` captures the rest pose — bodies are already
|
||||
positioned, so the reference is correct.)
|
||||
- Momentum handoff: `_ragdoll_bodies["torso"].linear_velocity =
|
||||
_cached_linear_velocity`; `.angular_velocity = _cached_angular_velocity`
|
||||
(set **after** `add_child`).
|
||||
3. `state = RigState.RAGDOLL`; `state_changed.emit(int(state))`.
|
||||
|
||||
### 6.3 `_track_momentum(delta)`
|
||||
|
||||
```gdscript
|
||||
if delta > 0.0:
|
||||
_cached_linear_velocity = (global_position - _prev_global_pos) / delta
|
||||
_cached_angular_velocity = wrapf(global_rotation - _prev_global_rot, -PI, PI) / delta
|
||||
_prev_global_pos = global_position
|
||||
_prev_global_rot = global_rotation
|
||||
```
|
||||
Initialize `_prev_*` in `_ready()`.
|
||||
|
||||
### 6.4 `_exit_ragdoll()`
|
||||
|
||||
1. `_destroy_ragdoll()`: if `_ragdoll_root != null && is_instance_valid`,
|
||||
`_ragdoll_root.queue_free()`; clear `_ragdoll_bodies`, `_ragdoll_root = null`.
|
||||
2. Re-enable kinematic rig: `_body_container.visible = true`;
|
||||
`_skeleton.modification_stack.enabled = true`; `_anim_player.stop()` (leave
|
||||
pose as-is; do not attempt to restore a specific frame — the skeleton keeps
|
||||
its last pose, which for the harness is the rest/IK pose).
|
||||
3. `state = RigState.ANIMATED`; `state_changed.emit(int(state))`.
|
||||
|
||||
### 6.5 `set_ragdoll(enabled)`
|
||||
|
||||
Idempotent guards: entering while already `RAGDOLL` (or exiting while `ANIMATED`)
|
||||
is a no-op. Null-guard `_skeleton`/`_body_container` before use (consistent with
|
||||
the file's `push_warning` style).
|
||||
|
||||
---
|
||||
|
||||
## 7. `physics_test_harness.gd` changes (exact)
|
||||
|
||||
1. Add member: `var _rig: StickmanRig = null`.
|
||||
2. In `_spawn_rig()`, change `as Node2D` → `as StickmanRig`, store `_rig = rig`.
|
||||
3. Add a `match` arm in `_handle_key`:
|
||||
```gdscript
|
||||
KEY_R:
|
||||
if _rig != null:
|
||||
_rig.toggle_ragdoll()
|
||||
if _rig.is_in_ragdoll():
|
||||
_remove_rig_collision_proxy()
|
||||
else:
|
||||
_add_rig_collision_proxy()
|
||||
```
|
||||
4. Add `_remove_rig_collision_proxy()`: find the direct child named
|
||||
`"RigCollisionProxy"` (iterate `get_children()` matching `name`), `queue_free()`
|
||||
if present. Make `_add_rig_collision_proxy()` idempotent: skip if a child named
|
||||
`"RigCollisionProxy"` already exists (prevents duplicates on rapid toggling).
|
||||
|
||||
---
|
||||
|
||||
## 8. Acceptance criteria
|
||||
|
||||
- **Toggle**: pressing **R** hides the stick figure, removes `RigCollisionProxy`,
|
||||
and spawns a physics ragdoll at the same world pose; pressing **R** again frees
|
||||
the ragdoll, restores the figure, and re-adds the proxy.
|
||||
- **Terrain**: ragdoll rests/slides/tumbles on flat ground, ramp, and stairs
|
||||
(collides with `TerrainBlock` bodies); props still bounce off it.
|
||||
- **Integrity**: connected limbs never separate/detach (pin joints hold under
|
||||
gravity); elbows/knees resist hyperextension and full 360° rotation at the
|
||||
spawn orientation (native world-frame limits — see caveat in §3.1).
|
||||
- **Fidelity**: collision capsules/circle line up with the hidden bones (no
|
||||
floating/offset shapes).
|
||||
- **Cleanup**: rapid R toggling leaves no orphaned nodes (proxy + ragdoll
|
||||
container are freed each cycle); no leaked `RigidBody2D`/`PinJoint2D`.
|
||||
- **No scene edits**: `master_rig.tscn` and `physics_test_harness.tscn` are
|
||||
unchanged; everything is code-driven.
|
||||
|
||||
---
|
||||
|
||||
## 9. Verification commands (for the tester phase)
|
||||
|
||||
Godot binaries live in `C:\Godot4\` (`Godot_v4.4-stable_win64_console.exe`,
|
||||
`Godot_v4.7.1-stable_win64_console.exe`). No shell tool is available to the
|
||||
Architect; the Tester runs these:
|
||||
|
||||
```bat
|
||||
:: 1. Whole-project parse/compile check (reports script errors, then quits)
|
||||
C:\Godot4\Godot_v4.4-stable_win64_console.exe --headless --editor --path C:\Godot4\stickman --quit
|
||||
|
||||
:: 2. Headless runtime smoke test of the harness scene (runs N frames, then quits)
|
||||
C:\Godot4\Godot_v4.4-stable_win64_console.exe --headless --path C:\Godot4\stickman res://scenes/physics_test_harness.tscn --quit-after 120
|
||||
```
|
||||
|
||||
- Command 1 catches GDScript syntax/type errors across all scripts.
|
||||
- Command 2 exercises `_ready()` + `_spawn_rig()` + `_add_rig_collision_proxy()`.
|
||||
Headless cannot send the **R** keypress, so the toggle itself must be verified
|
||||
**interactively** via F6 on `physics_test_harness.tscn` (project convention):
|
||||
load scene, press R, observe ragdoll fall; press R again; confirm figure
|
||||
restored and props still collide. Rapid-tap R to check for orphans/leaks
|
||||
(use the editor's remote scene tree).
|
||||
|
||||
---
|
||||
|
||||
## 10. Deferred / out of scope
|
||||
|
||||
- **Local-frame angular limits** (limits that follow the parent's rotation) —
|
||||
would require a custom `Joint2D` or per-frame correction; only pursue if QA
|
||||
deems the world-frame limits insufficient for tumbling realism.
|
||||
- **Disabling `RemoteTransform2D` drivers** while in RAGDOLL — skipped (hidden
|
||||
visuals, negligible cost).
|
||||
- **`AnimationTree`** — untouched placeholder (per existing convention).
|
||||
- **Ragdoll self-collision tuning** — v1 relies on `disable_collision=true` for
|
||||
joint-connected pairs + thin capsules; a dedicated collision layer for
|
||||
intra-ragdoll exclusions is a follow-up if jitter appears.
|
||||
@@ -1,12 +1,12 @@
|
||||
[gd_scene format=3]
|
||||
[gd_scene format=3 uid="uid://dib8otk34bnb7"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/physics_test_harness.gd" id="1_harness"]
|
||||
[ext_resource type="Script" uid="uid://cvejolojthtcs" path="res://scripts/physics_test_harness.gd" id="1_harness"]
|
||||
|
||||
[node name="PhysicsTestHarness" type="Node2D"]
|
||||
[node name="PhysicsTestHarness" type="Node2D" unique_id=1744152756]
|
||||
script = ExtResource("1_harness")
|
||||
|
||||
[node name="Camera2D" type="Camera2D" parent="."]
|
||||
[node name="Camera2D" type="Camera2D" parent="." unique_id=1663373236]
|
||||
position = Vector2(0, -400)
|
||||
zoom = Vector2(0.5, 0.5)
|
||||
|
||||
[node name="Environment" type="Node2D" parent="."]
|
||||
[node name="Environment" type="Node2D" parent="." unique_id=1200920843]
|
||||
|
||||
+106
-17
@@ -4,8 +4,9 @@ extends Node2D
|
||||
##
|
||||
## Builds flat ground, an angled ramp and stepped terrain via the TerrainUtils
|
||||
## factory, spawns a master_rig.tscn instance standing on the flat ground, and
|
||||
## provides camera zoom/pan input. NOT wired into the editor — run standalone
|
||||
## via F6 on res://scenes/physics_test_harness.tscn.
|
||||
## provides camera zoom/pan input plus a top-bar UI (spawn buttons, a
|
||||
## Stickman/Ragdoll mode toggle, and a "Knock Up" force button). NOT wired into
|
||||
## the editor — run standalone via F6 on res://scenes/physics_test_harness.tscn.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
@@ -32,6 +33,9 @@ const RIG_SPAWN_POSITION := Vector2(0.0, -385.0)
|
||||
## Spawn point for dynamic props: above the angled ramp so they tumble down.
|
||||
const PROP_SPAWN_POSITION := Vector2(300.0, -300.0)
|
||||
|
||||
## Upward velocity delta applied by the "Knock Up" button (negative Y = up).
|
||||
const KNOCK_UP_VELOCITY := Vector2(0.0, -450.0)
|
||||
|
||||
## Best-effort collision proxy for the rig (which has no physics bodies): a
|
||||
## static box matching the standing figure's world bounds (x ±120, y 0..-1000).
|
||||
const RIG_PROXY_SIZE := Vector2(240.0, 1000.0)
|
||||
@@ -51,6 +55,13 @@ const RIG_PROXY_CENTER := Vector2(0.0, -500.0)
|
||||
var _is_panning: bool = false
|
||||
var _pan_last: Vector2 = Vector2.ZERO
|
||||
|
||||
## The spawned rig instance (StickmanRig). Stored so the mode toggle can
|
||||
## switch between Stickman (animated) and Ragdoll modes.
|
||||
var _rig: StickmanRig = null
|
||||
|
||||
## Top-bar mode toggle button (text flips "Stickman" <-> "Ragdoll").
|
||||
var _ragdoll_toggle: Button = null
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -59,6 +70,7 @@ func _ready() -> void:
|
||||
_camera.make_current()
|
||||
_build_environment()
|
||||
_spawn_rig()
|
||||
_build_ui()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input (camera zoom / pan)
|
||||
@@ -69,20 +81,6 @@ func _input(event: InputEvent) -> void:
|
||||
_handle_mouse_button(event as InputEventMouseButton)
|
||||
elif event is InputEventMouseMotion:
|
||||
_handle_mouse_motion(event as InputEventMouseMotion)
|
||||
elif event is InputEventKey:
|
||||
_handle_key(event as InputEventKey)
|
||||
|
||||
|
||||
func _handle_key(key: InputEventKey) -> void:
|
||||
if not key.pressed or key.echo:
|
||||
return
|
||||
match key.keycode:
|
||||
KEY_1:
|
||||
_spawn_prop_crate()
|
||||
KEY_2:
|
||||
_spawn_prop_ball()
|
||||
KEY_3:
|
||||
_spawn_prop_plank()
|
||||
|
||||
|
||||
func _handle_mouse_button(mb: InputEventMouseButton) -> void:
|
||||
@@ -107,6 +105,80 @@ func _set_zoom(value: float) -> void:
|
||||
var z := clampf(value, MIN_ZOOM, MAX_ZOOM)
|
||||
_camera.zoom = Vector2(z, z)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Top-bar UI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _build_ui() -> void:
|
||||
var ui := CanvasLayer.new()
|
||||
ui.name = "UI"
|
||||
add_child(ui)
|
||||
|
||||
var top_bar := PanelContainer.new()
|
||||
top_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
|
||||
top_bar.offset_bottom = 40.0
|
||||
ui.add_child(top_bar)
|
||||
|
||||
var hbox := HBoxContainer.new()
|
||||
hbox.add_theme_constant_override("separation", 8)
|
||||
top_bar.add_child(hbox)
|
||||
|
||||
var crate_btn := Button.new()
|
||||
crate_btn.text = "Spawn Crate"
|
||||
crate_btn.pressed.connect(_spawn_prop_crate)
|
||||
hbox.add_child(crate_btn)
|
||||
|
||||
var ball_btn := Button.new()
|
||||
ball_btn.text = "Spawn Ball"
|
||||
ball_btn.pressed.connect(_spawn_prop_ball)
|
||||
hbox.add_child(ball_btn)
|
||||
|
||||
var plank_btn := Button.new()
|
||||
plank_btn.text = "Spawn Plank"
|
||||
plank_btn.pressed.connect(_spawn_prop_plank)
|
||||
hbox.add_child(plank_btn)
|
||||
|
||||
_ragdoll_toggle = Button.new()
|
||||
_ragdoll_toggle.toggle_mode = true
|
||||
_ragdoll_toggle.toggled.connect(_on_ragdoll_toggled)
|
||||
hbox.add_child(_ragdoll_toggle)
|
||||
_update_ragdoll_toggle()
|
||||
|
||||
var knock_btn := Button.new()
|
||||
knock_btn.text = "Knock Up"
|
||||
knock_btn.pressed.connect(_knock_up)
|
||||
hbox.add_child(knock_btn)
|
||||
|
||||
|
||||
func _on_ragdoll_toggled(pressed: bool) -> void:
|
||||
if _rig == null:
|
||||
return
|
||||
_rig.set_ragdoll(pressed)
|
||||
if pressed:
|
||||
_remove_rig_collision_proxy()
|
||||
else:
|
||||
_add_rig_collision_proxy()
|
||||
_update_ragdoll_toggle()
|
||||
|
||||
|
||||
func _update_ragdoll_toggle() -> void:
|
||||
if _ragdoll_toggle == null or _rig == null:
|
||||
return
|
||||
var in_ragdoll: bool = _rig.is_in_ragdoll()
|
||||
_ragdoll_toggle.set_pressed_no_signal(in_ragdoll)
|
||||
_ragdoll_toggle.text = "Ragdoll" if in_ragdoll else "Stickman"
|
||||
|
||||
|
||||
func _knock_up() -> void:
|
||||
# Ragdoll: boost every ragdoll body upward (preserves internal structure).
|
||||
if _rig != null and _rig.is_in_ragdoll():
|
||||
_rig.apply_ragdoll_velocity_boost(KNOCK_UP_VELOCITY)
|
||||
# Dynamic props: same upward delta so everything flies together.
|
||||
for child: Node in _environment.get_children():
|
||||
if child is RigidBody2D and is_instance_valid(child):
|
||||
var body := child as RigidBody2D
|
||||
body.apply_central_impulse(KNOCK_UP_VELOCITY * body.mass)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Environment / rig construction
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -144,12 +216,13 @@ func _build_environment() -> void:
|
||||
|
||||
|
||||
func _spawn_rig() -> void:
|
||||
var rig := RIG_SCENE.instantiate() as Node2D
|
||||
var rig := RIG_SCENE.instantiate() as StickmanRig
|
||||
if rig == null:
|
||||
push_warning("PhysicsTestHarness: failed to instantiate master_rig.tscn.")
|
||||
return
|
||||
rig.position = RIG_SPAWN_POSITION
|
||||
add_child(rig)
|
||||
_rig = rig
|
||||
_add_rig_collision_proxy()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -189,6 +262,9 @@ func _spawn_prop_plank() -> void:
|
||||
## The rig has no physics bodies, so a code-only StaticBody2D proxy provides a
|
||||
## collision surface matching its standing bounds. Props bounce/rest against it.
|
||||
func _add_rig_collision_proxy() -> void:
|
||||
if _find_rig_collision_proxy() != null:
|
||||
return
|
||||
|
||||
var proxy := StaticBody2D.new()
|
||||
proxy.name = "RigCollisionProxy"
|
||||
proxy.position = RIG_PROXY_CENTER
|
||||
@@ -201,3 +277,16 @@ func _add_rig_collision_proxy() -> void:
|
||||
proxy.add_child(shape)
|
||||
|
||||
add_child(proxy)
|
||||
|
||||
|
||||
func _remove_rig_collision_proxy() -> void:
|
||||
var proxy := _find_rig_collision_proxy()
|
||||
if proxy != null:
|
||||
proxy.queue_free()
|
||||
|
||||
|
||||
func _find_rig_collision_proxy() -> Node:
|
||||
for child: Node in get_children():
|
||||
if child.name == "RigCollisionProxy":
|
||||
return child
|
||||
return null
|
||||
|
||||
@@ -19,6 +19,10 @@ enum FacingProfile { LEFT, RIGHT, FORWARD }
|
||||
## Per-joint bend direction. INVERTED == flip_bend_direction = true.
|
||||
enum BendDirection { NORMAL, INVERTED }
|
||||
|
||||
## Rig physics mode. ANIMATED drives the skeleton + IK; RAGDOLL swaps in a
|
||||
## procedural RigidBody2D + PinJoint2D network (see _build_ragdoll).
|
||||
enum RigState { ANIMATED, RAGDOLL }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -78,6 +82,55 @@ const Z_ORDER_BY_PROFILE: Dictionary = {
|
||||
],
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ragdoll constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const ANIMATION_PLAYER_PATH := "AnimationPlayer"
|
||||
const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
|
||||
|
||||
const RAGDOLL_LIMB_RADIUS := 8.0
|
||||
const RAGDOLL_TORSO_RADIUS := 12.0
|
||||
const RAGDOLL_HEAD_RADIUS := 100.0
|
||||
|
||||
## 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
|
||||
## ragdoll body gets a matching visible mesh (Line2D capsule / Polygon2D circle).
|
||||
const RAGDOLL_VISUAL_COLOR := Color(0.445488, 0.445488, 0.445488)
|
||||
const RAGDOLL_HEAD_VISUAL_COLOR := Color.WHITE
|
||||
const RAGDOLL_CIRCLE_SEGMENTS := 32
|
||||
|
||||
## Ragdoll body definitions, ordered parent-before-child. `node_path` is
|
||||
## Skeleton2D-relative for bones and rig-root-relative for the head visual.
|
||||
## `kind` is "bone" (capsule along a Bone2D) or "visual" (circle at Body/Head).
|
||||
const RAGDOLL_BODIES: Array[Dictionary] = [
|
||||
{ "key": "torso", "kind": "bone", "node_path": "Torso", "parent": "", "shape": "capsule", "radius": RAGDOLL_TORSO_RADIUS, "mass": 8.0, "linear_damp": 1.0, "angular_damp": 4.0 },
|
||||
{ "key": "head", "kind": "visual", "node_path": "Body/Head", "parent": "torso", "shape": "circle", "radius": RAGDOLL_HEAD_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 2.0 },
|
||||
{ "key": "left_upper_arm", "kind": "bone", "node_path": "Torso/LeftUpperArm", "parent": "torso", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
{ "key": "left_lower_arm", "kind": "bone", "node_path": "Torso/LeftUpperArm/LeftLowerArm", "parent": "left_upper_arm", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.0, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
{ "key": "right_upper_arm", "kind": "bone", "node_path": "Torso/RightUpperArm", "parent": "torso", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
{ "key": "right_lower_arm", "kind": "bone", "node_path": "Torso/RightUpperArm/RightLowerArm", "parent": "right_upper_arm", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.0, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
{ "key": "left_upper_leg", "kind": "bone", "node_path": "Torso/LeftUpperLeg", "parent": "torso", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
{ "key": "left_lower_leg", "kind": "bone", "node_path": "Torso/LeftUpperLeg/LeftLowerLeg", "parent": "left_upper_leg", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
{ "key": "right_upper_leg", "kind": "bone", "node_path": "Torso/RightUpperLeg", "parent": "torso", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
{ "key": "right_lower_leg", "kind": "bone", "node_path": "Torso/RightUpperLeg/RightLowerLeg", "parent": "right_upper_leg", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
]
|
||||
|
||||
## Ragdoll joint definitions: one PinJoint2D per non-root body, pinned at the
|
||||
## child bone's origin. `limit` selects the angular-limit band (neck free,
|
||||
## shoulder/hip ±160°, elbow/knee −5°..+150°, or its mirrored CCW variant).
|
||||
const RAGDOLL_JOINTS: Array[Dictionary] = [
|
||||
{ "child": "head", "parent": "torso", "pin_node_path": "Torso/Head", "limit": "neck" },
|
||||
{ "child": "left_upper_arm", "parent": "torso", "pin_node_path": "Torso/LeftUpperArm", "limit": "shoulder_hip" },
|
||||
{ "child": "left_lower_arm", "parent": "left_upper_arm", "pin_node_path": "Torso/LeftUpperArm/LeftLowerArm", "limit": "elbow_knee" },
|
||||
{ "child": "right_upper_arm", "parent": "torso", "pin_node_path": "Torso/RightUpperArm", "limit": "shoulder_hip" },
|
||||
{ "child": "right_lower_arm", "parent": "right_upper_arm", "pin_node_path": "Torso/RightUpperArm/RightLowerArm", "limit": "elbow_knee_ccw" },
|
||||
{ "child": "left_upper_leg", "parent": "torso", "pin_node_path": "Torso/LeftUpperLeg", "limit": "shoulder_hip" },
|
||||
{ "child": "left_lower_leg", "parent": "left_upper_leg", "pin_node_path": "Torso/LeftUpperLeg/LeftLowerLeg", "limit": "elbow_knee_ccw" },
|
||||
{ "child": "right_upper_leg", "parent": "torso", "pin_node_path": "Torso/RightUpperLeg", "limit": "shoulder_hip" },
|
||||
{ "child": "right_lower_leg", "parent": "right_upper_leg", "pin_node_path": "Torso/RightUpperLeg/RightLowerLeg", "limit": "elbow_knee" },
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exported controls
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -124,6 +177,10 @@ signal facing_profile_changed(profile: int)
|
||||
## flip_bend_direction value (true == inverted).
|
||||
signal bend_flag_changed(joint: String, flipped: bool)
|
||||
|
||||
## Emitted when the rig's ANIMATED/RAGDOLL state changes. `new_state` carries
|
||||
## the RigState enum value.
|
||||
signal state_changed(new_state: int)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal state
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -134,6 +191,20 @@ var _body_container: Node2D = null
|
||||
var _bend_joint_bones: Dictionary = {} # { String : Bone2D } (lower bones)
|
||||
var _bend_modifications: Dictionary = {} # { String : SkeletonModification2DTwoBoneIK }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rig state (ANIMATED / RAGDOLL)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var state: RigState = RigState.ANIMATED
|
||||
|
||||
var _ragdoll_root: Node2D = null
|
||||
var _ragdoll_bodies: Dictionary = {} # { String : RigidBody2D }
|
||||
var _anim_player: AnimationPlayer = null
|
||||
var _prev_global_pos: Vector2 = Vector2.ZERO
|
||||
var _prev_global_rot: float = 0.0
|
||||
var _cached_linear_velocity: Vector2 = Vector2.ZERO
|
||||
var _cached_angular_velocity: float = 0.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -148,6 +219,10 @@ func _ready() -> void:
|
||||
if _body_container == null:
|
||||
push_warning("StickmanRig: missing '%s' node in rig." % BODY_CONTAINER_PATH)
|
||||
|
||||
_anim_player = get_node_or_null(NodePath(ANIMATION_PLAYER_PATH)) as AnimationPlayer
|
||||
if _anim_player == null:
|
||||
push_warning("StickmanRig: missing '%s' node in rig." % ANIMATION_PLAYER_PATH)
|
||||
|
||||
_resolve_bend_modifications()
|
||||
|
||||
# Enable the modification stack (IK solves only at runtime).
|
||||
@@ -165,6 +240,20 @@ func _ready() -> void:
|
||||
# setters only stored values).
|
||||
_nodes_ready = true
|
||||
_apply_profile()
|
||||
_prev_global_pos = global_position
|
||||
_prev_global_rot = global_rotation
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_track_momentum(delta)
|
||||
|
||||
|
||||
func _track_momentum(delta: float) -> void:
|
||||
if delta > 0.0:
|
||||
_cached_linear_velocity = (global_position - _prev_global_pos) / delta
|
||||
_cached_angular_velocity = wrapf(global_rotation - _prev_global_rot, -PI, PI) / delta
|
||||
_prev_global_pos = global_position
|
||||
_prev_global_rot = global_rotation
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
@@ -221,6 +310,33 @@ func get_bend_joint_global_position(joint: String) -> Vector2:
|
||||
return Vector2.ZERO
|
||||
return bone.global_position
|
||||
|
||||
|
||||
func is_in_ragdoll() -> bool:
|
||||
return state == RigState.RAGDOLL
|
||||
|
||||
|
||||
func set_ragdoll(enabled: bool) -> void:
|
||||
if enabled and not is_in_ragdoll():
|
||||
_enter_ragdoll()
|
||||
elif not enabled and is_in_ragdoll():
|
||||
_exit_ragdoll()
|
||||
|
||||
|
||||
func toggle_ragdoll() -> void:
|
||||
set_ragdoll(not is_in_ragdoll())
|
||||
|
||||
|
||||
## Applies the same velocity delta to every ragdoll body via a mass-scaled
|
||||
## central impulse, preserving the ragdoll's internal structure. No-op outside
|
||||
## RAGDOLL mode. Used by the physics harness "Knock Up" button.
|
||||
func apply_ragdoll_velocity_boost(velocity: Vector2) -> void:
|
||||
if not is_in_ragdoll():
|
||||
return
|
||||
for key: String in _ragdoll_bodies:
|
||||
var body := _ragdoll_bodies[key] as RigidBody2D
|
||||
if body != null and is_instance_valid(body):
|
||||
body.apply_central_impulse(velocity * body.mass)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal resolution / apply
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -306,3 +422,207 @@ func _apply_body_z_order() -> void:
|
||||
var part := _body_container.get_node_or_null(NodePath(part_name))
|
||||
if part != null:
|
||||
_body_container.move_child(part, _body_container.get_child_count() - 1)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ragdoll translation (kinematic -> physics)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _enter_ragdoll() -> void:
|
||||
if _skeleton == null or _body_container == null:
|
||||
push_warning("StickmanRig: cannot enter ragdoll; missing rig nodes.")
|
||||
return
|
||||
# Freeze the kinematic puppet: disable IK, stop animation, hide visuals.
|
||||
if _skeleton.modification_stack != null:
|
||||
_skeleton.modification_stack.enabled = false
|
||||
if _anim_player != null and is_instance_valid(_anim_player):
|
||||
_anim_player.stop()
|
||||
_body_container.visible = false
|
||||
_build_ragdoll()
|
||||
state = RigState.RAGDOLL
|
||||
state_changed.emit(int(state))
|
||||
|
||||
|
||||
func _exit_ragdoll() -> void:
|
||||
_destroy_ragdoll()
|
||||
if _body_container != null and is_instance_valid(_body_container):
|
||||
_body_container.visible = true
|
||||
if _skeleton != null and is_instance_valid(_skeleton):
|
||||
if _skeleton.modification_stack != null:
|
||||
_skeleton.modification_stack.enabled = true
|
||||
if _anim_player != null and is_instance_valid(_anim_player):
|
||||
_anim_player.stop()
|
||||
state = RigState.ANIMATED
|
||||
state_changed.emit(int(state))
|
||||
|
||||
|
||||
func _build_ragdoll() -> void:
|
||||
var parent: Node = get_parent()
|
||||
if parent == null:
|
||||
parent = get_tree().current_scene
|
||||
if parent == null:
|
||||
push_warning("StickmanRig: cannot reparent ragdoll container; no parent or current scene.")
|
||||
return
|
||||
|
||||
_ragdoll_root = Node2D.new()
|
||||
_ragdoll_root.name = RAGDOLL_CONTAINER_NAME
|
||||
parent.add_child(_ragdoll_root)
|
||||
|
||||
_ragdoll_bodies.clear()
|
||||
for entry: Dictionary in RAGDOLL_BODIES:
|
||||
_build_ragdoll_body(entry)
|
||||
for entry: Dictionary in RAGDOLL_JOINTS:
|
||||
_build_ragdoll_joint(entry)
|
||||
|
||||
var torso := _ragdoll_bodies.get("torso") as RigidBody2D
|
||||
if torso != null:
|
||||
torso.linear_velocity = _cached_linear_velocity
|
||||
torso.angular_velocity = _cached_angular_velocity
|
||||
|
||||
|
||||
func _build_ragdoll_body(entry: Dictionary) -> void:
|
||||
var key: String = entry["key"]
|
||||
var body := RigidBody2D.new()
|
||||
body.name = "Ragdoll_" + key
|
||||
body.mass = float(entry["mass"])
|
||||
body.linear_damp = float(entry["linear_damp"])
|
||||
body.angular_damp = float(entry["angular_damp"])
|
||||
body.gravity_scale = 1.0
|
||||
body.lock_rotation = false
|
||||
body.freeze = false
|
||||
body.collision_layer = 1
|
||||
body.collision_mask = 1
|
||||
|
||||
var shape := CollisionShape2D.new()
|
||||
shape.name = "CollisionShape2D"
|
||||
|
||||
if entry["kind"] == "visual":
|
||||
var visual := get_node_or_null(NodePath(entry["node_path"])) as Node2D
|
||||
if visual == null or not is_instance_valid(visual):
|
||||
push_warning("StickmanRig: missing ragdoll visual node '%s'." % entry["node_path"])
|
||||
body.queue_free()
|
||||
return
|
||||
var circle := CircleShape2D.new()
|
||||
circle.radius = float(entry["radius"])
|
||||
shape.shape = circle
|
||||
body.add_child(shape)
|
||||
body.position = visual.global_position
|
||||
body.rotation = 0.0
|
||||
_add_ragdoll_visual_circle(body, float(entry["radius"]), RAGDOLL_HEAD_VISUAL_COLOR)
|
||||
else:
|
||||
var bone := _skeleton.get_node_or_null(NodePath(entry["node_path"])) as Bone2D
|
||||
if bone == null or not is_instance_valid(bone):
|
||||
push_warning("StickmanRig: missing ragdoll bone '%s'." % entry["node_path"])
|
||||
body.queue_free()
|
||||
return
|
||||
var origin: Vector2 = bone.global_position
|
||||
var tip: Vector2
|
||||
if key == "torso":
|
||||
var head_bone := _skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
|
||||
if head_bone == null or not is_instance_valid(head_bone):
|
||||
push_warning("StickmanRig: missing Head bone for torso ragdoll body.")
|
||||
body.queue_free()
|
||||
return
|
||||
tip = head_bone.global_position
|
||||
else:
|
||||
# A Bone2D's length runs along its local +X rotated by `bone_angle`
|
||||
# (stored in degrees). to_global(Vector2(length, 0)) alone ignores
|
||||
# bone_angle, so rotate the tip vector by it to reach the real
|
||||
# far-end joint (which coincides with the child bone's origin).
|
||||
tip = bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))
|
||||
var length: float = origin.distance_to(tip)
|
||||
var midpoint: Vector2 = (origin + tip) * 0.5
|
||||
var capsule := CapsuleShape2D.new()
|
||||
capsule.height = length
|
||||
capsule.radius = float(entry["radius"])
|
||||
shape.shape = capsule
|
||||
# CapsuleShape2D spans local +Y, but a Bone2D's length runs along local
|
||||
# +X: rotate the shape -90° so the capsule aligns with the body's +X,
|
||||
# which we point along the bone's origin->tip direction below.
|
||||
shape.rotation = -PI / 2.0
|
||||
body.add_child(shape)
|
||||
body.position = midpoint
|
||||
body.rotation = (tip - origin).angle()
|
||||
_add_ragdoll_visual_capsule(body, length, float(entry["radius"]), RAGDOLL_VISUAL_COLOR)
|
||||
|
||||
_ragdoll_root.add_child(body)
|
||||
_ragdoll_bodies[key] = body
|
||||
|
||||
|
||||
## Visible capsule mesh (Line2D with round caps) spanning the body's local +X,
|
||||
## which `_build_ragdoll_body` already aligns with the bone's origin->tip
|
||||
## direction. Collision shapes never render in-game, so this is what the player
|
||||
## actually sees in RAGDOLL mode.
|
||||
func _add_ragdoll_visual_capsule(body: RigidBody2D, length: float, radius: float, color: Color) -> void:
|
||||
var line := Line2D.new()
|
||||
line.name = "VisualCapsule"
|
||||
line.points = PackedVector2Array([Vector2(-length * 0.5, 0.0), Vector2(length * 0.5, 0.0)])
|
||||
line.width = radius * 2.0
|
||||
line.default_color = color
|
||||
line.begin_cap_mode = Line2D.LINE_CAP_ROUND
|
||||
line.end_cap_mode = Line2D.LINE_CAP_ROUND
|
||||
line.joint_mode = Line2D.LINE_JOINT_ROUND
|
||||
body.add_child(line)
|
||||
|
||||
|
||||
## Visible filled circle for the head body, matching the authored head circle.
|
||||
func _add_ragdoll_visual_circle(body: RigidBody2D, radius: float, color: Color) -> void:
|
||||
var poly := Polygon2D.new()
|
||||
poly.name = "VisualCircle"
|
||||
var points := PackedVector2Array()
|
||||
for i: int in RAGDOLL_CIRCLE_SEGMENTS:
|
||||
var angle: float = TAU * float(i) / float(RAGDOLL_CIRCLE_SEGMENTS)
|
||||
points.append(Vector2(cos(angle), sin(angle)) * radius)
|
||||
poly.polygon = points
|
||||
poly.color = color
|
||||
body.add_child(poly)
|
||||
|
||||
|
||||
func _build_ragdoll_joint(entry: Dictionary) -> void:
|
||||
var child_key: String = entry["child"]
|
||||
var parent_key: String = entry["parent"]
|
||||
var child_body := _ragdoll_bodies.get(child_key) as RigidBody2D
|
||||
var parent_body := _ragdoll_bodies.get(parent_key) as RigidBody2D
|
||||
if child_body == null or parent_body == null:
|
||||
return
|
||||
var pin_bone := _skeleton.get_node_or_null(NodePath(entry["pin_node_path"])) as Bone2D
|
||||
if pin_bone == null or not is_instance_valid(pin_bone):
|
||||
push_warning("StickmanRig: missing pin bone '%s' for ragdoll joint '%s'." % [entry["pin_node_path"], child_key])
|
||||
return
|
||||
|
||||
var pin := PinJoint2D.new()
|
||||
pin.name = "RagdollPin_" + child_key
|
||||
pin.position = pin_bone.global_position
|
||||
_ragdoll_root.add_child(pin)
|
||||
pin.node_a = pin.get_path_to(parent_body)
|
||||
pin.node_b = pin.get_path_to(child_body)
|
||||
pin.softness = 0.0
|
||||
_apply_ragdoll_joint_limits(pin, entry["limit"])
|
||||
|
||||
|
||||
func _apply_ragdoll_joint_limits(pin: PinJoint2D, limit: String) -> void:
|
||||
match limit:
|
||||
"elbow_knee":
|
||||
# Fold (natural bend) toward +CW, resist hyperextension past -5°.
|
||||
pin.angular_limit_enabled = true
|
||||
pin.angular_limit_lower = -deg_to_rad(5.0)
|
||||
pin.angular_limit_upper = deg_to_rad(150.0)
|
||||
"elbow_knee_ccw":
|
||||
# Mirrored limb: its natural bend folds -CCW (the rig's TwoBoneIK
|
||||
# bend flag for this limb is inverted), so the large allowance goes
|
||||
# on the negative side and hyperextension is capped at +5°.
|
||||
pin.angular_limit_enabled = true
|
||||
pin.angular_limit_lower = -deg_to_rad(150.0)
|
||||
pin.angular_limit_upper = deg_to_rad(5.0)
|
||||
"shoulder_hip":
|
||||
pin.angular_limit_enabled = true
|
||||
pin.angular_limit_lower = -deg_to_rad(160.0)
|
||||
pin.angular_limit_upper = deg_to_rad(160.0)
|
||||
_:
|
||||
pin.angular_limit_enabled = false
|
||||
|
||||
|
||||
func _destroy_ragdoll() -> void:
|
||||
if _ragdoll_root != null and is_instance_valid(_ragdoll_root):
|
||||
_ragdoll_root.queue_free()
|
||||
_ragdoll_root = null
|
||||
_ragdoll_bodies.clear()
|
||||
|
||||
Reference in New Issue
Block a user