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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+108
-32
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bxsrevw15hyc6
|
||||
@@ -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 +0,0 @@
|
||||
uid://c0l8if18mvdvb
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user