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:`.
- 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.
- 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
+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 `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.
- 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
+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
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
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"]`),
`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
@@ -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
`@export_group("Bend Direction")` of four `@export_enum("Normal","Inverted")` vars
`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`,
`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`/
@@ -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
`"StickmanRig: "` throughout; never crashes.
- **Phase 10 ragdoll state system:** `StickmanRig` owns a reversible `ANIMATED ⇄ RAGDOLL`
physics mode switch. `enum RigState { ANIMATED, RAGDOLL }`, `var state: RigState`
(default `ANIMATED`), `signal state_changed(new_state: int)`, and public API
`set_ragdoll(enabled: bool)` / `toggle_ragdoll()` / `is_in_ragdoll() -> bool`.
`_physics_process()` → `_track_momentum(delta)` caches the rig root's linear/angular
velocity from per-frame `global_position`/`global_rotation` deltas. `_enter_ragdoll()`
disables the IK modification stack, `stop()`s the `AnimationPlayer` (`ANIMATION_PLAYER_PATH`
const), hides `Body/*`, builds the ragdoll, then sets `state` + emits `state_changed`.
`_build_ragdoll()` creates a `Node2D` container `"RagdollBodyContainer"` under the rig's
**parent** (world root; fallback `get_tree().current_scene`) and populates it from the
`RAGDOLL_BODIES` table (**10** `RigidBody2D`: torso `CapsuleShape2D` radius 12 mass 8.0,
head `CircleShape2D` radius 100 mass 2.0, limb capsules radius 8 masses 1.02.0;
`collision_layer`/`collision_mask` = 1) and the `RAGDOLL_JOINTS` table (**9** `PinJoint2D`,
one per non-root body pinned at the child bone's origin, `softness` 0.0). Angular limits via
`_apply_ragdoll_joint_limits()`: `elbow_knee` folds +CW `-5°..+150°`, `elbow_knee_ccw`
`-150°..+5°`, `shoulder_hip` ±160°, default (neck) free. Cached momentum is applied to the
torso body. `apply_ragdoll_velocity_boost(velocity)` applies the same velocity delta
(mass-scaled `apply_central_impulse`) to every ragdoll body — used by the harness "Knock
Up" button. `_exit_ragdoll()` `queue_free()`s the container, re-shows `Body/*`, re-enables
the IK stack, `stop()`s the animation, and reverts `state` to `ANIMATED`. All ragdoll nodes
are spawned procedurally — `master_rig.tscn` is **not** modified.
physics mode switch plus a `RECOVERING` stand-up state. `enum RigState { ANIMATED, RAGDOLL,
RECOVERING }`, `var state: RigState` (default `ANIMATED`), `signal state_changed(new_state:
int)`, and public API `set_ragdoll(enabled: bool)` / `toggle_ragdoll()` / `is_in_ragdoll() ->
bool` / `request_recovery()`. `_physics_process()` → `_track_momentum(delta)` caches the rig
root's linear/angular velocity from per-frame `global_position`/`global_rotation` deltas, then
drives `_update_rest_detection()` (auto-recovery trigger). `_enter_ragdoll()` `stop(true)`s the
`AnimationPlayer` (`ANIMATION_PLAYER_PATH` const, keep_state — no pose reset), builds 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, popping the figure), then hides
`Body/*` and disables the IK stack **immediately** — an instant handoff with no crossfade (the
ragdoll spawns at exactly the same pose, so a fade would only read as ghosting), sets `state =
RAGDOLL` + emits. `_build_ragdoll()` creates a `Node2D`
container `"RagdollBodyContainer"` under the rig's **parent** (world root; fallback
`get_tree().current_scene`) and populates it from the `RAGDOLL_BODIES` table (**10**
`RigidBody2D`: torso `CapsuleShape2D` radius 12 mass 8.0, head `CircleShape2D` radius 100 mass
2.0, limb capsules radius 8 masses 1.02.0; `collision_layer`/`collision_mask` = 1, bodies
spawn fully visible) and the `RAGDOLL_JOINTS` table (**9** `PinJoint2D`, one per
non-root body pinned at the child bone's origin, `softness = RAGDOLL_TARGET_SOFTNESS` at
build). Angular limits via `_apply_ragdoll_joint_limits()`: `elbow_knee` folds +CW
`-5°..+150°`, `elbow_knee_ccw` `-150°..+5°`, `shoulder_hip` ±160°, default (neck) free. Cached
momentum is applied to the torso body. `apply_ragdoll_velocity_boost(velocity)` applies the
same velocity delta (mass-scaled `apply_central_impulse`) to every ragdoll body — used by the
harness "Knock Up" button. 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
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:
@@ -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).
- `static func spawn(path: String) -> StickmanRig` — `load_stk()` then `spawn_from_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**;
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`);
@@ -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,
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.
- **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/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used
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**.
- `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
@@ -349,20 +349,22 @@ A **unified live-update setter** drives geometry to all children live — a chan
### 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.
- **`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`):**
| 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. |
| `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). |
| `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.
@@ -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** |
| Head | `CircleShape2D` (radius 100), mass **2.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`) |
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`).
**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`)
@@ -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/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_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://scenes/test_harness.tscn` | **Phase 9.** Standalone staging scene backing `scripts/test_harness.gd` (run via **F6**; not wired into the editor). |
| `res://scripts/terrain_block.gd` | **Vector Terrain System.** `class_name TerrainBlock`, `extends StaticBody2D` — a reusable vector terrain component building `Polygon2D` (fill) + `Line2D` (border) + `CollisionPolygon2D` (`BUILD_SOLIDS`, supports concave) children in code. |
| `res://scripts/terrain_utils.gd` | **Vector Terrain System.** `class_name TerrainUtils`, `extends RefCounted` — static `sanitize_points()` (grid snap → local `_simplify_polyline()` → clockwise enforcement) and a `spawn_block()` factory. |
| `res://scripts/physics_test_harness.gd` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene root building flat/ramp/step terrain via `TerrainUtils`, instantiating `master_rig.tscn`, spawning props via **1/2/3** (`PropUtils`), and adding a rig collision proxy (run via **F6**; not wired into the editor). 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://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`). |
@@ -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 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. |
| 4 | **Performance (Ragdoll Pooling)** — Spawning 10 bodies + 9 joints procedurally is fine for a single rig. If the scene ever contains dozens of ragdolls, consider a pooling system to avoid allocation spikes. | Low | Open | Not needed now, but worth noting if scaling to large crowds. |
| 5 | **Line2D ↔ Capsule Radius Match** — Limbs use `Line2D` width 16, ragdoll capsules radius 8. These align visually. | ✅ Resolved | Closed | Verified during implementation. No action needed. |
| 6 | **Recovery Animation Starting Pose** — The `stand_up` animation must work from any captured ragdoll pose. Currently uses a fixed start frame. | High | Open | Investigate blending the captured pose with the animation's first keyframe using an additive blend or a `SkeletonModification` that interpolates. |
| 7 | **Transition Visual Pop** — The crossfade between kinematic and ragdoll currently uses a simple `modulate.a` lerp. This may cause ghosting if the kinematic and ragdoll poses are misaligned. | Medium | Open | Ensure the kinematic skeleton is snapped to the ragdoll pose _before_ the fade begins (or vice versa) to avoid doubleexposure. |
| 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. |
| 9 | **Animation Generation DRY**`create_walk.gd` is a standalone script. It should be merged into a unified `create_animations.gd` that also generates `stand_up` and idle animations. | Medium | Open | Refactor to support parameterized generation (profile, duration, target pose). |
| 10 | **Rig Collision Proxy 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. |
| 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 | ✅ 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 | ✅ 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 | ✅ 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 | 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)
| Improvement | Description | Priority |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------- |
| **Soft Transition Blending** | Add physical blending (joint stiffness ramp) to complement visual crossfade. | Medium |
| **Ragdoll Recovery Interruptibility** | Allow the director to force a mode switch midrecovery (e.g., if they want the character to ragdoll again immediately). | Low |
| Improvement | Description | Status / Priority |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ----------------- |
| **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). | ✅ Done |
| **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 |
| **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 |
| ---------- | -------------------------------------------------------------- |
| 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
[sub_resource type="SkeletonModificationStack2D" id="SkeletonModificationStack2D_j4hao"]
enabled = true
modification_count = 5
modifications/0 = SubResource("SkeletonModification2DTwoBoneIK_yvxej")
modifications/1 = SubResource("SkeletonModification2DTwoBoneIK_f0s26")
@@ -86,6 +85,93 @@ tracks/0/keys = {
[sub_resource type="Animation" id="Animation_ylko5"]
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
tracks/0/type = "value"
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)]
}
[sub_resource type="Animation" id="Animation_t75yq"]
[sub_resource type="Animation" id="Animation_m3sq4"]
length = 0.8
loop_mode = 1
tracks/0/type = "value"
@@ -263,8 +349,9 @@ tracks/6/keys = {
[sub_resource type="AnimationLibrary" id="AnimationLibrary_t75yq"]
_data = {
&"RESET": SubResource("Animation_2leu7"),
&"walk_left": SubResource("Animation_ylko5"),
&"walk_right": SubResource("Animation_t75yq")
&"stand_up": SubResource("Animation_ylko5"),
&"walk_left": SubResource("Animation_t75yq"),
&"walk_right": SubResource("Animation_m3sq4")
}
[sub_resource type="AnimationNodeStateMachine" id="AnimationNodeStateMachine_6rw38"]
@@ -282,69 +369,67 @@ width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="LeftUpperLeg" type="Line2D" parent="Body" unique_id=199373156]
position = Vector2(1.6037084e-05, 10.000003)
rotation = 0.55427897
position = Vector2(1.6098846e-05, 10.000002)
rotation = 0.4947604
points = PackedVector2Array(0, 0, 0, 200)
width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="RightUpperLeg" type="Line2D" parent="Body" unique_id=1396556308]
position = Vector2(-2.4553774e-05, 10.000021)
rotation = -0.43021643
position = Vector2(-2.3252098e-05, 10.000022)
rotation = -0.49392816
scale = Vector2(0.99999994, 0.99999994)
points = PackedVector2Array(0, 0, 0, 200)
width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="LeftLowerLeg" type="Line2D" parent="Body" unique_id=1590485904]
position = Vector2(-105.26607, 180.05603)
rotation = 0.023672067
position = Vector2(-94.96416, 186.0165)
rotation = 0.0051915743
scale = Vector2(0.99999994, 0.99999994)
points = PackedVector2Array(0, 0, 0, 200)
width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="RightLowerLeg" type="Line2D" parent="Body" unique_id=29203948]
position = Vector2(83.4135, 191.77509)
rotation = -0.13332568
scale = Vector2(0.9999997, 0.9999997)
position = Vector2(94.817635, 186.09546)
rotation = -0.0034907677
scale = Vector2(0.9999998, 0.9999998)
points = PackedVector2Array(0, 0, 0, 200)
width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="LeftUpperArm" type="Line2D" parent="Body" unique_id=744524157]
position = Vector2(9.313226e-10, -238)
rotation = -0.48905078
rotation = 1.6184965
points = PackedVector2Array(0, 0, 0, 175)
width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="RightUpperArm" type="Line2D" parent="Body" unique_id=1593646765]
position = Vector2(9.313226e-10, -238)
rotation = 0.48905125
scale = Vector2(0.99999994, 0.99999994)
rotation = -1.6184964
points = PackedVector2Array(0, 0, 0, 175)
width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="LeftLowerArm" type="Line2D" parent="Body" unique_id=2142117097]
position = Vector2(78.92438, -89.6931)
rotation = -0.055406693
position = Vector2(-167.80888, -246.01059)
rotation = 3.1406152
points = PackedVector2Array(0, 0, 0, 200)
width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="RightLowerArm" type="Line2D" parent="Body" unique_id=1200980480]
position = Vector2(-78.924484, -89.69313)
rotation = 0.055405635
position = Vector2(167.80891, -246.01057)
rotation = -3.1406155
points = PackedVector2Array(0, 0, 0, 200)
width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="Head" type="Node2D" parent="Body" unique_id=864822355]
position = Vector2(28.397173, -447.61597)
rotation = 0.4066687
scale = Vector2(0.9999997, 0.9999997)
position = Vector2(-0.07846909, -453.50793)
scale = Vector2(0.99999887, 0.99999887)
script = SubResource("GDScript_f0s26")
[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
length = 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]
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
length = 168.0
bone_angle = -180.0
metadata/_local_pose_override_enabled_ = true
[node name="LeftLowerArm" type="Bone2D" parent="Skeleton2D/Torso/LeftUpperArm" unique_id=721380545]
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
length = 200.0
bone_angle = -90.0
metadata/_local_pose_override_enabled_ = true
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/LeftUpperArm/LeftLowerArm" unique_id=1189277122]
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
length = 168.00006
bone_angle = 0.0
metadata/_local_pose_override_enabled_ = true
[node name="RightLowerArm" type="Bone2D" parent="Skeleton2D/Torso/RightUpperArm" unique_id=1018820563]
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
length = 200.0
bone_angle = 0.0
metadata/_local_pose_override_enabled_ = true
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/RightUpperArm/RightLowerArm" unique_id=1282597655]
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
length = 200.0
bone_angle = 90.0
metadata/_local_pose_override_enabled_ = true
[node name="LeftLowerLeg" type="Bone2D" parent="Skeleton2D/Torso/LeftUpperLeg" unique_id=81288294]
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
length = 200.0
bone_angle = 0.0
metadata/_local_pose_override_enabled_ = true
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg" unique_id=1673135542]
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
length = 200.0
bone_angle = 90.0
metadata/_local_pose_override_enabled_ = true
[node name="RightLowerLeg" type="Bone2D" parent="Skeleton2D/Torso/RightUpperLeg" unique_id=1819999778]
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
length = 200.0
bone_angle = 0.0
metadata/_local_pose_override_enabled_ = true
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/RightUpperLeg/RightLowerLeg" unique_id=278100224]
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").
var _ragdoll_toggle: Button = null
## Rest-timeout spinbox (director-controlled auto-recovery delay).
var _rest_timeout_spinbox: SpinBox = null
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
@@ -149,6 +152,36 @@ func _build_ui() -> void:
knock_btn.pressed.connect(_knock_up)
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:
if _rig == null:
@@ -223,8 +256,20 @@ func _spawn_rig() -> void:
rig.position = RIG_SPAWN_POSITION
add_child(rig)
_rig = rig
rig.state_changed.connect(_on_rig_state_changed)
_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)
# ---------------------------------------------------------------------------
+272 -16
View File
@@ -20,8 +20,10 @@ enum FacingProfile { LEFT, RIGHT, FORWARD }
enum BendDirection { NORMAL, INVERTED }
## Rig physics mode. ANIMATED drives the skeleton + IK; RAGDOLL swaps in a
## procedural RigidBody2D + PinJoint2D network (see _build_ragdoll).
enum RigState { ANIMATED, RAGDOLL }
## procedural RigidBody2D + PinJoint2D network (see _build_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
@@ -100,6 +102,46 @@ const RAGDOLL_VISUAL_COLOR := Color(0.445488, 0.445488, 0.445488)
const RAGDOLL_HEAD_VISUAL_COLOR := Color.WHITE
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
## 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).
@@ -166,6 +208,14 @@ const RAGDOLL_JOINTS: Array[Dictionary] = [
right_leg_bend = value
_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
# ---------------------------------------------------------------------------
@@ -188,6 +238,7 @@ signal state_changed(new_state: int)
var _nodes_ready: bool = false
var _skeleton: Skeleton2D = null
var _body_container: Node2D = null
var _torso_bone: Bone2D = null
var _bend_joint_bones: Dictionary = {} # { String : Bone2D } (lower bones)
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_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
# ---------------------------------------------------------------------------
@@ -219,6 +279,11 @@ func _ready() -> void:
if _body_container == null:
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
if _anim_player == null:
push_warning("StickmanRig: missing '%s' node in rig." % ANIMATION_PLAYER_PATH)
@@ -246,6 +311,7 @@ func _ready() -> void:
func _physics_process(delta: float) -> void:
_track_momentum(delta)
_update_rest_detection(delta)
func _track_momentum(delta: float) -> void:
@@ -255,6 +321,35 @@ func _track_momentum(delta: float) -> void:
_prev_global_pos = global_position
_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
# ---------------------------------------------------------------------------
@@ -316,16 +411,31 @@ func is_in_ragdoll() -> bool:
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()
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:
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
## central impulse, preserving the ragdoll's internal structure. No-op outside
## 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:
push_warning("StickmanRig: cannot enter ragdoll; missing rig nodes.")
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:
_skeleton.modification_stack.enabled = false
if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop()
_body_container.visible = false
_build_ragdoll()
state = RigState.RAGDOLL
state_changed.emit(int(state))
_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()
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):
_body_container.visible = true
if _skeleton != null and is_instance_valid(_skeleton):
if _skeleton.modification_stack != null:
_body_container.modulate.a = 1.0
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
_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_changed.emit(int(state))
@@ -507,6 +754,7 @@ func _build_ragdoll_body(entry: Dictionary) -> void:
body.add_child(shape)
body.position = visual.global_position
body.rotation = 0.0
body.set_meta("half_height", 0.0)
_add_ragdoll_visual_circle(body, float(entry["radius"]), RAGDOLL_HEAD_VISUAL_COLOR)
else:
var bone := _skeleton.get_node_or_null(NodePath(entry["node_path"])) as Bone2D
@@ -542,6 +790,9 @@ func _build_ragdoll_body(entry: Dictionary) -> void:
body.add_child(shape)
body.position = midpoint
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)
_ragdoll_root.add_child(body)
@@ -595,7 +846,7 @@ func _build_ragdoll_joint(entry: Dictionary) -> void:
_ragdoll_root.add_child(pin)
pin.node_a = pin.get_path_to(parent_body)
pin.node_b = pin.get_path_to(child_body)
pin.softness = 0.0
pin.softness = RAGDOLL_TARGET_SOFTNESS
_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:
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 = null
_ragdoll_bodies.clear()