From 0e971d99b1d3edc9afee8a34eaab5b6735326e49 Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Thu, 27 Aug 2026 12:01:54 -0400 Subject: [PATCH] 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. --- .opencode/agents/developer.md | 2 +- .opencode/agents/tester.md | 2 +- AGENTS.md | 99 +++++++--- README.md | 33 ++-- docs/tech_debt_and_optimizations.md | 19 +- master_rig.tscn | 140 ++++++++++--- scripts/create_animations.gd | 140 +++++++++++++ scripts/create_animations.gd.uid | 1 + scripts/create_walk.gd | 63 ------ scripts/create_walk.gd.uid | 1 - scripts/physics_test_harness.gd | 45 +++++ scripts/stickman_rig.gd | 292 ++++++++++++++++++++++++++-- 12 files changed, 678 insertions(+), 159 deletions(-) create mode 100644 scripts/create_animations.gd create mode 100644 scripts/create_animations.gd.uid delete mode 100644 scripts/create_walk.gd delete mode 100644 scripts/create_walk.gd.uid diff --git a/.opencode/agents/developer.md b/.opencode/agents/developer.md index ea4e368..9c8d48b 100644 --- a/.opencode/agents/developer.md +++ b/.opencode/agents/developer.md @@ -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 diff --git a/.opencode/agents/tester.md b/.opencode/agents/tester.md index 813bb09..4e324ea 100644 --- a/.opencode/agents/tester.md +++ b/.opencode/agents/tester.md @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 2bfc70f..3661ea4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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.0–2.0; - `collision_layer`/`collision_mask` = 1) and the `RAGDOLL_JOINTS` table (**9** `PinJoint2D`, - one per non-root body pinned at the child bone's origin, `softness` 0.0). Angular limits via - `_apply_ragdoll_joint_limits()`: `elbow_knee` folds +CW `-5°..+150°`, `elbow_knee_ccw` - `-150°..+5°`, `shoulder_hip` ±160°, default (neck) free. Cached momentum is applied to the - torso body. `apply_ragdoll_velocity_boost(velocity)` applies the same velocity delta - (mass-scaled `apply_central_impulse`) to every ragdoll body — used by the harness "Knock - Up" button. `_exit_ragdoll()` `queue_free()`s the container, re-shows `Body/*`, re-enables - the IK stack, `stop()`s the animation, and reverts `state` to `ANIMATED`. All ragdoll nodes - are spawned procedurally — `master_rig.tscn` is **not** modified. + 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.0–2.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`, diff --git a/README.md b/README.md index b5ac956..1f7a107 100644 --- a/README.md +++ b/README.md @@ -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.1–10 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.0–2.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 4–6 bugfix).** Standalone runtime adapter (`class_name StkRigAdapter`, `static func apply(stk_data, rig)`): fits an instantiated `master_rig.tscn` to a loaded `.stk` by re-fitting the 8 limb bones (`Skeleton2D/Torso/...` `Bone2D` lengths + lower-bone origins), recalibrating the IK targets (`IK_Targets/Left|Right_Hand`, `Left|Right_Leg`), and mounting the `.stk` shapes onto the `Body/*` visual nodes (**one node per shape**: closed → single `Polygon2D` fill, open → single `Line2D` width 2). Shape mounting recomputes each part's bounding box at mount time (file `pivot`/`length` are no longer trusted) and derives a mount transform in the rig's **hanging convention** (joint anchor at the local origin, far end along local `+Y`) via `_compute_mount_transform()`: the part's preview transform `E(P) = C + R(rot)·S·(P − C)` (rotation + scale about the bbox center — the editor's exact Whole-Stickman-preview transform) is composed **first**, then the anchor/alignment θ/bone-fit scale are computed on the **transformed geometry**; rotations near ±180° (`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint). Anchors (raw family rules): head/torso bottom-center `(cx, max_y)`, left horizontal limbs `(max_x, cy)`, right horizontal limbs `(min_x, cy)`, vertically drawn limbs top-center `(cx, min_y)`; alignment rotation θ maps the far end onto `+Y`; scaling is **anisotropic** — only the **auto-detected drawn long axis** (`width >= height`) scales to the bone length (`bone_length/extent`, guard `extent <= 0.0001` → `1.0`), cross-axis thickness stays 1:1. The `RemoteTransform2D` drivers keep `update_rotation = true`, so mounted shapes follow their bones under IK flexing. (Phase 9 Round 5) when a part dict carries `guide_offset`, the mounted geometry is translated by `t = (guide_offset + (A − C)).rotated(−c_node)`; (Phase 9 Round 6) when `guide_offset` is present, the joint anchor is whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is nearest the part's guide joint (`center − guide_offset`), replacing the per-side family choice + 180° flip heuristic for that case (fixing the lower-left-leg and lower-right-arm, which were mounted 180° off their bones) — old files without the key keep the family rules + flip heuristic as the fallback in the driver's bone frame (A = mount anchor incl. the 180° flip rule, C = raw bbox center, `c_node` = driver `RemoteTransform2D.global_rotation`), so the harness reproduces the editor's guide-relative placement 1:1; old files without the key keep the offset-0 behavior (head falls back to `HEAD_CHIN_DROP`). Each `Body/*` container's scale is reset to `(1,1)` / rotation `0` (position untouched). (Phase 9) also fits the head bone (`Head.position.y = -proportions.torso_length`) while mounting the head as **full geometry** — it clears the head's inline `@tool` circle script and mounts `.stk` head shapes as `Line2D`/`Polygon2D`, and zeroes the Head driver's local position so the chin sits on the neck joint; the head mounts upright (`θ = 0`, `s = 1`) but still applies the part scale via `E` (face ≈160 px). **Not used by the editor** — consumed by the runtime pipeline. | | `res://scripts/stickman_factory.gd` | **Phase 9.** Runtime entry point (`class_name StickmanFactory`, `extends RefCounted`); a static factory that turns a `.stk` file into a live, rigged `master_rig.tscn` instance. `load_stk(path)` reads + parses the file (`{}` + `push_warning` on failure); `spawn_from_data(stk_data)` instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, and returns the rig root **typed as `StickmanRig`** (the rig now carries the `StickmanRig` root script); `spawn(path)` chains them (`null` on empty data). **Not used by the editor.** | -| `res://scripts/stickman_rig.gd` | **Phase 9 Task 4.** `class_name StickmanRig`, `extends Node2D`; the runtime owner of facing direction, per-joint bone bend, `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.1–10 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`. diff --git a/docs/tech_debt_and_optimizations.md b/docs/tech_debt_and_optimizations.md index cfa2057..1c12843 100644 --- a/docs/tech_debt_and_optimizations.md +++ b/docs/tech_debt_and_optimizations.md @@ -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 self‑collision issues (limbs clipping through each other) under high stress. | Low | Open | Future enhancement: assign ragdoll limbs to layer 2, terrain to layer 1, and use masks to allow limb‑limb collision only where desired. | | 4 | **Performance (Ragdoll Pooling)** — Spawning 10 bodies + 9 joints procedurally is fine for a single rig. If the scene ever contains dozens of ragdolls, consider a pooling system to avoid allocation spikes. | Low | Open | Not needed now, but worth noting if scaling to large crowds. | | 5 | **Line2D ↔ Capsule Radius Match** — Limbs use `Line2D` width 16, ragdoll capsules radius 8. These align visually. | ✅ Resolved | Closed | Verified during implementation. No action needed. | -| 6 | **Recovery Animation Starting Pose** — The `stand_up` animation must work from any captured ragdoll pose. Currently uses a fixed start frame. | High | Open | Investigate blending the captured pose with the animation's first keyframe using an additive blend or a `SkeletonModification` that interpolates. | -| 7 | **Transition Visual Pop** — The crossfade between kinematic and ragdoll currently uses a simple `modulate.a` lerp. This may cause ghosting if the kinematic and ragdoll poses are misaligned. | Medium | Open | Ensure the kinematic skeleton is snapped to the ragdoll pose _before_ the fade begins (or vice versa) to avoid double‑exposure. | -| 8 | **Rest Timeout UI** — The director can adjust `rest_timeout` via inspector, but there is no in‑world UI in the physics harness yet. | Low | Open | Add a slider or spinbox to the harness UI for easier tuning. | -| 9 | **Animation Generation DRY** — `create_walk.gd` is a standalone script. It should be merged into a unified `create_animations.gd` that also generates `stand_up` and idle animations. | Medium | Open | Refactor to support parameterized generation (profile, duration, target pose). | -| 10 | **Rig Collision Proxy Re‑addition** — The proxy is re‑added on ragdoll exit, but may cause a brief visual pop if it appears while the kinematic rig is visible. | Low | Open | Consider delaying proxy re‑addition until after the recovery animation completes, or fading it in. | +| 6 | **Recovery Animation Starting Pose** — The `stand_up` animation must work from any captured ragdoll pose. Currently uses a fixed start frame. | High | ✅ Resolved | Phase 11: `_start_recovery()` captures the 10 bodies' rig‑local pose, snap‑solves the skeleton via the 6 IK targets, then `_play_stand_up()` tweens the markers **directly** from the captured values to `STAND_POSE` (`STAND_UP_DURATION`, sine ease‑in‑out) — the baked `stand_up` animation is **not** played (a fixed first keyframe can never match an arbitrary rest pose; the earlier bridge‑into‑the‑animation approach caused a visible jump and was removed). Revision: the snap now derives the hip (`pos − dir·half`) and wrist/ankle (`pos + dir·half`) from the capsule ends and subtracts the Torso bone's `bone_angle` for the marker rotation, so recovery starts from the ragdoll's exact final pose (e.g. sitting stays sitting). (2026‑08‑27) | +| 7 | **Transition Visual Pop** — The crossfade between kinematic and ragdoll currently uses a simple `modulate.a` lerp. This may cause ghosting if the kinematic and ragdoll poses are misaligned. | Medium | ✅ Resolved | Phase 11: the entry is now an **instant handoff** — the ragdoll is built from the **current solved bone positions** (`AnimationPlayer.stop(true)` keeps the pose), then `Body/*` is hidden and the IK stack disabled in the same call. The earlier opacity crossfade + pin‑softness ramp was **removed on director feedback** (it read as ghosting, since both poses are identical). Recovery snap‑solves the kinematic skeleton to the captured ragdoll pose before re‑showing `Body/*`, eliminating the pop on both directions. (2026‑08‑27) | +| 8 | **Rest Timeout UI** — The director can adjust `rest_timeout` via inspector, but there is no in‑world UI in the physics harness yet. | Low | ✅ Resolved | Phase 11: added a Rest `SpinBox` (0.1–10 s, step 0.1) to the harness UI that writes `_rig.rest_timeout` (runtime‑only), plus a "Recover Now" button → `_rig.request_recovery()`. (2026‑08‑27) | +| 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 one‑shot `stand_up` into the scene's `AnimationLibrary` (the baked `stand_up` is an authored reference only — runtime recovery tweens the IK targets directly). (2026‑08‑27) | +| 10 | **Rig Collision Proxy Re‑addition** — The proxy is re‑added on ragdoll exit, but may cause a brief visual pop if it appears while the kinematic rig is visible. | Low | Open | Phase 11 still re‑adds 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 stand‑up completes. Consider delaying re‑addition until after recovery finishes (`ANIMATED`). (2026‑08‑27) | --- ## Suggested Future Improvements (Beyond Current Scope) -| Improvement | Description | Priority | -| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------- | -| **Soft Transition Blending** | Add physical blending (joint stiffness ramp) to complement visual crossfade. | Medium | -| **Ragdoll Recovery Interruptibility** | Allow the director to force a mode switch mid‑recovery (e.g., if they want the character to ragdoll again immediately). | Low | +| 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 mid‑recovery (e.g., if they want the character to ragdoll again immediately). | ✅ Done | | **Multiple Rig Support** | Ensure all state variables are instance‑specific (already true) and that the harness can manage multiple rigs. | Future | | **Animation Blending (IK vs. Physics)** | Blend between the animation‑driven pose and the ragdoll pose during transition to prevent snapping. | Future | | **Save/Load for Ragdoll State** | Save the current ragdoll pose to `.stk` (e.g., for storyboarding a fall). | Future | @@ -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 re‑addition) remains open with updated scope. Later revision: stand‑up recovery switched from bridge‑into‑baked‑animation 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 half‑heights with Torso `bone_angle` compensation, fixing the entry pose‑pop 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. | --- diff --git a/master_rig.tscn b/master_rig.tscn index ddbd2d6..1d7d309 100644 --- a/master_rig.tscn +++ b/master_rig.tscn @@ -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 diff --git a/scripts/create_animations.gd b/scripts/create_animations.gd new file mode 100644 index 0000000..1ed70bb --- /dev/null +++ b/scripts/create_animations.gd @@ -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) diff --git a/scripts/create_animations.gd.uid b/scripts/create_animations.gd.uid new file mode 100644 index 0000000..fa12072 --- /dev/null +++ b/scripts/create_animations.gd.uid @@ -0,0 +1 @@ +uid://bxsrevw15hyc6 diff --git a/scripts/create_walk.gd b/scripts/create_walk.gd deleted file mode 100644 index db2443f..0000000 --- a/scripts/create_walk.gd +++ /dev/null @@ -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) diff --git a/scripts/create_walk.gd.uid b/scripts/create_walk.gd.uid deleted file mode 100644 index 7d1d024..0000000 --- a/scripts/create_walk.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c0l8if18mvdvb diff --git a/scripts/physics_test_harness.gd b/scripts/physics_test_harness.gd index 5224753..3b7cdf6 100644 --- a/scripts/physics_test_harness.gd +++ b/scripts/physics_test_harness.gd @@ -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) # --------------------------------------------------------------------------- diff --git a/scripts/stickman_rig.gd b/scripts/stickman_rig.gd index 30121ac..0ba889b 100644 --- a/scripts/stickman_rig.gd +++ b/scripts/stickman_rig.gd @@ -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(): - _enter_ragdoll() - elif not enabled and is_in_ragdoll(): - _exit_ragdoll() + if enabled: + match state: + RigState.RAGDOLL: + return + RigState.RECOVERING: + _cancel_recovery() + _enter_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: - _skeleton.modification_stack.enabled = true - if _anim_player != null and is_instance_valid(_anim_player): - _anim_player.stop() + _body_container.modulate.a = 1.0 + if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null: + _skeleton.modification_stack.enabled = true + + +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()