Refactor animation generation: replace create_walk.gd with create_animations.gd

- Removed the old create_walk.gd script, which generated walk animations.
- Introduced create_animations.gd to unify the generation of walk_left, walk_right, and stand_up animations.
- Added a new SpinBox for rest timeout configuration in physics_test_harness.gd.
- Enhanced StickmanRig to support automatic recovery from ragdoll state with configurable timeout.
- Implemented recovery logic in StickmanRig, allowing for smooth transitions from ragdoll to animated state.
- Updated animation generation logic to use new pose templates for standing and lying down positions.
This commit is contained in:
2026-08-27 12:01:54 -04:00
parent e3df1cc5c0
commit 0e971d99b1
12 changed files with 678 additions and 159 deletions
+1 -1
View File
@@ -58,7 +58,7 @@ You are an expert Godot 4 game developer and code reviewer. Your purpose is to a
- Enforce static typing wherever possible: `var health: int = 100` or `func take_damage(amount: float) -> void:`. - Enforce static typing wherever possible: `var health: int = 100` or `func take_damage(amount: float) -> void:`.
- Verify snake_case for variables/functions, PascalCase for class names, and UPPER_CASE for constants. - Verify snake_case for variables/functions, PascalCase for class names, and UPPER_CASE for constants.
- Check for proper use of `@export` annotations for inspector variables. - Check for proper use of `@export` annotations for inspector variables.
- Verify syntax using '..\Godot_v4.4-stable_win64_console.exe" . --check-only' - Verify syntax using '..\Godot_v4.7.1-stable_win64_console.exe" . --check-only'
## 5. Response Output Format ## 5. Response Output Format
+1 -1
View File
@@ -60,7 +60,7 @@ Use the built-in GUT lifecycle methods properly:
- Use `yield_to()` or `yield_for()` when waiting for `signals` or timers. - Use `yield_to()` or `yield_for()` when waiting for `signals` or timers.
- Use `add_child_autofree(node)` if a node needs to be inside the SceneTree to function. - Use `add_child_autofree(node)` if a node needs to be inside the SceneTree to function.
- Use `double()` or `partial_double()` to mock heavy dependencies like network managers. - Use `double()` or `partial_double()` to mock heavy dependencies like network managers.
- Verify syntax using '..\Godot_v4.4-stable_win64_console.exe" . --check-only' - Verify syntax using '..\Godot_v4.7.1-stable_win64_console.exe" . --check-only'
## 📝 Reference Code Template ## 📝 Reference Code Template
+77 -22
View File
@@ -218,7 +218,8 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
Task 4). Attached to the `Master` root node of `master_rig.tscn`. **Non-`@tool`** — node Task 4). Attached to the `Master` root node of `master_rig.tscn`. **Non-`@tool`** — node
resolution, flag writes, and z-order reordering run only at runtime (`_ready` + setters on a resolution, flag writes, and z-order reordering run only at runtime (`_ready` + setters on a
live instance). Enums `FacingProfile { LEFT, RIGHT, FORWARD }` (values are the harness facing-menu live instance). Enums `FacingProfile { LEFT, RIGHT, FORWARD }` (values are the harness facing-menu
ids) and `BendDirection { NORMAL, INVERTED }`. Constants (moved from the harness): `SKELETON_PATH`, ids), `BendDirection { NORMAL, INVERTED }`, and `RigState { ANIMATED, RAGDOLL, RECOVERING }`.
Constants (moved from the harness): `SKELETON_PATH`,
`BODY_CONTAINER_PATH`, `BEND_JOINTS` (`["LeftArm","RightArm","LeftLeg","RightLeg"]`), `BODY_CONTAINER_PATH`, `BEND_JOINTS` (`["LeftArm","RightArm","LeftLeg","RightLeg"]`),
`BEND_JOINT_BONE_PATHS` (each joint → its lower `Bone2D` NodePath relative to `Skeleton2D`), `BEND_JOINT_BONE_PATHS` (each joint → its lower `Bone2D` NodePath relative to `Skeleton2D`),
`PROFILE_FLAGS` (per-profile `flip_bend_direction` sets), `Z_ORDER_BY_PROFILE` (per-profile `PROFILE_FLAGS` (per-profile `flip_bend_direction` sets), `Z_ORDER_BY_PROFILE` (per-profile
@@ -226,7 +227,11 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
`FORWARD`, a preset whose setter writes the four per-joint vars + reorders `Body/*`) and an `FORWARD`, a preset whose setter writes the four per-joint vars + reorders `Body/*`) and an
`@export_group("Bend Direction")` of four `@export_enum("Normal","Inverted")` vars `@export_group("Bend Direction")` of four `@export_enum("Normal","Inverted")` vars
`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend` (defaults `left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend` (defaults
NORMAL/INVERTED/INVERTED/NORMAL = FORWARD). Signals `facing_profile_changed(profile)` / NORMAL/INVERTED/INVERTED/NORMAL = FORWARD). Recovery exports: `rest_timeout` (2.0 s),
`auto_recover` (true), plus recovery constants
`STAND_POSE`/`IK_TARGET_PATHS`/`REST_LINEAR_THRESHOLD`/`REST_ANGULAR_THRESHOLD`/
`STAND_UP_DURATION`/`STABILIZATION_DELAY`/`RAGDOLL_TARGET_SOFTNESS`.
Signals `facing_profile_changed(profile)` /
`bend_flag_changed(joint, flipped)`. Public API: `set_facing_profile`/`get_facing_profile`, `bend_flag_changed(joint, flipped)`. Public API: `set_facing_profile`/`get_facing_profile`,
`set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, and `set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, and
`get_bend_joint_global_position(joint)` (unknown joint → `push_warning` + no-op/`false`/ `get_bend_joint_global_position(joint)` (unknown joint → `push_warning` + no-op/`false`/
@@ -237,26 +242,57 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
setter timing during `PackedScene.instantiate()`). Null-guards + `push_warning` prefixed setter timing during `PackedScene.instantiate()`). Null-guards + `push_warning` prefixed
`"StickmanRig: "` throughout; never crashes. `"StickmanRig: "` throughout; never crashes.
- **Phase 10 ragdoll state system:** `StickmanRig` owns a reversible `ANIMATED ⇄ RAGDOLL` - **Phase 10 ragdoll state system:** `StickmanRig` owns a reversible `ANIMATED ⇄ RAGDOLL`
physics mode switch. `enum RigState { ANIMATED, RAGDOLL }`, `var state: RigState` physics mode switch plus a `RECOVERING` stand-up state. `enum RigState { ANIMATED, RAGDOLL,
(default `ANIMATED`), `signal state_changed(new_state: int)`, and public API RECOVERING }`, `var state: RigState` (default `ANIMATED`), `signal state_changed(new_state:
`set_ragdoll(enabled: bool)` / `toggle_ragdoll()` / `is_in_ragdoll() -> bool`. int)`, and public API `set_ragdoll(enabled: bool)` / `toggle_ragdoll()` / `is_in_ragdoll() ->
`_physics_process()` → `_track_momentum(delta)` caches the rig root's linear/angular bool` / `request_recovery()`. `_physics_process()` → `_track_momentum(delta)` caches the rig
velocity from per-frame `global_position`/`global_rotation` deltas. `_enter_ragdoll()` root's linear/angular velocity from per-frame `global_position`/`global_rotation` deltas, then
disables the IK modification stack, `stop()`s the `AnimationPlayer` (`ANIMATION_PLAYER_PATH` drives `_update_rest_detection()` (auto-recovery trigger). `_enter_ragdoll()` `stop(true)`s the
const), hides `Body/*`, builds the ragdoll, then sets `state` + emits `state_changed`. `AnimationPlayer` (`ANIMATION_PLAYER_PATH` const, keep_state — no pose reset), builds the
`_build_ragdoll()` creates a `Node2D` container `"RagdollBodyContainer"` under the rig's ragdoll from the CURRENT solved bone positions while the IK stack is still enabled (disabling
**parent** (world root; fallback `get_tree().current_scene`) and populates it from the it first would revert the bones to the authored rest pose, popping the figure), then hides
`RAGDOLL_BODIES` table (**10** `RigidBody2D`: torso `CapsuleShape2D` radius 12 mass 8.0, `Body/*` and disables the IK stack **immediately** — an instant handoff with no crossfade (the
head `CircleShape2D` radius 100 mass 2.0, limb capsules radius 8 masses 1.02.0; ragdoll spawns at exactly the same pose, so a fade would only read as ghosting), sets `state =
`collision_layer`/`collision_mask` = 1) and the `RAGDOLL_JOINTS` table (**9** `PinJoint2D`, RAGDOLL` + emits. `_build_ragdoll()` creates a `Node2D`
one per non-root body pinned at the child bone's origin, `softness` 0.0). Angular limits via container `"RagdollBodyContainer"` under the rig's **parent** (world root; fallback
`_apply_ragdoll_joint_limits()`: `elbow_knee` folds +CW `-5°..+150°`, `elbow_knee_ccw` `get_tree().current_scene`) and populates it from the `RAGDOLL_BODIES` table (**10**
`-150°..+5°`, `shoulder_hip` ±160°, default (neck) free. Cached momentum is applied to the `RigidBody2D`: torso `CapsuleShape2D` radius 12 mass 8.0, head `CircleShape2D` radius 100 mass
torso body. `apply_ragdoll_velocity_boost(velocity)` applies the same velocity delta 2.0, limb capsules radius 8 masses 1.02.0; `collision_layer`/`collision_mask` = 1, bodies
(mass-scaled `apply_central_impulse`) to every ragdoll body — used by the harness "Knock spawn fully visible) and the `RAGDOLL_JOINTS` table (**9** `PinJoint2D`, one per
Up" button. `_exit_ragdoll()` `queue_free()`s the container, re-shows `Body/*`, re-enables non-root body pinned at the child bone's origin, `softness = RAGDOLL_TARGET_SOFTNESS` at
the IK stack, `stop()`s the animation, and reverts `state` to `ANIMATED`. All ragdoll nodes build). Angular limits via `_apply_ragdoll_joint_limits()`: `elbow_knee` folds +CW
are spawned procedurally — `master_rig.tscn` is **not** modified. `-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. All ragdoll nodes are spawned procedurally — `master_rig.tscn` is
**not** modified.
- **Phase 11 instant handoff + recovery:** `_update_rest_detection()` (only when `state ==
RAGDOLL`)
reads `_ragdoll_bodies["torso"]`: when its linear/angular velocity drops below
`REST_LINEAR_THRESHOLD`/`REST_ANGULAR_THRESHOLD` it accumulates `_rest_timer`; after
`rest_timeout` (and a `STABILIZATION_DELAY` hold) with `auto_recover` on it calls
`_start_recovery()`. `_start_recovery()` (also `request_recovery()`, no-op unless in
RAGDOLL) captures the 10 bodies' rig-local `{pos, rot, half}` into `_captured_pose` (`half` =
each capsule's half-length from build-time metadata), `_destroy_ragdoll()`s,
sets `state = RECOVERING` + emits, then `_snap_skeleton_to_pose()` — a **marker-driven** snap
writing `IK_Targets/Torso.position`/`.rotation`, `IK_Targets/Head.position`, and the 4 limb
markers (never the slaved `Torso` Bone2D), re-showing
`Body/*` before re-enabling the IK stack so TwoBoneIK solves toward the end-effectors. Snap
geometry: the ragdoll capsules span joint origin→tip along their +X, so the **hip** is derived
as `torso.pos spine_dir·half` and the wrist/ankle targets as `lower_body.pos + dir·half`;
the Torso marker rotation subtracts the Torso Bone2D's `bone_angle` (bone world angle =
marker rotation + bone_angle — copying the body rotation directly would slam the skeleton 90°
and lay it flat).
`_play_stand_up()` then tweens the 6 markers **directly** from their captured values to
`STAND_POSE` over `STAND_UP_DURATION` (sine ease-in-out, `_tween_markers_to()`); the baked
`"stand_up"` animation is **not** played (a fixed first keyframe can never match an arbitrary
ragdoll rest pose, so recovery starts from wherever the snap left the markers). On tween
finish `_on_stand_up_finished()` re-enables IK, re-shows `Body/*`, sets `state = ANIMATED`,
and emits. Interruptible: `set_ragdoll(true)`
during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll;
`set_ragdoll(false)` during `RAGDOLL` routes through `_start_recovery()`; repeated
`set_ragdoll` calls are idempotent. `is_in_ragdoll()` stays
`state == RigState.RAGDOLL` (so `RECOVERING` reads as "Stickman").
- `scripts/stickman_factory.gd` — `class_name StickmanFactory`, `extends RefCounted`; a **static - `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 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: `.stk` file into a live, rigged `master_rig.tscn` instance:
@@ -267,6 +303,16 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
(typed as `StickmanRig` since the rig now carries the `StickmanRig` root script). (typed as `StickmanRig` since the rig now carries the `StickmanRig` root script).
- `static func spawn(path: String) -> StickmanRig` — `load_stk()` then `spawn_from_data()`; - `static func spawn(path: String) -> StickmanRig` — `load_stk()` then `spawn_from_data()`;
returns `null` on empty data. returns `null` on empty data.
- `scripts/create_animations.gd` — `@tool extends EditorScript`; a **standalone editor utility**
(run manually with `master_rig.tscn` open, **not** auto-loaded or referenced at runtime).
Supersedes the deleted `scripts/create_walk.gd`. `_run()` bakes `walk_left`/`walk_right`
(same keyframes as the old script, via `_generate_walk_animation()`) and a one-shot `stand_up`
(via `_generate_pose_animation()`, `STAND_UP_DURATION` = 0.8, `loop_mode = LOOP_NONE`) into the
open scene's default `AnimationLibrary`. `stand_up` keys the 6 `IK_Targets/*:position` tracks
plus a `IK_Targets/Torso:rotation` track from `POSE_DOWN` (a generic "lying on back" pose) to
`POSE_STANDING` (matching `master_rig.tscn` defaults), with `POSE_PATHS`/`POSE_MARKERS` consts.
The baked `stand_up` is an **authored reference only** — runtime recovery does not play it
(StickmanRig tweens the IK targets directly from the captured ragdoll pose).
- `scripts/test_harness.gd` — **standalone staging scene** (Phase 9, **not wired into the editor**; - `scripts/test_harness.gd` — **standalone staging scene** (Phase 9, **not wired into the editor**;
run via **F6** on `res://scenes/test_harness.tscn`) for debugging bone scales, vector drawing run via **F6** on `res://scenes/test_harness.tscn`) for debugging bone scales, vector drawing
offsets, and IK limits in isolation. Top UI bar: "Open .stk…" button → `FileDialog` (`*.stk`); offsets, and IK limits in isolation. Top UI bar: "Open .stk…" button → `FileDialog` (`*.stk`);
@@ -458,6 +504,15 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
KNOCK_UP_VELOCITY = (0, -450))` (mass-scaled `apply_central_impulse` on every ragdoll body, 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 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. prop (`RigidBody2D` child of `_environment`) so the whole pile flies up together.
- **Phase 11 recovery UI:** a `Label("Rest")` + `SpinBox` (`_rest_timeout_spinbox`, min 0.1 /
max 10.0 / step 0.1, initialized to `_rig.rest_timeout` after `_build_ui`) and a **"Recover
Now"** button (`_recover_now()` → `_rig.request_recovery()`) are added after the "Knock Up"
button. `_on_rest_timeout_changed(v)` writes `_rig.rest_timeout = v` (runtime-only, no
settings.json). `_spawn_rig()` connects `_rig.state_changed` → `_on_rig_state_changed()`,
which removes the collision proxy on `RAGDOLL`, re-adds it on `ANIMATED`/`RECOVERING`, and
always `_update_ragdoll_toggle()` (proxy helpers are idempotent, so the toggle handler's own
add/remove is harmless). The toggle label reads "Stickman" during `RECOVERING` (since
`is_in_ragdoll()` is false).
- Scenes: - Scenes:
- `scenes/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used - `scenes/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used
for typed `@onready` access: `%MenuBar`, `%StickmanNameEdit`, `%LeftColumn`, for typed `@onready` access: `%MenuBar`, `%StickmanNameEdit`, `%LeftColumn`,
+21 -12
View File
@@ -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**. 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. - `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). 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. **`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). A toggle-mode **Button** (label "Stickman" ↔ "Ragdoll") flips the rig's **kinematic-to-ragdoll** mode (see §17); a **Rest** `SpinBox` (0.110 s) writes `_rig.rest_timeout` (runtime-only) and a **"Recover Now"** button calls `_rig.request_recovery()`. The harness connects the rig's `state_changed` signal, which removes the `RigCollisionProxy` on `RAGDOLL` and re-adds it (idempotently) on `ANIMATED`/`RECOVERING`.
### 16. Dynamic Vector Props ### 16. Dynamic Vector Props
@@ -349,20 +349,22 @@ A **unified live-update setter** drives geometry to all children live — a chan
### 17. Kinematic-to-Ragdoll State System ### 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: `StickmanRig` (the runtime root of `master_rig.tscn`) can switch between physics modes via a reversible state machine with three states:
- **`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. - **`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. - **`RAGDOLL`** — the kinematic rig is frozen (`AnimationPlayer` stopped with `keep_state`) and the `Body/*` visuals hidden **immediately**; a procedural network of `RigidBody2D` + `PinJoint2D` nodes spawned in code takes over, letting the figure fall/tumble against the terrain and props. The ragdoll is built from the **current solved bone positions** (the IK stack is disabled only after the build), so the handoff is instant and seamless — the ragdoll appears in exactly the pose the figure was in, no crossfade.
- **`RECOVERING`** — the ragdoll has been destroyed and the skeleton is being snapped back to the captured rest pose, then tweened upright (IK targets captured → standing) before returning to `ANIMATED`.
**Public API (`StickmanRig`):** **Public API (`StickmanRig`):**
| Member | Signature | Behavior | | Member | Signature | Behavior |
|---|---|---| |---|---|---|
| `state` | `var state: RigState` | Current mode — `RigState.ANIMATED` or `RigState.RAGDOLL`. | | `state` | `var state: RigState` | Current mode — `RigState.ANIMATED`, `RigState.RAGDOLL`, or `RigState.RECOVERING`. |
| `state_changed` | `signal state_changed(new_state: int)` | Emitted on every transition with the `RigState` enum value. | | `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`. | | `is_in_ragdoll()` | `func is_in_ragdoll() -> bool` | `state == RigState.RAGDOLL` (so `RECOVERING` reads as "Stickman"). |
| `set_ragdoll(enabled)` | `func set_ragdoll(enabled: bool) -> void` | Enters/exits ragdoll; idempotent (no-op when already in the target state). | | `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())`. | | `toggle_ragdoll()` | `func toggle_ragdoll() -> void` | `set_ragdoll(not is_in_ragdoll())`. |
| `request_recovery()` | `func request_recovery() -> void` | Forces the stand-up recovery path; no-op unless 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. **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.
@@ -374,16 +376,20 @@ A **unified live-update setter** drives geometry to all children live — a chan
| Torso | `CapsuleShape2D` (radius 12, length = distance between the Torso and Head bone origins), mass **8.0** | | Torso | `CapsuleShape2D` (radius 12, length = distance between the Torso and Head bone origins), mass **8.0** |
| Head | `CircleShape2D` (radius 100), mass **2.0** | | Head | `CircleShape2D` (radius 100), mass **2.0** |
| 8 limbs | `CapsuleShape2D` (radius 8, length = `bone.length`), masses 1.02.0 | | 8 limbs | `CapsuleShape2D` (radius 8, length = `bone.length`), masses 1.02.0 |
| Joint count | **9** `PinJoint2D` (one per non-root body, pinned at the child bone's origin), `softness` 0.0 | | Joint count | **9** `PinJoint2D` (one per non-root body, pinned at the child bone's origin), `softness` = `RAGDOLL_TARGET_SOFTNESS` (0.2) at build |
| Collision | `collision_layer` / `collision_mask` = **1** (matches `TerrainBlock` / `PropBlock`) | | Collision | `collision_layer` / `collision_mask` = **1** (matches `TerrainBlock` / `PropBlock`) |
Ragdoll bodies spawn fully visible — the entry handoff is instant (the ragdoll is built at the current solved pose, so there is nothing to fade).
**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`). **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. **Instant handoff (Phase 11):** `_enter_ragdoll()` stops the player with `keep_state` (no pose reset), builds the ragdoll from the **current solved bone positions** while the IK stack is still enabled, then hides `Body/*` and disables the IK stack in the same call. There is deliberately **no crossfade** — the ragdoll spawns at exactly the same pose, so a fade would only read as ghosting (an earlier blended transition was removed on director feedback).
**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. **Rest detection (auto-recovery):** while `state == RAGDOLL`, `_update_rest_detection()` reads the **Torso** `RigidBody2D`. When it is sleeping **or** its linear velocity ≤ `REST_LINEAR_THRESHOLD` (**5.0 px/s** — tuned up from the original 0.1 because a soft-pinned ragdoll micro-jitters around ~0.5 px/s even when settled) and angular velocity ≤ `REST_ANGULAR_THRESHOLD` (0.1 rad/s), a `_rest_timer` accumulates; after `rest_timeout` (exported, default **2.0 s**) plus a `STABILIZATION_DELAY` (0.1 s) hold, and with `auto_recover` (exported, default **true**) enabled, recovery is triggered. `rest_timeout` and `auto_recover` are runtime-adjustable exports.
**No scene edits:** `master_rig.tscn` is unchanged — all ragdoll nodes are spawned procedurally at runtime. **Recovery (`_start_recovery()`, also `request_recovery()`):** the 10 bodies' rig-local `{pos, rot, half}` are captured into `_captured_pose` (`half` = each capsule's half-length, stored as build-time metadata), the ragdoll is destroyed, and `state = RECOVERING` is set + emitted. `_snap_skeleton_to_pose()` writes the `IK_Targets/Torso` position + rotation, `IK_Targets/Head`, and the 4 limb markers (never the slaved Torso `Bone2D`), re-shows `Body/*`, then re-enables the IK stack so TwoBoneIK solves toward the end-effectors. Snap geometry: the ragdoll capsules span joint origin→tip along their +X, so the **hip** is derived as `torso.pos spine_dir·half` and the wrist/ankle targets as `lower_body.pos + dir·half`; the Torso marker rotation subtracts the Torso `Bone2D`'s `bone_angle` (bone world angle = marker rotation + bone_angle — copying the body rotation directly would slam the skeleton 90° and lay it flat). The snap therefore reproduces the ragdoll's exact final pose (a "sitting" rest stays sitting). `_play_stand_up()` then tweens the 6 markers **directly** from their captured values to `STAND_POSE` over `STAND_UP_DURATION` (**0.8 s**, sine ease-in-out, `_tween_markers_to()`). The baked `stand_up` animation is **not** played — a fixed first keyframe can never match an arbitrary ragdoll rest pose (the earlier bridge-into-the-animation approach caused a visible jump from the captured pose to the animation's first frame), so the tween starts from wherever the snap left the markers. On tween finish, `_on_stand_up_finished()` re-enables IK, re-shows `Body/*`, sets `state = ANIMATED`, and emits.
**Interruptibility:** `set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll; `set_ragdoll(false)` during `RAGDOLL` routes through `_start_recovery()`; repeated `set_ragdoll` calls are idempotent. All ragdoll nodes are spawned procedurally — `master_rig.tscn` is **not** modified (the `stand_up` / `walk_left` / `walk_right` animations are baked into the scene's `AnimationLibrary` by the `create_animations.gd` editor script, which runs manually in the editor; the baked `stand_up` is an authored reference and the recovery path does not play it).
## File format (`.stk`) ## File format (`.stk`)
@@ -523,12 +529,13 @@ 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/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 46 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/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9 (Rounds 46 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_factory.gd` | **Phase 9.** Runtime entry point (`class_name StickmanFactory`, `extends RefCounted`); a static factory that turns a `.stk` file into a live, rigged `master_rig.tscn` instance. `load_stk(path)` reads + parses the file (`{}` + `push_warning` on failure); `spawn_from_data(stk_data)` instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, and returns the rig root **typed as `StickmanRig`** (the rig now carries the `StickmanRig` root script); `spawn(path)` chains them (`null` on empty data). **Not used by the editor.** |
| `res://scripts/stickman_rig.gd` | **Phase 9 Task 4.** `class_name StickmanRig`, `extends Node2D`; the runtime owner of facing direction, per-joint bone bend, `Body/*` z-order, and (Phase 10) 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/stickman_rig.gd` | **Phase 9 Task 4.** `class_name StickmanRig`, `extends Node2D`; the runtime owner of facing direction, per-joint bone bend, `Body/*` z-order, and (Phase 10/11) the **kinematic-to-ragdoll** state switch with instant handoff + stand-up recovery, attached to the `master_rig.tscn` root `Master`. Exports a `facing_profile` preset (`FacingProfile` LEFT/RIGHT/FORWARD, default FORWARD) and four `@export_enum("Normal","Inverted")` per-joint bend vars (`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend`), plus (Phase 11) `rest_timeout` (2.0 s) and `auto_recover` (true) exports. Non-`@tool`: resolves `Skeleton2D`/`Body`/bend joints at runtime, enables its own modification stack, and applies the profile (flag writes + `Body/*` reorder) in `_ready()` and setters. Signals `facing_profile_changed` / `bend_flag_changed` / `state_changed`; public API `set_facing_profile`/`get_facing_profile`, `set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, `get_bend_joint_global_position()`, plus the ragdoll API `set_ragdoll(enabled)`/`toggle_ragdoll()`/`is_in_ragdoll()`/`request_recovery()` with `state` / `enum RigState { ANIMATED, RAGDOLL, RECOVERING }`. Null-guarded (`push_warning` + skip). **Not used by the editor.** |
| `res://scripts/create_animations.gd` | **Phase 11.** `@tool extends EditorScript`; a **standalone editor utility** (run manually with `master_rig.tscn` open; not auto-loaded or referenced at runtime) that supersedes the deleted `scripts/create_walk.gd`. `_run()` bakes `walk_left`/`walk_right` (same keyframes as the old script) and a one-shot `stand_up` (`POSE_DOWN``POSE_STANDING`, `STAND_UP_DURATION` 0.8, `loop_mode = LOOP_NONE`) into the open scene's default `AnimationLibrary`. The baked `stand_up` is an **authored reference only** — runtime recovery does not play it (`StickmanRig` tweens the IK targets directly from the captured ragdoll pose, since a fixed first keyframe can never match an arbitrary rest pose). |
| `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://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://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_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/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). 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://scripts/physics_test_harness.gd` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene root building flat/ramp/step terrain via `TerrainUtils`, instantiating `master_rig.tscn`, spawning props via **1/2/3** (`PropUtils`), and adding a rig collision proxy (run via **F6**; not wired into the editor). A toggle-mode button flips the rig's kinematic-to-ragdoll mode via `_rig.set_ragdoll()`, plus (Phase 11) a **Rest** `SpinBox` (writes `_rig.rest_timeout`) and **"Recover Now"** button (`_rig.request_recovery()`); the rig's `state_changed` signal removes the proxy on `RAGDOLL` and re-adds it (idempotently) on `ANIMATED`/`RECOVERING`. |
| `res://scenes/physics_test_harness.tscn` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene backing `scripts/physics_test_harness.gd` (run via **F6**; not wired into the editor). | | `res://scenes/physics_test_harness.tscn` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene backing `scripts/physics_test_harness.gd` (run via **F6**; not wired into the editor). |
| `res://scripts/prop_block.gd` | **Dynamic Vector Props.** `class_name PropBlock`, `extends RigidBody2D` — a reusable physical prop building `Polygon2D` (fill) + `Line2D` (outline) + `CollisionPolygon2D`/`CollisionShape2D` (polygon/circle collision) children in code, with material presets (mass + friction/bounce) and live-updating exports. | | `res://scripts/prop_block.gd` | **Dynamic Vector Props.** `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`). | | `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`). |
@@ -629,4 +636,6 @@ BodyPartPanel.shape_selected() ---(bound to part_name)---> stickman_editor
> **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 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. > **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 toggles via its **Stickman ↔ Ragdoll** button, removing the `RigCollisionProxy` on entry and re-adding it (idempotently) on exit. `master_rig.tscn` is **not** modified.
> **Phase 11 (Instant Handoff & Recovery):** replaces the hard ragdoll entry/exit with an **instant handoff** and adds a **stand-up recovery** path. `StickmanRig` gains `enum RigState { ANIMATED, RAGDOLL, RECOVERING }` plus exports `rest_timeout` (2.0 s) and `auto_recover` (true). On entering `RAGDOLL` the ragdoll is built from the **current solved bone positions** (the player is stopped with `keep_state`), then `Body/*` is hidden and the IK stack disabled in the same call — no crossfade, since the ragdoll spawns at exactly the same pose and a fade would only read as ghosting (an earlier `transition_duration` blend was removed on director feedback). Rest detection reads the Torso body — sleeping, or linear ≤ `REST_LINEAR_THRESHOLD` (5.0 px/s, tuned up from the plan's 0.1 because a soft-pinned ragdoll micro-jitters around ~0.5 px/s) and angular ≤ 0.1 rad/s — then after `rest_timeout` + `STABILIZATION_DELAY` (0.1 s) with `auto_recover` on, calls `_start_recovery()`. Recovery captures the 10 bodies' rig-local pose, destroys the ragdoll, sets `state = RECOVERING` + emits, snap-solves the skeleton via the **6** IK targets (`IK_Targets/Torso` pos+rot, `IK_Targets/Head`, 4 limb markers — never the slaved Torso `Bone2D`), deriving the **hip** from the torso capsule's bottom end (`pos dir·half`) and the wrist/ankle targets from the lower-limb capsules' far ends (`pos + dir·half`), with the Torso marker rotation subtracting the Torso bone's `bone_angle` (copying the body rotation directly would slam the skeleton 90° and lay it flat), then `_play_stand_up()` tweens the markers **directly** from their captured values to `STAND_POSE` over `STAND_UP_DURATION` (0.8 s, sine ease-in-out) — the baked `stand_up` animation is **not** played, because a fixed first keyframe can never match an arbitrary ragdoll rest pose (the earlier bridge-into-the-animation approach caused a visible jump); `_on_stand_up_finished()` then returns the rig to `ANIMATED`. `request_recovery()` is public (no-op unless in `RAGDOLL`); `set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll, `set_ragdoll(false)` during `RAGDOLL` routes through recovery, and calls are otherwise idempotent. `is_in_ragdoll()` stays `state == RAGDOLL` (so `RECOVERING` reads as "Stickman"). A new `res://scripts/create_animations.gd` editor script (superseding the deleted `create_walk.gd`) bakes `walk_left`/`walk_right`/the one-shot `stand_up` into `master_rig.tscn`'s `AnimationLibrary` (the baked `stand_up` is an authored reference only — recovery does not play it) — **no `.stk` format change**; `master_rig.tscn` scene nodes are unchanged (only its baked animations are added). The physics harness gains a **Rest** `SpinBox` (0.110 s, writes `_rig.rest_timeout`), a **"Recover Now"** button (`request_recovery()`), and a `state_changed` hook that removes the `RigCollisionProxy` on `RAGDOLL` and re-adds it (idempotently) on `ANIMATED`/`RECOVERING`.
+10 -9
View File
@@ -15,20 +15,20 @@ This document tracks known technical debt, optimization opportunities, and minor
| 3 | **Collision Layers Separation** — Bodies and terrain share layer 1/mask 1. This may cause selfcollision 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 limblimb collision only where desired. | | 3 | **Collision Layers Separation** — Bodies and terrain share layer 1/mask 1. This may cause selfcollision 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 limblimb 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. | | 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. | | 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. | | 6 | **Recovery Animation Starting Pose** — The `stand_up` animation must work from any captured ragdoll pose. Currently uses a fixed start frame. | High | ✅ Resolved | Phase 11: `_start_recovery()` captures the 10 bodies' riglocal pose, snapsolves the skeleton via the 6 IK targets, then `_play_stand_up()` tweens the markers **directly** from the captured values to `STAND_POSE` (`STAND_UP_DURATION`, sine easeinout) — the baked `stand_up` animation is **not** played (a fixed first keyframe can never match an arbitrary rest pose; the earlier bridgeintotheanimation approach caused a visible jump and was removed). Revision: the snap now derives the hip (`pos dir·half`) and wrist/ankle (`pos + dir·half`) from the capsule ends and subtracts the Torso bone's `bone_angle` for the marker rotation, so recovery starts from the ragdoll's exact final pose (e.g. sitting stays sitting). (20260827) |
| 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 doubleexposure. | | 7 | **Transition Visual Pop** — The crossfade between kinematic and ragdoll currently uses a simple `modulate.a` lerp. This may cause ghosting if the kinematic and ragdoll poses are misaligned. | Medium | ✅ Resolved | Phase 11: the entry is now an **instant handoff** — the ragdoll is built from the **current solved bone positions** (`AnimationPlayer.stop(true)` keeps the pose), then `Body/*` is hidden and the IK stack disabled in the same call. The earlier opacity crossfade + pinsoftness ramp was **removed on director feedback** (it read as ghosting, since both poses are identical). Recovery snapsolves the kinematic skeleton to the captured ragdoll pose before reshowing `Body/*`, eliminating the pop on both directions. (20260827) |
| 8 | **Rest Timeout UI** — The director can adjust `rest_timeout` via inspector, but there is no inworld UI in the physics harness yet. | Low | Open | Add a slider or spinbox to the harness UI for easier tuning. | | 8 | **Rest Timeout UI** — The director can adjust `rest_timeout` via inspector, but there is no inworld UI in the physics harness yet. | Low | ✅ Resolved | Phase 11: added a Rest `SpinBox` (0.110 s, step 0.1) to the harness UI that writes `_rig.rest_timeout` (runtimeonly), plus a "Recover Now" button → `_rig.request_recovery()`. (20260827) |
| 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). | | 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 | ✅ Resolved | Phase 11: `create_walk.gd` deleted; new `scripts/create_animations.gd` (`@tool extends EditorScript`) bakes `walk_left`/`walk_right` (same keyframes) and a oneshot `stand_up` into the scene's `AnimationLibrary` (the baked `stand_up` is an authored reference only — runtime recovery tweens the IK targets directly). (20260827) |
| 10 | **Rig Collision Proxy Readdition** — The proxy is readded on ragdoll exit, but may cause a brief visual pop if it appears while the kinematic rig is visible. | Low | Open | Consider delaying proxy readdition until after the recovery animation completes, or fading it in. | | 10 | **Rig Collision Proxy Readdition** — The proxy is readded on ragdoll exit, but may cause a brief visual pop if it appears while the kinematic rig is visible. | Low | Open | Phase 11 still readds the proxy as soon as `RECOVERING` begins (`state_changed` handler), while `Body/*` is already visible — the static box can pop in around the standing figure before the standup completes. Consider delaying readdition until after recovery finishes (`ANIMATED`). (20260827) |
--- ---
## Suggested Future Improvements (Beyond Current Scope) ## Suggested Future Improvements (Beyond Current Scope)
| Improvement | Description | Priority | | Improvement | Description | Status / Priority |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------- | | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ----------------- |
| **Soft Transition Blending** | Add physical blending (joint stiffness ramp) to complement visual crossfade. | Medium | | **Soft Transition Blending** | Add physical blending (joint stiffness ramp) to complement visual crossfade. | ✅ Done, then removed — implemented with Phase 11, but the whole crossfade was removed on director feedback (ghosting); entry is now an instant handoff. |
| **Ragdoll Recovery Interruptibility** | Allow the director to force a mode switch midrecovery (e.g., if they want the character to ragdoll again immediately). | Low | | **Ragdoll Recovery Interruptibility** | Allow the director to force a mode switch midrecovery (e.g., if they want the character to ragdoll again immediately). | ✅ Done |
| **Multiple Rig Support** | Ensure all state variables are instancespecific (already true) and that the harness can manage multiple rigs. | Future | | **Multiple Rig Support** | Ensure all state variables are instancespecific (already true) and that the harness can manage multiple rigs. | Future |
| **Animation Blending (IK vs. Physics)** | Blend between the animationdriven pose and the ragdoll pose during transition to prevent snapping. | Future | | **Animation Blending (IK vs. Physics)** | Blend between the animationdriven 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 | | **Save/Load for Ragdoll State** | Save the current ragdoll pose to `.stk` (e.g., for storyboarding a fall). | Future |
@@ -49,6 +49,7 @@ This document tracks known technical debt, optimization opportunities, and minor
| Date | Change | | Date | Change |
| ---------- | -------------------------------------------------------------- | | ---------- | -------------------------------------------------------------- |
| 2026-08-26 | Initial creation — migrated observations from Phase 10 review. | | 2026-08-26 | Initial creation — migrated observations from Phase 10 review. |
| 2026-08-27 | Phase 11 resolved #6 (recovery starting pose), #7 (transition visual pop), #8 (rest timeout UI), #9 (animation generation DRY); #10 (proxy readdition) remains open with updated scope. Later revision: standup recovery switched from bridgeintobakedanimation to a direct marker tween (captured pose → `STAND_POSE`), fixing a visible jump; baked `stand_up` kept as authored reference only. Second revision: ragdoll entry builds from the current solved bone positions (IK disabled only after the blend completes) and the recovery snap derives joint ends from capsule halfheights with Torso `bone_angle` compensation, fixing the entry posepop and the "recovery starts lying" bugs. Third revision: the entire crossfade/blend (`transition_duration`, `BlendDirection`, opacity fade, softness ramp) was **removed on director feedback** — entry is now an instant handoff (build at current pose → hide `Body/*` → disable IK in one call), since the ragdoll spawns at the identical pose and a fade only read as ghosting. |
--- ---
+108 -32
View File
@@ -61,7 +61,6 @@ constraint_angle_invert = true
constraint_in_localspace = true constraint_in_localspace = true
[sub_resource type="SkeletonModificationStack2D" id="SkeletonModificationStack2D_j4hao"] [sub_resource type="SkeletonModificationStack2D" id="SkeletonModificationStack2D_j4hao"]
enabled = true
modification_count = 5 modification_count = 5
modifications/0 = SubResource("SkeletonModification2DTwoBoneIK_yvxej") modifications/0 = SubResource("SkeletonModification2DTwoBoneIK_yvxej")
modifications/1 = SubResource("SkeletonModification2DTwoBoneIK_f0s26") modifications/1 = SubResource("SkeletonModification2DTwoBoneIK_f0s26")
@@ -86,6 +85,93 @@ tracks/0/keys = {
[sub_resource type="Animation" id="Animation_ylko5"] [sub_resource type="Animation" id="Animation_ylko5"]
length = 0.8 length = 0.8
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("IK_Targets/Torso:position")
tracks/0/interp = 2
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 0.8),
"transitions": PackedFloat32Array(1, 1),
"update": 0,
"values": [Vector2(0, 330), Vector2(0, 10)]
}
tracks/1/type = "value"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("IK_Targets/Head:position")
tracks/1/interp = 2
tracks/1/loop_wrap = true
tracks/1/keys = {
"times": PackedFloat32Array(0, 0.8),
"transitions": PackedFloat32Array(1, 1),
"update": 0,
"values": [Vector2(-60, 300), Vector2(100, -614)]
}
tracks/2/type = "value"
tracks/2/imported = false
tracks/2/enabled = true
tracks/2/path = NodePath("IK_Targets/Left_Hand:position")
tracks/2/interp = 2
tracks/2/loop_wrap = true
tracks/2/keys = {
"times": PackedFloat32Array(0, 0.8),
"transitions": PackedFloat32Array(1, 1),
"update": 0,
"values": [Vector2(100, 330), Vector2(90, 110)]
}
tracks/3/type = "value"
tracks/3/imported = false
tracks/3/enabled = true
tracks/3/path = NodePath("IK_Targets/Right_Hand:position")
tracks/3/interp = 2
tracks/3/loop_wrap = true
tracks/3/keys = {
"times": PackedFloat32Array(0, 0.8),
"transitions": PackedFloat32Array(1, 1),
"update": 0,
"values": [Vector2(-100, 330), Vector2(-90, 110)]
}
tracks/4/type = "value"
tracks/4/imported = false
tracks/4/enabled = true
tracks/4/path = NodePath("IK_Targets/Left_Leg:position")
tracks/4/interp = 2
tracks/4/loop_wrap = true
tracks/4/keys = {
"times": PackedFloat32Array(0, 0.8),
"transitions": PackedFloat32Array(1, 1),
"update": 0,
"values": [Vector2(90, 300), Vector2(-110, 380)]
}
tracks/5/type = "value"
tracks/5/imported = false
tracks/5/enabled = true
tracks/5/path = NodePath("IK_Targets/Right_Leg:position")
tracks/5/interp = 2
tracks/5/loop_wrap = true
tracks/5/keys = {
"times": PackedFloat32Array(0, 0.8),
"transitions": PackedFloat32Array(1, 1),
"update": 0,
"values": [Vector2(-90, 300), Vector2(110, 390)]
}
tracks/6/type = "value"
tracks/6/imported = false
tracks/6/enabled = true
tracks/6/path = NodePath("IK_Targets/Torso:rotation")
tracks/6/interp = 2
tracks/6/loop_wrap = true
tracks/6/keys = {
"times": PackedFloat32Array(0, 0.8),
"transitions": PackedFloat32Array(1, 1),
"update": 0,
"values": [-1.5707963267948966, 0.0]
}
[sub_resource type="Animation" id="Animation_t75yq"]
length = 0.8
loop_mode = 1 loop_mode = 1
tracks/0/type = "value" tracks/0/type = "value"
tracks/0/imported = false tracks/0/imported = false
@@ -172,7 +258,7 @@ tracks/6/keys = {
"values": [Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110)] "values": [Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110)]
} }
[sub_resource type="Animation" id="Animation_t75yq"] [sub_resource type="Animation" id="Animation_m3sq4"]
length = 0.8 length = 0.8
loop_mode = 1 loop_mode = 1
tracks/0/type = "value" tracks/0/type = "value"
@@ -263,8 +349,9 @@ tracks/6/keys = {
[sub_resource type="AnimationLibrary" id="AnimationLibrary_t75yq"] [sub_resource type="AnimationLibrary" id="AnimationLibrary_t75yq"]
_data = { _data = {
&"RESET": SubResource("Animation_2leu7"), &"RESET": SubResource("Animation_2leu7"),
&"walk_left": SubResource("Animation_ylko5"), &"stand_up": SubResource("Animation_ylko5"),
&"walk_right": SubResource("Animation_t75yq") &"walk_left": SubResource("Animation_t75yq"),
&"walk_right": SubResource("Animation_m3sq4")
} }
[sub_resource type="AnimationNodeStateMachine" id="AnimationNodeStateMachine_6rw38"] [sub_resource type="AnimationNodeStateMachine" id="AnimationNodeStateMachine_6rw38"]
@@ -282,69 +369,67 @@ width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1) default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="LeftUpperLeg" type="Line2D" parent="Body" unique_id=199373156] [node name="LeftUpperLeg" type="Line2D" parent="Body" unique_id=199373156]
position = Vector2(1.6037084e-05, 10.000003) position = Vector2(1.6098846e-05, 10.000002)
rotation = 0.55427897 rotation = 0.4947604
points = PackedVector2Array(0, 0, 0, 200) points = PackedVector2Array(0, 0, 0, 200)
width = 16.0 width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1) default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="RightUpperLeg" type="Line2D" parent="Body" unique_id=1396556308] [node name="RightUpperLeg" type="Line2D" parent="Body" unique_id=1396556308]
position = Vector2(-2.4553774e-05, 10.000021) position = Vector2(-2.3252098e-05, 10.000022)
rotation = -0.43021643 rotation = -0.49392816
scale = Vector2(0.99999994, 0.99999994) scale = Vector2(0.99999994, 0.99999994)
points = PackedVector2Array(0, 0, 0, 200) points = PackedVector2Array(0, 0, 0, 200)
width = 16.0 width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1) default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="LeftLowerLeg" type="Line2D" parent="Body" unique_id=1590485904] [node name="LeftLowerLeg" type="Line2D" parent="Body" unique_id=1590485904]
position = Vector2(-105.26607, 180.05603) position = Vector2(-94.96416, 186.0165)
rotation = 0.023672067 rotation = 0.0051915743
scale = Vector2(0.99999994, 0.99999994) scale = Vector2(0.99999994, 0.99999994)
points = PackedVector2Array(0, 0, 0, 200) points = PackedVector2Array(0, 0, 0, 200)
width = 16.0 width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1) default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="RightLowerLeg" type="Line2D" parent="Body" unique_id=29203948] [node name="RightLowerLeg" type="Line2D" parent="Body" unique_id=29203948]
position = Vector2(83.4135, 191.77509) position = Vector2(94.817635, 186.09546)
rotation = -0.13332568 rotation = -0.0034907677
scale = Vector2(0.9999997, 0.9999997) scale = Vector2(0.9999998, 0.9999998)
points = PackedVector2Array(0, 0, 0, 200) points = PackedVector2Array(0, 0, 0, 200)
width = 16.0 width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1) default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="LeftUpperArm" type="Line2D" parent="Body" unique_id=744524157] [node name="LeftUpperArm" type="Line2D" parent="Body" unique_id=744524157]
position = Vector2(9.313226e-10, -238) position = Vector2(9.313226e-10, -238)
rotation = -0.48905078 rotation = 1.6184965
points = PackedVector2Array(0, 0, 0, 175) points = PackedVector2Array(0, 0, 0, 175)
width = 16.0 width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1) default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="RightUpperArm" type="Line2D" parent="Body" unique_id=1593646765] [node name="RightUpperArm" type="Line2D" parent="Body" unique_id=1593646765]
position = Vector2(9.313226e-10, -238) position = Vector2(9.313226e-10, -238)
rotation = 0.48905125 rotation = -1.6184964
scale = Vector2(0.99999994, 0.99999994)
points = PackedVector2Array(0, 0, 0, 175) points = PackedVector2Array(0, 0, 0, 175)
width = 16.0 width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1) default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="LeftLowerArm" type="Line2D" parent="Body" unique_id=2142117097] [node name="LeftLowerArm" type="Line2D" parent="Body" unique_id=2142117097]
position = Vector2(78.92438, -89.6931) position = Vector2(-167.80888, -246.01059)
rotation = -0.055406693 rotation = 3.1406152
points = PackedVector2Array(0, 0, 0, 200) points = PackedVector2Array(0, 0, 0, 200)
width = 16.0 width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1) default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="RightLowerArm" type="Line2D" parent="Body" unique_id=1200980480] [node name="RightLowerArm" type="Line2D" parent="Body" unique_id=1200980480]
position = Vector2(-78.924484, -89.69313) position = Vector2(167.80891, -246.01057)
rotation = 0.055405635 rotation = -3.1406155
points = PackedVector2Array(0, 0, 0, 200) points = PackedVector2Array(0, 0, 0, 200)
width = 16.0 width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1) default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="Head" type="Node2D" parent="Body" unique_id=864822355] [node name="Head" type="Node2D" parent="Body" unique_id=864822355]
position = Vector2(28.397173, -447.61597) position = Vector2(-0.07846909, -453.50793)
rotation = 0.4066687 scale = Vector2(0.99999887, 0.99999887)
scale = Vector2(0.9999997, 0.9999997)
script = SubResource("GDScript_f0s26") script = SubResource("GDScript_f0s26")
[node name="Skeleton2D" type="Skeleton2D" parent="." unique_id=1854735445] [node name="Skeleton2D" type="Skeleton2D" parent="." unique_id=1854735445]
@@ -363,7 +448,6 @@ rest = Transform2D(0.9998273, 0.0005539903, -0.0005539903, 0.9998273, -0.1288230
auto_calculate_length_and_angle = false auto_calculate_length_and_angle = false
length = 90.0 length = 90.0
bone_angle = -90.0 bone_angle = -90.0
metadata/_local_pose_override_enabled_ = true
[node name="RayCast_Aim" type="RayCast2D" parent="Skeleton2D/Torso/Head" unique_id=2000000004] [node name="RayCast_Aim" type="RayCast2D" parent="Skeleton2D/Torso/Head" unique_id=2000000004]
position = Vector2(0, -90) position = Vector2(0, -90)
@@ -383,7 +467,6 @@ rest = Transform2D(0.9988364, 0.04768082, -0.04768082, 0.9988364, 0, -248)
auto_calculate_length_and_angle = false auto_calculate_length_and_angle = false
length = 168.0 length = 168.0
bone_angle = -180.0 bone_angle = -180.0
metadata/_local_pose_override_enabled_ = true
[node name="LeftLowerArm" type="Bone2D" parent="Skeleton2D/Torso/LeftUpperArm" unique_id=721380545] [node name="LeftLowerArm" type="Bone2D" parent="Skeleton2D/Torso/LeftUpperArm" unique_id=721380545]
position = Vector2(-168, 0) position = Vector2(-168, 0)
@@ -392,7 +475,6 @@ rest = Transform2D(0.9987903, -0.04865717, 0.04865717, 0.9987903, -168, 0)
auto_calculate_length_and_angle = false auto_calculate_length_and_angle = false
length = 200.0 length = 200.0
bone_angle = -90.0 bone_angle = -90.0
metadata/_local_pose_override_enabled_ = true
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/LeftUpperArm/LeftLowerArm" unique_id=1189277122] [node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/LeftUpperArm/LeftLowerArm" unique_id=1189277122]
position = Vector2(3.0517578e-05, 1.9073486e-06) position = Vector2(3.0517578e-05, 1.9073486e-06)
@@ -410,7 +492,6 @@ rest = Transform2D(0.9988364, -0.047680702, 0.047680702, 0.9988364, 0, -248)
auto_calculate_length_and_angle = false auto_calculate_length_and_angle = false
length = 168.00006 length = 168.00006
bone_angle = 0.0 bone_angle = 0.0
metadata/_local_pose_override_enabled_ = true
[node name="RightLowerArm" type="Bone2D" parent="Skeleton2D/Torso/RightUpperArm" unique_id=1018820563] [node name="RightLowerArm" type="Bone2D" parent="Skeleton2D/Torso/RightUpperArm" unique_id=1018820563]
position = Vector2(168, 0) position = Vector2(168, 0)
@@ -419,7 +500,6 @@ rest = Transform2D(0.048656836, -0.9987903, 0.9987903, 0.048656836, 168, 0)
auto_calculate_length_and_angle = false auto_calculate_length_and_angle = false
length = 200.0 length = 200.0
bone_angle = 0.0 bone_angle = 0.0
metadata/_local_pose_override_enabled_ = true
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/RightUpperArm/RightLowerArm" unique_id=1282597655] [node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/RightUpperArm/RightLowerArm" unique_id=1282597655]
rotation = -1.5707964 rotation = -1.5707964
@@ -436,7 +516,6 @@ rest = Transform2D(0.88005984, 0.47480857, -0.47480857, 0.88005984, -9.536743e-0
auto_calculate_length_and_angle = false auto_calculate_length_and_angle = false
length = 200.0 length = 200.0
bone_angle = 90.0 bone_angle = 90.0
metadata/_local_pose_override_enabled_ = true
[node name="LeftLowerLeg" type="Bone2D" parent="Skeleton2D/Torso/LeftUpperLeg" unique_id=81288294] [node name="LeftLowerLeg" type="Bone2D" parent="Skeleton2D/Torso/LeftUpperLeg" unique_id=81288294]
position = Vector2(0, 200) position = Vector2(0, 200)
@@ -445,7 +524,6 @@ rest = Transform2D(0.4702357, 0.8825176, -0.8825176, 0.4702357, 0, 200)
auto_calculate_length_and_angle = false auto_calculate_length_and_angle = false
length = 200.0 length = 200.0
bone_angle = 0.0 bone_angle = 0.0
metadata/_local_pose_override_enabled_ = true
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg" unique_id=1673135542] [node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg" unique_id=1673135542]
rotation = -1.5707964 rotation = -1.5707964
@@ -462,7 +540,6 @@ rest = Transform2D(0.8800552, -0.47482592, 0.47482592, 0.8800552, 0, 0)
auto_calculate_length_and_angle = false auto_calculate_length_and_angle = false
length = 200.0 length = 200.0
bone_angle = 90.0 bone_angle = 90.0
metadata/_local_pose_override_enabled_ = true
[node name="RightLowerLeg" type="Bone2D" parent="Skeleton2D/Torso/RightUpperLeg" unique_id=1819999778] [node name="RightLowerLeg" type="Bone2D" parent="Skeleton2D/Torso/RightUpperLeg" unique_id=1819999778]
position = Vector2(0, 200) position = Vector2(0, 200)
@@ -472,7 +549,6 @@ rest = Transform2D(-0.47026652, 0.8825017, -0.8825017, -0.47026652, 0, 200)
auto_calculate_length_and_angle = false auto_calculate_length_and_angle = false
length = 200.0 length = 200.0
bone_angle = 0.0 bone_angle = 0.0
metadata/_local_pose_override_enabled_ = true
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/RightUpperLeg/RightLowerLeg" unique_id=278100224] [node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/RightUpperLeg/RightLowerLeg" unique_id=278100224]
rotation = -1.5707964 rotation = -1.5707964
+140
View File
@@ -0,0 +1,140 @@
@tool
extends EditorScript
## Unified animation generator: bakes walk_left, walk_right and stand_up into
## the open scene's AnimationPlayer. Supersedes the old create_walk.gd.
##
## Note: runtime ragdoll recovery does NOT play the baked stand_up — StickmanRig
## tweens the IK targets directly from the captured ragdoll pose to the standing
## pose (a fixed first keyframe can never match an arbitrary rest pose). The
## baked stand_up is kept as an authored reference / manual-play animation.
const STAND_UP_DURATION := 0.8
## IK target marker node paths (relative to the rig root), keyed by marker name.
const POSE_PATHS: Dictionary = {
"Torso": "IK_Targets/Torso",
"Head": "IK_Targets/Head",
"Left_Hand": "IK_Targets/Left_Hand",
"Right_Hand": "IK_Targets/Right_Hand",
"Left_Leg": "IK_Targets/Left_Leg",
"Right_Leg": "IK_Targets/Right_Leg",
}
## Ordered marker names, used to iterate the pose templates deterministically.
const POSE_MARKERS: Array[String] = ["Torso", "Head", "Left_Hand", "Right_Hand", "Left_Leg", "Right_Leg"]
## Pose templates (rig-local). POSE_DOWN is a generic "lying on back" rest pose;
## POSE_STANDING matches master_rig.tscn's authored IK-target defaults. The
## baked stand_up is an authored reference only — runtime recovery tweens the
## IK targets directly from the captured ragdoll pose instead.
const POSE_DOWN: Dictionary = {
"Torso": { "pos": Vector2(0, 330), "rot": -PI / 2.0 },
"Head": { "pos": Vector2(-60, 300), "rot": 0.0 },
"Left_Hand": { "pos": Vector2(100, 330), "rot": 0.0 },
"Right_Hand": { "pos": Vector2(-100, 330), "rot": 0.0 },
"Left_Leg": { "pos": Vector2(90, 300), "rot": 0.0 },
"Right_Leg": { "pos": Vector2(-90, 300), "rot": 0.0 },
}
const POSE_STANDING: Dictionary = {
"Torso": { "pos": Vector2(0, 10), "rot": 0.0 },
"Head": { "pos": Vector2(100, -614), "rot": 0.0 },
"Left_Hand": { "pos": Vector2(90, 110), "rot": 0.0 },
"Right_Hand": { "pos": Vector2(-90, 110), "rot": 0.0 },
"Left_Leg": { "pos": Vector2(-110, 380), "rot": 0.0 },
"Right_Leg": { "pos": Vector2(110, 390), "rot": 0.0 },
}
func _run() -> void:
var root = EditorInterface.get_edited_scene_root()
if not root:
push_error("EditorScript: No active scene open.")
return
var anim_player = root.get_node_or_null("AnimationPlayer") as AnimationPlayer
if not anim_player:
push_error("EditorScript: AnimationPlayer node not found under root.")
return
_generate_walk_animation(anim_player, "walk_right", 1, false) # FacingProfile.RIGHT (1)
_generate_walk_animation(anim_player, "walk_left", 0, true) # FacingProfile.LEFT (0)
_generate_pose_animation(anim_player, "stand_up", STAND_UP_DURATION, POSE_DOWN, POSE_STANDING)
func _generate_walk_animation(anim_player: AnimationPlayer, anim_name: String, profile_enum: int, flip_x: bool) -> void:
var anim = Animation.new()
anim.length = 0.8
anim.loop_mode = Animation.LOOP_LINEAR
var dir_mult: float = -1.0 if flip_x else 1.0
# 1. Profile Track (0 = LEFT, 1 = RIGHT)
var profile_track = anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(profile_track, ".:facing_profile")
anim.value_track_set_update_mode(profile_track, Animation.UPDATE_DISCRETE)
anim.track_insert_key(profile_track, 0.0, profile_enum)
# 2. Keyframe positions
var raw_tracks = {
"IK_Targets/Torso:position": [Vector2(0, 10), Vector2(0, -15), Vector2(0, 10), Vector2(0, -15), Vector2(0, 10)],
"IK_Targets/Head:position": [Vector2(100, -614), Vector2(100, -639), Vector2(100, -614), Vector2(100, -639), Vector2(100, -614)],
"IK_Targets/Right_Leg:position": [Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390)],
"IK_Targets/Left_Leg:position": [Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380)],
"IK_Targets/Right_Hand:position": [Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110)],
"IK_Targets/Left_Hand:position": [Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110)]
}
var times = [0.0, 0.2, 0.4, 0.6, 0.8]
for path in raw_tracks:
var track_idx = anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(track_idx, path)
anim.track_set_interpolation_type(track_idx, Animation.INTERPOLATION_CUBIC)
for i in range(times.size()):
var orig_pos: Vector2 = raw_tracks[path][i]
var mirrored_pos = Vector2(orig_pos.x * dir_mult, orig_pos.y)
anim.track_insert_key(track_idx, times[i], mirrored_pos)
_add_animation(anim_player, anim_name, anim)
## Generates a two-keyframe pose animation (start -> end) keying the 6 IK-target
## positions plus the Torso rotation. loop_mode is always LOOP_NONE.
func _generate_pose_animation(anim_player: AnimationPlayer, anim_name: String, duration: float, start_pose: Dictionary, end_pose: Dictionary) -> void:
var anim = Animation.new()
anim.length = duration
anim.loop_mode = Animation.LOOP_NONE
for marker_name: String in POSE_MARKERS:
var start_pos: Vector2 = start_pose[marker_name]["pos"]
var end_pos: Vector2 = end_pose[marker_name]["pos"]
var pos_track = anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(pos_track, "%s:position" % POSE_PATHS[marker_name])
anim.track_set_interpolation_type(pos_track, Animation.INTERPOLATION_CUBIC)
anim.track_insert_key(pos_track, 0.0, start_pos)
anim.track_insert_key(pos_track, duration, end_pos)
var start_rot: float = start_pose["Torso"]["rot"]
var end_rot: float = end_pose["Torso"]["rot"]
var rot_track = anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(rot_track, "IK_Targets/Torso:rotation")
anim.track_set_interpolation_type(rot_track, Animation.INTERPOLATION_CUBIC)
anim.track_insert_key(rot_track, 0.0, start_rot)
anim.track_insert_key(rot_track, duration, end_rot)
_add_animation(anim_player, anim_name, anim)
func _add_animation(anim_player: AnimationPlayer, anim_name: String, anim: Animation) -> void:
var lib = anim_player.get_animation_library("")
if not lib:
lib = AnimationLibrary.new()
anim_player.add_animation_library("", lib)
if lib.has_animation(anim_name):
lib.remove_animation(anim_name)
lib.add_animation(anim_name, anim)
print("Successfully generated '%s' animation!" % anim_name)
+1
View File
@@ -0,0 +1 @@
uid://bxsrevw15hyc6
-63
View File
@@ -1,63 +0,0 @@
@tool
extends EditorScript
func _run() -> void:
var root = EditorInterface.get_edited_scene_root()
if not root:
push_error("EditorScript: No active scene open.")
return
var anim_player = root.get_node_or_null("AnimationPlayer") as AnimationPlayer
if not anim_player:
push_error("EditorScript: AnimationPlayer node not found under root.")
return
_generate_walk_animation(anim_player, "walk_right", 1, false) # FacingProfile.RIGHT (1)
_generate_walk_animation(anim_player, "walk_left", 0, true) # FacingProfile.LEFT (0)
func _generate_walk_animation(anim_player: AnimationPlayer, anim_name: String, profile_enum: int, flip_x: bool) -> void:
var anim = Animation.new()
anim.length = 0.8
anim.loop_mode = Animation.LOOP_LINEAR
var dir_mult: float = -1.0 if flip_x else 1.0
# 1. Profile Track (0 = LEFT, 1 = RIGHT)
var profile_track = anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(profile_track, ".:facing_profile")
anim.value_track_set_update_mode(profile_track, Animation.UPDATE_DISCRETE)
anim.track_insert_key(profile_track, 0.0, profile_enum)
# 2. Keyframe positions
var raw_tracks = {
"IK_Targets/Torso:position": [Vector2(0, 10), Vector2(0, -15), Vector2(0, 10), Vector2(0, -15), Vector2(0, 10)],
"IK_Targets/Head:position": [Vector2(100, -614), Vector2(100, -639), Vector2(100, -614), Vector2(100, -639), Vector2(100, -614)],
"IK_Targets/Right_Leg:position": [Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390)],
"IK_Targets/Left_Leg:position": [Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380)],
"IK_Targets/Right_Hand:position": [Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110)],
"IK_Targets/Left_Hand:position": [Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110)]
}
var times = [0.0, 0.2, 0.4, 0.6, 0.8]
for path in raw_tracks:
var track_idx = anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(track_idx, path)
anim.track_set_interpolation_type(track_idx, Animation.INTERPOLATION_CUBIC)
for i in range(times.size()):
var orig_pos: Vector2 = raw_tracks[path][i]
var mirrored_pos = Vector2(orig_pos.x * dir_mult, orig_pos.y)
anim.track_insert_key(track_idx, times[i], mirrored_pos)
# 3. Attach animation to AnimationPlayer library
var lib = anim_player.get_animation_library("")
if not lib:
lib = AnimationLibrary.new()
anim_player.add_animation_library("", lib)
if lib.has_animation(anim_name):
lib.remove_animation(anim_name)
lib.add_animation(anim_name, anim)
print("Successfully generated '%s' animation!" % anim_name)
-1
View File
@@ -1 +0,0 @@
uid://c0l8if18mvdvb
+45
View File
@@ -62,6 +62,9 @@ var _rig: StickmanRig = null
## Top-bar mode toggle button (text flips "Stickman" <-> "Ragdoll"). ## Top-bar mode toggle button (text flips "Stickman" <-> "Ragdoll").
var _ragdoll_toggle: Button = null var _ragdoll_toggle: Button = null
## Rest-timeout spinbox (director-controlled auto-recovery delay).
var _rest_timeout_spinbox: SpinBox = null
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Lifecycle # Lifecycle
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -149,6 +152,36 @@ func _build_ui() -> void:
knock_btn.pressed.connect(_knock_up) knock_btn.pressed.connect(_knock_up)
hbox.add_child(knock_btn) hbox.add_child(knock_btn)
var rest_label := Label.new()
rest_label.text = "Rest"
hbox.add_child(rest_label)
_rest_timeout_spinbox = SpinBox.new()
_rest_timeout_spinbox.min_value = 0.1
_rest_timeout_spinbox.max_value = 10.0
_rest_timeout_spinbox.step = 0.1
_rest_timeout_spinbox.value = 2.0
_rest_timeout_spinbox.value_changed.connect(_on_rest_timeout_changed)
hbox.add_child(_rest_timeout_spinbox)
var recover_btn := Button.new()
recover_btn.text = "Recover Now"
recover_btn.pressed.connect(_recover_now)
hbox.add_child(recover_btn)
if _rig != null:
_rest_timeout_spinbox.value = _rig.rest_timeout
func _on_rest_timeout_changed(v: float) -> void:
if _rig != null:
_rig.rest_timeout = v
func _recover_now() -> void:
if _rig != null:
_rig.request_recovery()
func _on_ragdoll_toggled(pressed: bool) -> void: func _on_ragdoll_toggled(pressed: bool) -> void:
if _rig == null: if _rig == null:
@@ -223,8 +256,20 @@ func _spawn_rig() -> void:
rig.position = RIG_SPAWN_POSITION rig.position = RIG_SPAWN_POSITION
add_child(rig) add_child(rig)
_rig = rig _rig = rig
rig.state_changed.connect(_on_rig_state_changed)
_add_rig_collision_proxy() _add_rig_collision_proxy()
## Keeps the best-effort collision proxy in sync with the rig's physics mode:
## the ragdoll collides directly with the terrain, so the proxy is removed
## during RAGDOLL and re-added otherwise. Proxy helpers are idempotent.
func _on_rig_state_changed(new_state: int) -> void:
if new_state == StickmanRig.RigState.RAGDOLL:
_remove_rig_collision_proxy()
else:
_add_rig_collision_proxy()
_update_ragdoll_toggle()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Dynamic prop spawning (keys 1/2/3) # Dynamic prop spawning (keys 1/2/3)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+272 -16
View File
@@ -20,8 +20,10 @@ enum FacingProfile { LEFT, RIGHT, FORWARD }
enum BendDirection { NORMAL, INVERTED } enum BendDirection { NORMAL, INVERTED }
## Rig physics mode. ANIMATED drives the skeleton + IK; RAGDOLL swaps in a ## Rig physics mode. ANIMATED drives the skeleton + IK; RAGDOLL swaps in a
## procedural RigidBody2D + PinJoint2D network (see _build_ragdoll). ## procedural RigidBody2D + PinJoint2D network (see _build_ragdoll);
enum RigState { ANIMATED, RAGDOLL } ## RECOVERING snaps the skeleton back to the captured rest pose and tweens the
## IK targets to the standing pose before returning to ANIMATED.
enum RigState { ANIMATED, RAGDOLL, RECOVERING }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Constants # Constants
@@ -100,6 +102,46 @@ const RAGDOLL_VISUAL_COLOR := Color(0.445488, 0.445488, 0.445488)
const RAGDOLL_HEAD_VISUAL_COLOR := Color.WHITE const RAGDOLL_HEAD_VISUAL_COLOR := Color.WHITE
const RAGDOLL_CIRCLE_SEGMENTS := 32 const RAGDOLL_CIRCLE_SEGMENTS := 32
# ---------------------------------------------------------------------------
# Blend / recovery constants
# ---------------------------------------------------------------------------
## Rest detection thresholds (linear px/s, angular rad/s) for the ragdoll
## torso. 5.0 px/s (not the plan's 0.1) — a soft-pinned ragdoll micro-jitters
## around ~0.5 px/s even when fully settled, so 0.1 is never reached. A body
## that the physics engine has put to sleep also counts as at rest.
const REST_LINEAR_THRESHOLD := 5.0
const REST_ANGULAR_THRESHOLD := 0.1
## Duration of the stand-up tween (captured pose -> STAND_POSE).
const STAND_UP_DURATION := 0.8
## Extra hold after rest is detected before recovery captures the pose.
const STABILIZATION_DELAY := 0.1
## Pin softness applied to every ragdoll joint at build time.
const RAGDOLL_TARGET_SOFTNESS := 0.2
## IK-target standing positions (rig-local), matching master_rig.tscn defaults.
const STAND_POSE: Dictionary = {
"Torso": { "pos": Vector2(0, 10), "rot": 0.0 },
"Head": { "pos": Vector2(100, -614), "rot": 0.0 },
"Left_Hand": { "pos": Vector2(90, 110), "rot": 0.0 },
"Right_Hand": { "pos": Vector2(-90, 110), "rot": 0.0 },
"Left_Leg": { "pos": Vector2(-110, 380), "rot": 0.0 },
"Right_Leg": { "pos": Vector2(110, 390), "rot": 0.0 },
}
## IK-target marker node paths (rig-root relative), keyed by marker name.
const IK_TARGET_PATHS: Dictionary = {
"Torso": "IK_Targets/Torso",
"Head": "IK_Targets/Head",
"Left_Hand": "IK_Targets/Left_Hand",
"Right_Hand": "IK_Targets/Right_Hand",
"Left_Leg": "IK_Targets/Left_Leg",
"Right_Leg": "IK_Targets/Right_Leg",
}
## Ragdoll body definitions, ordered parent-before-child. `node_path` is ## Ragdoll body definitions, ordered parent-before-child. `node_path` is
## Skeleton2D-relative for bones and rig-root-relative for the head visual. ## 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). ## `kind` is "bone" (capsule along a Bone2D) or "visual" (circle at Body/Head).
@@ -166,6 +208,14 @@ const RAGDOLL_JOINTS: Array[Dictionary] = [
right_leg_bend = value right_leg_bend = value
_set_joint_bend_inverted("RightLeg", value == BendDirection.INVERTED) _set_joint_bend_inverted("RightLeg", value == BendDirection.INVERTED)
@export_group("Ragdoll Transition")
## How long the ragdoll torso must be at rest before auto-recovery (seconds).
@export var rest_timeout: float = 2.0
## When true, a rested ragdoll automatically stands back up. When false, the
## ragdoll stays down until request_recovery() is called manually.
@export var auto_recover: bool = true
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Signals # Signals
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -188,6 +238,7 @@ signal state_changed(new_state: int)
var _nodes_ready: bool = false var _nodes_ready: bool = false
var _skeleton: Skeleton2D = null var _skeleton: Skeleton2D = null
var _body_container: Node2D = null var _body_container: Node2D = null
var _torso_bone: Bone2D = null
var _bend_joint_bones: Dictionary = {} # { String : Bone2D } (lower bones) var _bend_joint_bones: Dictionary = {} # { String : Bone2D } (lower bones)
var _bend_modifications: Dictionary = {} # { String : SkeletonModification2DTwoBoneIK } var _bend_modifications: Dictionary = {} # { String : SkeletonModification2DTwoBoneIK }
@@ -205,6 +256,15 @@ var _prev_global_rot: float = 0.0
var _cached_linear_velocity: Vector2 = Vector2.ZERO var _cached_linear_velocity: Vector2 = Vector2.ZERO
var _cached_angular_velocity: float = 0.0 var _cached_angular_velocity: float = 0.0
# ---------------------------------------------------------------------------
# Recovery state
# ---------------------------------------------------------------------------
var _rest_timer: float = 0.0
var _stabilize_timer: float = 0.0
var _captured_pose: Dictionary = {} # { String : {pos, rot, half} } (rig-local)
var _stand_up_tween: Tween = null
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Lifecycle # Lifecycle
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -219,6 +279,11 @@ func _ready() -> void:
if _body_container == null: if _body_container == null:
push_warning("StickmanRig: missing '%s' node in rig." % BODY_CONTAINER_PATH) push_warning("StickmanRig: missing '%s' node in rig." % BODY_CONTAINER_PATH)
if _skeleton != null:
_torso_bone = _skeleton.get_node_or_null(NodePath("Torso")) as Bone2D
if _torso_bone == null:
push_warning("StickmanRig: missing 'Torso' bone in Skeleton2D.")
_anim_player = get_node_or_null(NodePath(ANIMATION_PLAYER_PATH)) as AnimationPlayer _anim_player = get_node_or_null(NodePath(ANIMATION_PLAYER_PATH)) as AnimationPlayer
if _anim_player == null: if _anim_player == null:
push_warning("StickmanRig: missing '%s' node in rig." % ANIMATION_PLAYER_PATH) push_warning("StickmanRig: missing '%s' node in rig." % ANIMATION_PLAYER_PATH)
@@ -246,6 +311,7 @@ func _ready() -> void:
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
_track_momentum(delta) _track_momentum(delta)
_update_rest_detection(delta)
func _track_momentum(delta: float) -> void: func _track_momentum(delta: float) -> void:
@@ -255,6 +321,35 @@ func _track_momentum(delta: float) -> void:
_prev_global_pos = global_position _prev_global_pos = global_position
_prev_global_rot = global_rotation _prev_global_rot = global_rotation
# ---------------------------------------------------------------------------
# Per-frame rest detection
# ---------------------------------------------------------------------------
## RAGDOLL rest detection: when the torso sits still long enough (and
## auto_recover is on), trigger recovery after a short stabilization delay.
func _update_rest_detection(delta: float) -> void:
if state != RigState.RAGDOLL:
return
var torso := _ragdoll_bodies.get("torso") as RigidBody2D
if torso == null or not is_instance_valid(torso):
return
var at_rest := torso.sleeping \
or (torso.linear_velocity.length() <= REST_LINEAR_THRESHOLD \
and absf(torso.angular_velocity) <= REST_ANGULAR_THRESHOLD)
if not at_rest:
_rest_timer = 0.0
_stabilize_timer = 0.0
return
if not auto_recover:
_rest_timer = 0.0
return
_rest_timer += delta
if _rest_timer < rest_timeout:
return
_stabilize_timer += delta
if _stabilize_timer >= STABILIZATION_DELAY:
_start_recovery()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Public API # Public API
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -316,16 +411,31 @@ func is_in_ragdoll() -> bool:
func set_ragdoll(enabled: bool) -> void: func set_ragdoll(enabled: bool) -> void:
if enabled and not is_in_ragdoll(): if enabled:
match state:
RigState.RAGDOLL:
return
RigState.RECOVERING:
_cancel_recovery()
_enter_ragdoll() _enter_ragdoll()
elif not enabled and is_in_ragdoll(): _:
_exit_ragdoll() _enter_ragdoll()
else:
if state == RigState.RAGDOLL:
_start_recovery()
# else ANIMATED / RECOVERING: no-op
func toggle_ragdoll() -> void: func toggle_ragdoll() -> void:
set_ragdoll(not is_in_ragdoll()) set_ragdoll(not is_in_ragdoll())
## Public stand-up request. No-op unless the rig is in RAGDOLL.
func request_recovery() -> void:
if state == RigState.RAGDOLL:
_start_recovery()
## Applies the same velocity delta to every ragdoll body via a mass-scaled ## Applies the same velocity delta to every ragdoll body via a mass-scaled
## central impulse, preserving the ragdoll's internal structure. No-op outside ## central impulse, preserving the ragdoll's internal structure. No-op outside
## RAGDOLL mode. Used by the physics harness "Knock Up" button. ## RAGDOLL mode. Used by the physics harness "Knock Up" button.
@@ -431,26 +541,163 @@ func _enter_ragdoll() -> void:
if _skeleton == null or _body_container == null: if _skeleton == null or _body_container == null:
push_warning("StickmanRig: cannot enter ragdoll; missing rig nodes.") push_warning("StickmanRig: cannot enter ragdoll; missing rig nodes.")
return return
# Freeze the kinematic puppet: disable IK, stop animation, hide visuals. # Instant handoff: stop the player without resetting it (keep_state) and
# build the ragdoll from the CURRENT solved bone positions while the IK
# stack is still enabled (disabling it first would revert the bones to the
# authored rest pose). Body/* is then hidden immediately and IK disabled —
# no crossfade, because the ragdoll is spawned at exactly the same pose, so
# a fade would only read as ghosting.
if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop(true)
_build_ragdoll()
if is_instance_valid(_body_container):
_body_container.visible = false
_body_container.modulate.a = 1.0
if _skeleton.modification_stack != null: if _skeleton.modification_stack != null:
_skeleton.modification_stack.enabled = false _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 = RigState.RAGDOLL
state_changed.emit(int(state)) state_changed.emit(int(state))
_rest_timer = 0.0
_stabilize_timer = 0.0
func _exit_ragdoll() -> void: # ---------------------------------------------------------------------------
# Recovery (ragdoll -> kinematic stand-up)
# ---------------------------------------------------------------------------
## Captures every ragdoll body's global transform into rig-local space, plus
## each capsule's half-length (from build-time metadata) so the snap can derive
## the real joint ends (hip / wrist / ankle) instead of body midpoints.
func _capture_ragdoll_pose() -> void:
_captured_pose.clear()
for key: String in _ragdoll_bodies:
var body := _ragdoll_bodies[key] as RigidBody2D
if body == null or not is_instance_valid(body):
continue
_captured_pose[key] = {
"pos": to_local(body.global_position),
"rot": body.global_rotation - global_rotation,
"half": float(body.get_meta("half_height", 0.0)),
}
func _start_recovery() -> void:
_capture_ragdoll_pose()
_destroy_ragdoll() _destroy_ragdoll()
state = RigState.RECOVERING
state_changed.emit(int(state))
_snap_skeleton_to_pose()
_play_stand_up()
## Kills any in-flight stand-up tween so a re-entry into RAGDOLL starts from a
## clean slate.
func _cancel_recovery() -> void:
if _stand_up_tween != null and _stand_up_tween.is_valid():
_stand_up_tween.kill()
_stand_up_tween = null
## Marker-driven kinematic snap: writes the captured pose onto the 6 IK-target
## markers (NOT the Torso Bone2D, which is slaved to its marker via
## RemoteTransform2D), then re-enables IK so TwoBoneIK solves the limbs toward
## the captured end-effectors.
##
## Geometry notes: the ragdoll capsules span joint origin -> tip along their
## +X (body.rotation IS the segment direction), so the real joints are at
## center ± direction * half_height. The Torso marker rotation must also
## subtract the Torso Bone2D's `bone_angle` (bone world angle = marker rotation
## + bone_angle); using the body rotation directly would slam the whole
## skeleton -90° and lay the figure flat.
func _snap_skeleton_to_pose() -> void:
var torso_marker := _get_ik_marker("Torso")
if torso_marker != null:
var torso_pose: Dictionary = _captured_pose.get("torso", {})
if not torso_pose.is_empty():
var spine_dir := Vector2.from_angle(torso_pose.get("rot", 0.0))
var half := float(torso_pose.get("half", 0.0))
var bone_angle_rad := 0.0
if _torso_bone != null:
bone_angle_rad = deg_to_rad(_torso_bone.bone_angle)
# Hip = spine bottom end of the torso capsule.
torso_marker.position = torso_pose.get("pos", torso_marker.position) - spine_dir * half
torso_marker.rotation = torso_pose.get("rot", 0.0) - bone_angle_rad
var head_marker := _get_ik_marker("Head")
if head_marker != null:
var head_pose: Dictionary = _captured_pose.get("head", {})
if not head_pose.is_empty():
head_marker.position = head_pose.get("pos", head_marker.position)
_set_marker_from_body("Left_Hand", "left_lower_arm")
_set_marker_from_body("Right_Hand", "right_lower_arm")
_set_marker_from_body("Left_Leg", "left_lower_leg")
_set_marker_from_body("Right_Leg", "right_lower_leg")
# Show the kinematic puppet first so it appears already in the captured
# pose, then re-enable IK to solve toward the end-effector markers.
if _body_container != null and is_instance_valid(_body_container): if _body_container != null and is_instance_valid(_body_container):
_body_container.visible = true _body_container.visible = true
if _skeleton != null and is_instance_valid(_skeleton): _body_container.modulate.a = 1.0
if _skeleton.modification_stack != null: if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
_skeleton.modification_stack.enabled = true _skeleton.modification_stack.enabled = true
if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop()
func _set_marker_from_body(marker_name: String, body_key: String) -> void:
var marker := _get_ik_marker(marker_name)
if marker == null:
return
var pose: Dictionary = _captured_pose.get(body_key, {})
if pose.is_empty():
return
# Far end (wrist / ankle) = body center + segment direction * half.
var dir := Vector2.from_angle(pose.get("rot", 0.0))
var half := float(pose.get("half", 0.0))
marker.position = pose.get("pos", marker.position) + dir * half
func _get_ik_marker(name: String) -> Marker2D:
var path: String = IK_TARGET_PATHS.get(name, "")
if path.is_empty():
return null
return get_node_or_null(NodePath(path)) as Marker2D
## Stand-up: tweens the 6 IK markers from the captured pose to STAND_POSE
## (sine ease-in-out). No baked animation — a fixed first keyframe can never
## match an arbitrary ragdoll rest pose, so the tween starts from wherever the
## snap left the markers.
func _play_stand_up() -> void:
_stand_up_tween = _tween_markers_to(STAND_POSE, STAND_UP_DURATION)
if _stand_up_tween != null:
_stand_up_tween.finished.connect(_on_stand_up_finished)
## Tweens the 6 IK markers from their current (captured) values to the target
## pose over `duration` (sine ease-in-out), all in parallel.
func _tween_markers_to(target_pose: Dictionary, duration: float) -> Tween:
var tween := create_tween()
tween.set_parallel(true)
tween.set_trans(Tween.TRANS_SINE)
tween.set_ease(Tween.EASE_IN_OUT)
for marker_name: String in target_pose:
var marker := _get_ik_marker(marker_name)
if marker == null:
continue
var target: Dictionary = target_pose[marker_name]
tween.tween_property(marker, "position", target.get("pos", marker.position), duration)
if marker_name == "Torso":
tween.tween_property(marker, "rotation", target.get("rot", marker.rotation), duration)
return tween
## Stand-up tween complete: settle into ANIMATED.
func _on_stand_up_finished() -> void:
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
_skeleton.modification_stack.enabled = true
if _body_container != null and is_instance_valid(_body_container):
_body_container.visible = true
_body_container.modulate.a = 1.0
state = RigState.ANIMATED state = RigState.ANIMATED
state_changed.emit(int(state)) state_changed.emit(int(state))
@@ -507,6 +754,7 @@ func _build_ragdoll_body(entry: Dictionary) -> void:
body.add_child(shape) body.add_child(shape)
body.position = visual.global_position body.position = visual.global_position
body.rotation = 0.0 body.rotation = 0.0
body.set_meta("half_height", 0.0)
_add_ragdoll_visual_circle(body, float(entry["radius"]), RAGDOLL_HEAD_VISUAL_COLOR) _add_ragdoll_visual_circle(body, float(entry["radius"]), RAGDOLL_HEAD_VISUAL_COLOR)
else: else:
var bone := _skeleton.get_node_or_null(NodePath(entry["node_path"])) as Bone2D var bone := _skeleton.get_node_or_null(NodePath(entry["node_path"])) as Bone2D
@@ -542,6 +790,9 @@ func _build_ragdoll_body(entry: Dictionary) -> void:
body.add_child(shape) body.add_child(shape)
body.position = midpoint body.position = midpoint
body.rotation = (tip - origin).angle() body.rotation = (tip - origin).angle()
# Half the capsule's length along the body's +X — lets recovery derive
# the joint ends (hip/wrist/ankle) from the body center at capture time.
body.set_meta("half_height", length * 0.5)
_add_ragdoll_visual_capsule(body, length, float(entry["radius"]), RAGDOLL_VISUAL_COLOR) _add_ragdoll_visual_capsule(body, length, float(entry["radius"]), RAGDOLL_VISUAL_COLOR)
_ragdoll_root.add_child(body) _ragdoll_root.add_child(body)
@@ -595,7 +846,7 @@ func _build_ragdoll_joint(entry: Dictionary) -> void:
_ragdoll_root.add_child(pin) _ragdoll_root.add_child(pin)
pin.node_a = pin.get_path_to(parent_body) pin.node_a = pin.get_path_to(parent_body)
pin.node_b = pin.get_path_to(child_body) pin.node_b = pin.get_path_to(child_body)
pin.softness = 0.0 pin.softness = RAGDOLL_TARGET_SOFTNESS
_apply_ragdoll_joint_limits(pin, entry["limit"]) _apply_ragdoll_joint_limits(pin, entry["limit"])
@@ -623,6 +874,11 @@ func _apply_ragdoll_joint_limits(pin: PinJoint2D, limit: String) -> void:
func _destroy_ragdoll() -> void: func _destroy_ragdoll() -> void:
if _ragdoll_root != null and is_instance_valid(_ragdoll_root): if _ragdoll_root != null and is_instance_valid(_ragdoll_root):
# Retire the name immediately so a same-frame _build_ragdoll (e.g.
# set_ragdoll(true) during RECOVERING) does not get its fresh container
# auto-renamed by Godot's sibling-name de-duplication while the old one
# is still awaiting its deferred queue_free().
_ragdoll_root.name = RAGDOLL_CONTAINER_NAME + "_retired"
_ragdoll_root.queue_free() _ragdoll_root.queue_free()
_ragdoll_root = null _ragdoll_root = null
_ragdoll_bodies.clear() _ragdoll_bodies.clear()