Implement rig animation controls in the test harness

- Added a new animation specification document for Phase 9 Task 5 detailing the requirements for rig animation controls.
- Introduced a new `StickmanRig` script to manage the facing direction and joint bending for the rig.
- Implemented UI elements in the test harness for selecting animations, controlling playback (play/pause/resume/stop), and toggling loop mode.
- Enhanced the `test_harness.gd` script to handle animation playback state and UI interactions.
- Updated documentation in `AGENTS.md`, `README.md`, and `RIGGING.md` to reflect the new animation features.
This commit is contained in:
2026-08-24 23:41:23 -04:00
parent 273090993e
commit 07bef66703
12 changed files with 1695 additions and 283 deletions
+82 -45
View File
@@ -213,14 +213,38 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
`anchor`/`v` generically. `anchor`/`v` generically.
Targets `master_rig.tscn` node paths; every node lookup is null-guarded (missing node → Targets `master_rig.tscn` node paths; every node lookup is null-guarded (missing node →
`push_warning` + skip, never crash). Consumed by a future runtime pipeline. `push_warning` + skip, never crash). Consumed by a future runtime pipeline.
- `scripts/stickman_rig.gd` — `class_name StickmanRig`, `extends Node2D`; the **runtime owner of
facing direction, per-joint bone bend, and `Body/*` z-order** for `master_rig.tscn` (Phase 9
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`,
`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
`Body/*` draw-order tables, back-to-front). Exports: `facing_profile: FacingProfile` (default
`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)` /
`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`/
`Vector2.ZERO`). `_ready()` resolves `Skeleton2D`/`Body`/4 lower `Bone2D`s/4 TwoBoneIK
modifications (matched by `joint_two_bone2d_node` NodePath, **never stack index**), enables the
modification stack (`stack.enabled = true`), applies the current profile once, then sets
`_nodes_ready`; a `_nodes_ready` guard makes pre-`_ready` setters store-only (robust against
setter timing during `PackedScene.instantiate()`). Null-guards + `push_warning` prefixed
`"StickmanRig: "` throughout; never crashes.
- `scripts/stickman_factory.gd` — `class_name StickmanFactory`, `extends RefCounted`; a **static - `scripts/stickman_factory.gd` — `class_name StickmanFactory`, `extends RefCounted`; a **static
factory** and the **runtime entry point** (Phase 9, **not used by the editor**) that turns a factory** and the **runtime entry point** (Phase 9, **not used by the editor**) that turns a
`.stk` file into a live, rigged `master_rig.tscn` instance: `.stk` file into a live, rigged `master_rig.tscn` instance:
- `static func load_stk(path: String) -> Dictionary` — reads a `.stk` file (`FileAccess` + - `static func load_stk(path: String) -> Dictionary` — reads a `.stk` file (`FileAccess` +
`JSON.parse_string`); returns `{}` + `push_warning` on failure. `JSON.parse_string`); returns `{}` + `push_warning` on failure.
- `static func spawn_from_data(stk_data: Dictionary) -> Node2D` — instantiates - `static func spawn_from_data(stk_data: Dictionary) -> StickmanRig` — instantiates
`res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, returns the rig root. `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, returns the rig root
- `static func spawn(path: String) -> Node2D` — `load_stk()` then `spawn_from_data()`; (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. returns `null` on empty data.
- `scripts/test_harness.gd` — **standalone staging scene** (Phase 9, **not wired into the editor**; - `scripts/test_harness.gd` — **standalone staging scene** (Phase 9, **not wired into the editor**;
run via **F6** on `res://scenes/test_harness.tscn`) for debugging bone scales, vector drawing run via **F6** on `res://scenes/test_harness.tscn`) for debugging bone scales, vector drawing
@@ -241,8 +265,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
line to the aim point; colored markers at line to the aim point; colored markers at
`IK_Targets/{Left_Hand,Right_Hand,Left_Leg,Right_Leg}` when "Show IK Handles" is on. Interactive `IK_Targets/{Left_Hand,Right_Hand,Left_Leg,Right_Leg}` when "Show IK Handles" is on. Interactive
IK: click-drag the `Marker2D` IK targets; the scene's `SkeletonModificationStack2D` TwoBoneIK IK: click-drag the `Marker2D` IK targets; the scene's `SkeletonModificationStack2D` TwoBoneIK
flexes limbs live. The harness enables the modification stack (`enabled = true`) after each flexes limbs live (the rig self-enables its modification stack in `_ready()`).
spawn.
- **Phase 9 Round 7 draggable Torso & Head handles:** `IK_HANDLE_PATHS` now has **6 entries** - **Phase 9 Round 7 draggable Torso & Head handles:** `IK_HANDLE_PATHS` now has **6 entries**
— the 4 limb targets plus `"Head"` (`IK_Targets/Head`, the `SkeletonModification2DLookAt` aim — the 4 limb targets plus `"Head"` (`IK_Targets/Head`, the `SkeletonModification2DLookAt` aim
point) and `"Torso"` (`IK_Targets/Torso`, whose child `RemoteTransform2D` moves the hip bone). point) and `"Torso"` (`IK_Targets/Torso`, whose child `RemoteTransform2D` moves the hip bone).
@@ -258,46 +281,41 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
for the LookAt test. for the LookAt test.
- **Phase 9 Task 1 skeleton IK bone switches:** adds a "Facing" `MenuButton` (leftmost control - **Phase 9 Task 1 skeleton IK bone switches:** adds a "Facing" `MenuButton` (leftmost control
in the top-bar `HBox`) and per-joint bend-direction toggles for the rig's TwoBoneIK "Flip in the top-bar `HBox`) and per-joint bend-direction toggles for the rig's TwoBoneIK "Flip
Bend Direction" flags. `enum FacingProfile { LEFT, RIGHT, FORWARD }` (values used directly as Bend Direction" flags. The facing profile, the per-joint bend flags, and the `Body/*` z-order
menu item ids); `BEND_JOINTS: Array[String] = ["LeftArm","RightArm","LeftLeg","RightLeg"]` tables now **live in the `StickmanRig` script** (Phase 9 Task 4) — the harness drives the rig
with `BEND_JOINT_BONE_PATHS` (each joint → its lower `Bone2D` NodePath relative to via `_rig_script: StickmanRig` (typed root from `StickmanFactory.spawn()`; signals connected
`Skeleton2D`, e.g. `Torso/LeftUpperArm/LeftLowerArm`); `PROFILE_FLAGS` maps each profile to a **before** `add_child`) and keeps `_facing_profile` (default `FORWARD`) only as a **UI mirror**
per-joint `flip_bend_direction` set (LEFT: arms off / legs on; RIGHT: arms on / legs off; for the `[√] ` menu prefix + respawn re-application; the rig's exported `facing_profile` is the
FORWARD: LeftArm off, RightArm on, LeftLeg on, RightLeg off — matching `master_rig.tscn`'s authority. Menu item ids are `StickmanRig.FacingProfile.LEFT/RIGHT/FORWARD` (values used
authored defaults). State `_facing_profile` (default `FORWARD`, harness-level, persists across directly as menu item ids). State `_context_joint`, `_facing_button`/`_facing_menu`,
spawns), `_context_joint`, `_bend_joint_bones`, `_bend_modifications`, `_facing_button`/ `_context_menu`. The Facing popup (Left/Right/Forward) uses dynamic text-only labels with the
`_facing_menu`, `_context_menu`. The Facing popup (Left/Right/Forward) uses dynamic text-only current profile prefixed `[√] `, refreshed on `about_to_popup` and after selection (mirroring
labels with the current profile prefixed `[√] `, refreshed on `about_to_popup` and after the editor's snap-menu pattern); selection calls `_rig_script.set_facing_profile(id)`. Hit-test
selection (mirroring the editor's snap-menu pattern). Runtime resolution: for the right-click toggle iterates `_rig_script.get_bend_joints()` with positions from
`_resolve_bend_joints()` (called from `_resolve_rig_nodes()`) resolves the 4 lower-limb `_rig_script.get_bend_joint_global_position(joint)`. Per-joint toggle: right-click inside the
`Bone2D`s via `get_node_or_null` and matches each `SkeletonModification2DTwoBoneIK` in the viewport on an elbow/knee (the upper↔lower limb connector, within `JOINT_HIT_RADIUS_PX := 14.0`
modification stack by its `joint_two_bone2d_node` NodePath (no hardcoded stack index); screen px converted to world by `_camera.zoom.x`, nearest joint wins) pops a one-item context
`push_warning` on missing nodes/mods. Per-joint toggle: right-click inside the viewport on an menu labeled **"Normal Bend"** (when the rig's `get_joint_bend_flipped(joint)` is true) or
elbow/knee (the upper↔lower limb connector, within `JOINT_HIT_RADIUS_PX := 14.0` screen px **"Invert Bend"** (when false); selecting calls
converted to world by `_camera.zoom.x`, nearest joint wins) pops a one-item context menu `_rig_script.set_joint_bend_flipped(_context_joint, not _rig_script.get_joint_bend_flipped(_context_joint))`.
labeled **"Normal Bend"** (when `flip_bend_direction` is currently true) or **"Invert Bend"** Only the 4 elbows/knees are right-click targets — shoulders/hips/wrists/ankles/head/torso are
(when false); selecting toggles that joint's `flip_bend_direction` on the live TwoBoneIK not. Lifecycle: `_free_current_rig()` clears `_rig_script` (and `_context_joint`);
modification. Only the 4 elbows/knees are right-click targets — shoulders/hips/wrists/ankles/ `_load_and_spawn()` captures the remembered profile before `add_child` (so the rig's `_ready()`
head/torso are not. Lifecycle: `_free_current_rig()` clears `_bend_joint_bones`/ `facing_profile_changed(FORWARD)` doesn't clobber the mirror) then re-applies
`_bend_modifications`/`_context_joint`; `_load_and_spawn()` re-applies `_rig_script.set_facing_profile(remembered_profile)` after `_resolve_rig_nodes()`. The rig
`_apply_facing_profile(_facing_profile)` right after `_ensure_modification_stack_enabled()` so enables its own modification stack in `_ready()`. No persistence to disk.
every fresh spawn matches the current profile. No persistence to disk. - **Phase 9 Task 2 body-part z-order:** the rig's `_apply_profile()` reorders the rig's `Body/*`
- **Phase 9 Task 2 body-part z-order:** `_apply_facing_profile()` now also sets the visual part nodes (tree order = draw order) from the Facing profile; `Z_ORDER_BY_PROFILE` (now
`_facing_profile` state itself (previously only the menu handler did) and calls owned by `StickmanRig`) maps each `FacingProfile` to the `Body/*` part node names in
`_apply_body_z_order()`, so bend flags + draw order stay in sync from one entry point. **back-to-front draw order** (Godot 4 `Node2D` draws siblings in tree order; all parts keep
`const BODY_CONTAINER_PATH := "Body"` and `const Z_ORDER_BY_PROFILE: Dictionary` map each `z_index = 0`). FORWARD: torso → left/right upper legs → left/right lower legs → left/right
`FacingProfile` to the rig `Body/*` visual part node names in **back-to-front draw order** upper arms → left/right lower arms → head (all limbs in front of the torso); LEFT: left arm
(Godot 4 `Node2D` draws siblings in tree order; all parts keep `z_index = 0`). FORWARD: pair then left leg pair **behind** the torso, right leg pair then right arm pair in front;
torso → left/right upper legs → left/right lower legs → left/right upper arms → left/right RIGHT: mirrored. In every profile upper limbs stay behind lower limbs; far-side
lower arms → head (all limbs in front of the torso); LEFT: left arm pair then left leg (behind-torso) arms draw behind the legs while near-side arms draw in front of the legs; the
pair **behind** the torso, right leg pair then right arm pair in front; RIGHT: mirrored. **head is always frontmost**. The rig's `_apply_body_z_order()` walks the profile array
In every profile upper limbs stay behind lower limbs; far-side (behind-torso) arms draw back-to-front and `move_child(part, count - 1)`s each existing child (missing parts skipped),
behind the legs while near-side arms draw in front of the legs; the **head is always which yields the profile order; unknown extra children stay at the back. Safe with the adapter:
frontmost**. State `_body_container: Node2D` (resolved in
`_resolve_rig_nodes()` via `get_node_or_null`, null-guarded with `push_warning`, cleared
in `_free_current_rig()`). `_apply_body_z_order()` walks the profile array back-to-front
and `move_child(part, count - 1)`s each existing child (missing parts skipped), which
yields the profile order; unknown extra children stay at the back. Safe with the adapter:
shapes are children of the part nodes, so moving a part moves its whole shape group. No shapes are children of the part nodes, so moving a part moves its whole shape group. No
persistence to disk. persistence to disk.
- **Phase 9 Task 3 coordinates display:** adds a "Show Coords" `CheckBox` in the top-bar - **Phase 9 Task 3 coordinates display:** adds a "Show Coords" `CheckBox` in the top-bar
@@ -328,6 +346,25 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
frames. Values are frames. Values are
world-space (`global_position`/`global_rotation`), rotation in degrees via world-space (`global_position`/`global_rotation`), rotation in degrees via
`_fmt_deg(rad)` (1 decimal, `°`), `_fmt_vec2(v)` for positions. No persistence to disk. `_fmt_deg(rad)` (1 decimal, `°`), `_fmt_vec2(v)` for positions. No persistence to disk.
- **Phase 9 Task 5 rig animation:** adds top-bar controls immediately after the "Facing" menu
— an `_anim_dropdown` `OptionButton` populated per spawn from
`AnimationPlayer.get_animation_list()` (preferring `walk_right` via `DEFAULT_ANIMATION`), a
`_play_button` whose label swaps "Play"/"Pause"/"Resume" by `_playback_state`, a `_stop_button`
(Stop), and a `_loop_check` `CheckBox` default ON (harness-level, persists across respawns like
`_show_coords`). The harness resolves the rig's `AnimationPlayer` directly by node path via the
`ANIMATION_PLAYER_PATH` const (`_resolve_anim_player()`, called at the end of
`_resolve_rig_nodes()`) and drives it directly; the `AnimationTree` node remains an untouched
unconfigured placeholder (out of scope, D1). Loop is implemented by writing
`Animation.loop_mode` (`LOOP_LINEAR`/`LOOP_NONE`) on the selected animation before each play
(`_apply_loop_mode()`); playback state is tracked by the enum
`PlaybackState {STOPPED, PLAYING, PAUSED}` via the button handlers + the `animation_finished`
signal (guarded by `_loop`) — no polling in `_process`. Changing the dropdown selection stops
playback; `_free_current_rig()` clears `_anim_player`, the dropdown, `_selected_animation`,
and state. Playing `walk_right` also sets the rig's `facing_profile` via its animation track →
export setter → the existing `_on_facing_profile_changed` handling (menu `[√] ` + redraw). No
persistence to disk. The "Facing" menu and all animation controls are **hidden until an .stk is
loaded** (`_set_rig_controls_visible(false)` at the end of `_build_ui()` and in
`_free_current_rig()`; shown on successful spawn in `_load_and_spawn()`).
- Scenes: - Scenes:
- `scenes/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used - `scenes/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used
for typed `@onready` access: `%MenuBar`, `%StickmanNameEdit`, `%LeftColumn`, for typed `@onready` access: `%MenuBar`, `%StickmanNameEdit`, `%LeftColumn`,
+8 -6
View File
@@ -251,7 +251,7 @@ Phase 9 adds the **runtime pipeline** that turns a saved `.stk` file into a live
| `spawn_from_data` | `static func spawn_from_data(stk_data: Dictionary) -> Node2D` | Instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, returns the rig root. | | `spawn_from_data` | `static func spawn_from_data(stk_data: Dictionary) -> Node2D` | Instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, returns the rig root. |
| `spawn` | `static func spawn(path: String) -> Node2D` | Chains `load_stk()``spawn_from_data()`. Returns `null` on empty data. | | `spawn` | `static func spawn(path: String) -> Node2D` | Chains `load_stk()``spawn_from_data()`. Returns `null` on empty data. |
The factory is the intended runtime API: `StickmanFactory.spawn("res://stickmen/basic.stk")` yields a rigged `Node2D` ready to add to the scene tree. `StkRigAdapter` (Phase 8, extended by Phase 9) now also fits the head bone (`Head.position.y = -proportions.torso_length`) and mounts the head as **full geometry** like every other part (clearing its inline `@tool` circle script); the mounted head is dropped `HEAD_CHIN_DROP` (28 px) below the neck so its chin aligns with the pose-guide circle's bottom and overlaps the torso (Phase 9 Round 4). The factory is the intended runtime API: `StickmanFactory.spawn("res://stickmen/basic.stk")` yields a rigged `StickmanRig` (the root of `master_rig.tscn`) ready to add to the scene tree. `StkRigAdapter` (Phase 8, extended by Phase 9) now also fits the head bone (`Head.position.y = -proportions.torso_length`) and mounts the head as **full geometry** like every other part (clearing its inline `@tool` circle script); the mounted head is dropped `HEAD_CHIN_DROP` (28 px) below the neck so its chin aligns with the pose-guide circle's bottom and overlaps the torso (Phase 9 Round 4).
**Test Harness (`res://scenes/test_harness.tscn`)** — run via **F6** on the scene (NOT via the main editor scene). Controls: **Test Harness (`res://scenes/test_harness.tscn`)** — run via **F6** on the scene (NOT via the main editor scene). Controls:
@@ -259,10 +259,11 @@ The factory is the intended runtime API: `StickmanFactory.spawn("res://stickmen/
- **Show Bones / Show IK Handles** — checkboxes toggling the debug overlay. - **Show Bones / Show IK Handles** — checkboxes toggling the debug overlay.
- **Loaded filename** — status label showing the currently loaded file. - **Loaded filename** — status label showing the currently loaded file.
- **Pan / zoom** — middle-mouse drag to pan, mouse-wheel to zoom the `Camera2D`; the camera recenters on each spawn. - **Pan / zoom** — middle-mouse drag to pan, mouse-wheel to zoom the `Camera2D`; the camera recenters on each spawn.
- **IK drag** — click and drag any of the **6** `Marker2D` IK handles (`IK_Targets/Left_Hand`, `Right_Hand`, `Left_Leg`, `Right_Leg` flex the limb via TwoBoneIK; `IK_Targets/Torso` translates the whole rig rigidly via its `RemoteTransform2D`; `IK_Targets/Head` drives the head's `SkeletonModification2DLookAt` aim rotation) (Phase 9 Round 7). The harness enables the modification stack after each spawn. - **IK drag** — click and drag any of the **6** `Marker2D` IK handles (`IK_Targets/Left_Hand`, `Right_Hand`, `Left_Leg`, `Right_Leg` flex the limb via TwoBoneIK; `IK_Targets/Torso` translates the whole rig rigidly via its `RemoteTransform2D`; `IK_Targets/Head` drives the head's `SkeletonModification2DLookAt` aim rotation) (Phase 9 Round 7). The rig self-enables its modification stack in `_ready()`.
- **Facing** — a `MenuButton` (leftmost in the top bar) applying a preset to the rig's TwoBoneIK **Flip Bend Direction** flags: **Left** (arms normal, legs inverted), **Right** (arms inverted, legs normal), **Forward** (left arm / right leg inverted — the authored defaults). The current profile is prefixed `[√] ` on the menu labels and persists across rig loads (Phase 9 Task 1). - **Facing** — a `MenuButton` (leftmost in the top bar) applying a preset to the rig's **`StickmanRig`** exported `facing_profile`, which sets the rig's TwoBoneIK **Flip Bend Direction** flags: **Left** (arms normal, legs inverted), **Right** (arms inverted, legs normal), **Forward** (RightArm / LeftLeg inverted — the rig's default). The current profile is prefixed `[√] ` on the menu labels and persists across rig loads (Phase 9 Task 1, Task 4).
- **Bend-direction toggle** — right-click an elbow or knee joint in the viewport to pop a context menu that inverts that joint's TwoBoneIK bend direction ("Invert Bend" → "Normal Bend" and back). Only the 4 elbows/knees are targets (Phase 9 Task 1). - **Bend-direction toggle** — right-click an elbow or knee joint in the viewport to pop a context menu that inverts that joint's TwoBoneIK bend direction ("Invert Bend" → "Normal Bend" and back). Only the 4 elbows/knees are targets (Phase 9 Task 1).
- **Body-part z-order** — the Facing profile also reorders the rig's `Body/*` visual part nodes (tree order = draw order): **Forward** draws all limbs in front of the torso, **Left** tucks the left arm/leg pairs behind the torso (right pairs in front), **Right** tucks the right pairs behind; upper limbs sit behind lower limbs, far-side (behind-torso) arms draw behind the legs while near-side arms draw in front of them, and the **head is always frontmost** (Phase 9 Task 2). - **Body-part z-order** — the Facing profile also reorders the rig's `Body/*` visual part nodes (tree order = draw order), now owned by the rig's `StickmanRig._apply_body_z_order()`: **Forward** draws all limbs in front of the torso, **Left** tucks the left arm/leg pairs behind the torso (right pairs in front), **Right** tucks the right pairs behind; upper limbs sit behind lower limbs, far-side (behind-torso) arms draw behind the legs while near-side arms draw in front of them, and the **head is always frontmost** (Phase 9 Task 2, Task 4).
- **Animation** — a dropdown (populated per spawn from the rig's `AnimationPlayer.get_animation_list()`, `walk_right` pre-selected) plus **Play/Pause/Resume** (label swaps with playback state), **Stop**, and **Loop** (default ON, persists across loads) controls. The harness drives the rig's `AnimationPlayer` directly by node path (`ANIMATION_PLAYER_PATH`); loop writes `Animation.loop_mode` before play, and playback state is tracked via the button handlers + the `animation_finished` signal (no polling). The `AnimationTree` node remains an untouched placeholder. Playing `walk_right` also flips the rig's facing profile to Right via the animation's `facing_profile` track (Phase 9 Task 5).
Debug overlay (a world-space `Node2D` `_draw()`): true bone **segments** drawn between each `Bone2D` origin and its Bone2D children (color-coded left cyan / right orange / central white, with a joint dot per bone), with limb leaf bones drawn out to their IK targets so the forearm/shin segments and wrist/ankle joints are visible (Phase 9 Round 2) and the **Head** leaf drawn along the bone's own direction (~90 px, since its IK target is a LookAt aim point, not a joint) (Phase 9 Round 3), when **Show Bones** is on; colored markers at the six IK targets — hands green, feet blue, head **yellow**, torso **magenta** (Phase 9 Round 7) — plus a semi-transparent yellow aim line from the Head bone to the head marker, when **Show IK Handles** is on. Each load frees the previous rig and spawns a fresh one. Debug overlay (a world-space `Node2D` `_draw()`): true bone **segments** drawn between each `Bone2D` origin and its Bone2D children (color-coded left cyan / right orange / central white, with a joint dot per bone), with limb leaf bones drawn out to their IK targets so the forearm/shin segments and wrist/ankle joints are visible (Phase 9 Round 2) and the **Head** leaf drawn along the bone's own direction (~90 px, since its IK target is a LookAt aim point, not a joint) (Phase 9 Round 3), when **Show Bones** is on; colored markers at the six IK targets — hands green, feet blue, head **yellow**, torso **magenta** (Phase 9 Round 7) — plus a semi-transparent yellow aim line from the Head bone to the head marker, when **Show IK Handles** is on. Each load frees the previous rig and spawns a fresh one.
@@ -403,8 +404,9 @@ Behavior:
| `res://scenes/body_part_panel.tscn` | Reusable single body-part editor panel (title, drawing area, context menu, `ColorPickerPopup`); expands vertically in its column. | | `res://scenes/body_part_panel.tscn` | Reusable single body-part editor panel (title, drawing area, context menu, `ColorPickerPopup`); expands vertically in its column. |
| `res://scripts/stickman_editor.gd` | Editor controller — File/Edit/View menu actions, save/load/clear, JSON v1.5 serialization with multi-shape/rotation/scale, `part_order`, Phase 8 `proportions`/`pivot`/`length`, and Phase 9 Round 5 per-part `guide_offset` export, `settings.json` load/save, editor-wide shape clipboard (Copy/Paste across panels), broadcast of grid/snap settings to panels, Reset Views, populates panels, coordinates selection across panels. | | `res://scripts/stickman_editor.gd` | Editor controller — File/Edit/View menu actions, save/load/clear, JSON v1.5 serialization with multi-shape/rotation/scale, `part_order`, Phase 8 `proportions`/`pivot`/`length`, and Phase 9 Round 5 per-part `guide_offset` export, `settings.json` load/save, editor-wide shape clipboard (Copy/Paste across panels), broadcast of grid/snap settings to panels, Reset Views, populates panels, coordinates selection across panels. |
| `res://scripts/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9 (Rounds 46 bugfix).** Standalone runtime adapter (`class_name StkRigAdapter`, `static func apply(stk_data, rig)`): fits an instantiated `master_rig.tscn` to a loaded `.stk` by re-fitting the 8 limb bones (`Skeleton2D/Torso/...` `Bone2D` lengths + lower-bone origins), recalibrating the IK targets (`IK_Targets/Left|Right_Hand`, `Left|Right_Leg`), and mounting the `.stk` shapes onto the `Body/*` visual nodes (**one node per shape**: closed → single `Polygon2D` fill, open → single `Line2D` width 2). Shape mounting recomputes each part's bounding box at mount time (file `pivot`/`length` are no longer trusted) and derives a mount transform in the rig's **hanging convention** (joint anchor at the local origin, far end along local `+Y`) via `_compute_mount_transform()`: the part's preview transform `E(P) = C + R(rot)·S·(P C)` (rotation + scale about the bbox center — the editor's exact Whole-Stickman-preview transform) is composed **first**, then the anchor/alignment θ/bone-fit scale are computed on the **transformed geometry**; rotations near ±180° (`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint). Anchors (raw family rules): head/torso bottom-center `(cx, max_y)`, left horizontal limbs `(max_x, cy)`, right horizontal limbs `(min_x, cy)`, vertically drawn limbs top-center `(cx, min_y)`; alignment rotation θ maps the far end onto `+Y`; scaling is **anisotropic** — only the **auto-detected drawn long axis** (`width >= height`) scales to the bone length (`bone_length/extent`, guard `extent <= 0.0001``1.0`), cross-axis thickness stays 1:1. The `RemoteTransform2D` drivers keep `update_rotation = true`, so mounted shapes follow their bones under IK flexing. (Phase 9 Round 5) when a part dict carries `guide_offset`, the mounted geometry is translated by `t = (guide_offset + (A C)).rotated(c_node)`; (Phase 9 Round 6) when `guide_offset` is present, the joint anchor is whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is nearest the part's guide joint (`center guide_offset`), replacing the per-side family choice + 180° flip heuristic for that case (fixing the lower-left-leg and lower-right-arm, which were mounted 180° off their bones) — old files without the key keep the family rules + flip heuristic as the fallback in the driver's bone frame (A = mount anchor incl. the 180° flip rule, C = raw bbox center, `c_node` = driver `RemoteTransform2D.global_rotation`), so the harness reproduces the editor's guide-relative placement 1:1; old files without the key keep the offset-0 behavior (head falls back to `HEAD_CHIN_DROP`). Each `Body/*` container's scale is reset to `(1,1)` / rotation `0` (position untouched). (Phase 9) also fits the head bone (`Head.position.y = -proportions.torso_length`) while mounting the head as **full geometry** — it clears the head's inline `@tool` circle script and mounts `.stk` head shapes as `Line2D`/`Polygon2D`, and zeroes the Head driver's local position so the chin sits on the neck joint; the head mounts upright (`θ = 0`, `s = 1`) but still applies the part scale via `E` (face ≈160 px). **Not used by the editor** — consumed by the runtime pipeline. | | `res://scripts/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9 (Rounds 46 bugfix).** Standalone runtime adapter (`class_name StkRigAdapter`, `static func apply(stk_data, rig)`): fits an instantiated `master_rig.tscn` to a loaded `.stk` by re-fitting the 8 limb bones (`Skeleton2D/Torso/...` `Bone2D` lengths + lower-bone origins), recalibrating the IK targets (`IK_Targets/Left|Right_Hand`, `Left|Right_Leg`), and mounting the `.stk` shapes onto the `Body/*` visual nodes (**one node per shape**: closed → single `Polygon2D` fill, open → single `Line2D` width 2). Shape mounting recomputes each part's bounding box at mount time (file `pivot`/`length` are no longer trusted) and derives a mount transform in the rig's **hanging convention** (joint anchor at the local origin, far end along local `+Y`) via `_compute_mount_transform()`: the part's preview transform `E(P) = C + R(rot)·S·(P C)` (rotation + scale about the bbox center — the editor's exact Whole-Stickman-preview transform) is composed **first**, then the anchor/alignment θ/bone-fit scale are computed on the **transformed geometry**; rotations near ±180° (`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint). Anchors (raw family rules): head/torso bottom-center `(cx, max_y)`, left horizontal limbs `(max_x, cy)`, right horizontal limbs `(min_x, cy)`, vertically drawn limbs top-center `(cx, min_y)`; alignment rotation θ maps the far end onto `+Y`; scaling is **anisotropic** — only the **auto-detected drawn long axis** (`width >= height`) scales to the bone length (`bone_length/extent`, guard `extent <= 0.0001``1.0`), cross-axis thickness stays 1:1. The `RemoteTransform2D` drivers keep `update_rotation = true`, so mounted shapes follow their bones under IK flexing. (Phase 9 Round 5) when a part dict carries `guide_offset`, the mounted geometry is translated by `t = (guide_offset + (A C)).rotated(c_node)`; (Phase 9 Round 6) when `guide_offset` is present, the joint anchor is whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is nearest the part's guide joint (`center guide_offset`), replacing the per-side family choice + 180° flip heuristic for that case (fixing the lower-left-leg and lower-right-arm, which were mounted 180° off their bones) — old files without the key keep the family rules + flip heuristic as the fallback in the driver's bone frame (A = mount anchor incl. the 180° flip rule, C = raw bbox center, `c_node` = driver `RemoteTransform2D.global_rotation`), so the harness reproduces the editor's guide-relative placement 1:1; old files without the key keep the offset-0 behavior (head falls back to `HEAD_CHIN_DROP`). Each `Body/*` container's scale is reset to `(1,1)` / rotation `0` (position untouched). (Phase 9) also fits the head bone (`Head.position.y = -proportions.torso_length`) while mounting the head as **full geometry** — it clears the head's inline `@tool` circle script and mounts `.stk` head shapes as `Line2D`/`Polygon2D`, and zeroes the Head driver's local position so the chin sits on the neck joint; the head mounts upright (`θ = 0`, `s = 1`) but still applies the part scale via `E` (face ≈160 px). **Not used by the editor** — consumed by the runtime pipeline. |
| `res://scripts/stickman_factory.gd` | **Phase 9.** Runtime entry point (`class_name StickmanFactory`, `extends RefCounted`); a static factory that turns a `.stk` file into a live, rigged `master_rig.tscn` instance. `load_stk(path)` reads + parses the file (`{}` + `push_warning` on failure); `spawn_from_data(stk_data)` instantiates `res://master_rig.tscn` and calls `StkRigAdapter.apply(stk_data, rig)`; `spawn(path)` chains them (`null` on empty data). **Not used by the editor.** | | `res://scripts/stickman_factory.gd` | **Phase 9.** Runtime entry point (`class_name StickmanFactory`, `extends RefCounted`); a static factory that turns a `.stk` file into a live, rigged `master_rig.tscn` instance. `load_stk(path)` reads + parses the file (`{}` + `push_warning` on failure); `spawn_from_data(stk_data)` instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, and returns the rig root **typed as `StickmanRig`** (the rig now carries the `StickmanRig` root script); `spawn(path)` chains them (`null` on empty data). **Not used by the editor.** |
| `res://scripts/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 (enabled after each spawn), the Torso target translates the whole rig via its `RemoteTransform2D`, and the Head target drives the head's LookAt aim rotation (Phase 9 Round 7). | | `res://scripts/stickman_rig.gd` | **Phase 9 Task 4.** `class_name StickmanRig`, `extends Node2D`; the runtime owner of facing direction, per-joint bone bend, and `Body/*` z-order, attached to the `master_rig.tscn` root `Master`. Exports a `facing_profile` preset (`FacingProfile` LEFT/RIGHT/FORWARD, default FORWARD) and four `@export_enum("Normal","Inverted")` per-joint bend vars (`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend`). Non-`@tool`: resolves `Skeleton2D`/`Body`/bend joints at runtime, enables its own modification stack, and applies the profile (flag writes + `Body/*` reorder) in `_ready()` and setters. Signals `facing_profile_changed` / `bend_flag_changed`; public API `set_facing_profile`/`get_facing_profile`, `set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, `get_bend_joint_global_position()`. Null-guarded (`push_warning` + skip). **Not used by the editor.** |
| `res://scripts/test_harness.gd` | **Phase 9.** Standalone staging scene (run via **F6** on `res://scenes/test_harness.tscn`, not wired into the editor) for debugging bone scales, vector-drawing offsets, and IK limits in isolation. Top UI bar: "Open .stk…" / quick-select buttons (`stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`), "Show Bones" / "Show IK Handles" toggles, loaded-filename label. `SubViewport` world + enabled `Camera2D` (middle-mouse pan, wheel zoom, recenter on spawn); each load frees the previous rig and spawns a fresh one via `StickmanFactory.spawn()`. A world-space debug overlay draws true bone segments (joint dots + parent→child lines, with limb leaf bones drawn out to their IK targets so wrist/ankle joints are visible; the **Head** leaf is the exception — its target is a LookAt aim point, not a joint, so it draws a ~90 px segment along the bone's own direction instead) and colored IK-target markers (hands green, feet blue, head yellow, torso magenta) plus a semi-transparent yellow head-aim line; the **6** `Marker2D` IK targets are click-draggable — the 4 limb targets flex limbs live via `SkeletonModificationStack2D` TwoBoneIK (the rig self-enables its stack), the Torso target translates the whole rig via its `RemoteTransform2D`, and the Head target drives the head's LookAt aim rotation (Phase 9 Round 7). |
| `res://scenes/test_harness.tscn` | **Phase 9.** Standalone staging scene backing `scripts/test_harness.gd` (run via **F6**; not wired into the editor). | | `res://scenes/test_harness.tscn` | **Phase 9.** Standalone staging scene backing `scripts/test_harness.gd` (run via **F6**; not wired into the editor). |
| `res://scripts/body_part_panel.gd` | Multi-shape creation, vertex editing, shape dragging, per-panel zoom & pan, grid drawing & snap-to-grid, ColorPicker, shape/vertex delete, Z-ordering (Send Back / Bring Forward), shape Copy/Paste, shape Mirror X/Y, drawing (fill + outline for closed shapes). | | `res://scripts/body_part_panel.gd` | Multi-shape creation, vertex editing, shape dragging, per-panel zoom & pan, grid drawing & snap-to-grid, ColorPicker, shape/vertex delete, Z-ordering (Send Back / Bring Forward), shape Copy/Paste, shape Mirror X/Y, drawing (fill + outline for closed shapes). |
| `res://scripts/whole_stickman_preview.gd` | Assembly preview, drag-to-reposition, part selection with white bounding box, rotation gizmo (circle below box) with Ctrl 15° snap, scale gizmo (corner crosses) with Ctrl aspect lock, part Z-ordering (Send Back / Bring Forward) via `part_order`, part Mirror X/Y (scale negation), zoom & pan, grid drawing & snap-to-grid, pose silhouette guide (Phase 7), part hit-bounds, labels, and (Phase 9 Round 5) `get_guide_joint_preview()` — the preview-space position of a guide joint, used by the editor to export per-part `guide_offset`. | | `res://scripts/whole_stickman_preview.gd` | Assembly preview, drag-to-reposition, part selection with white bounding box, rotation gizmo (circle below box) with Ctrl 15° snap, scale gizmo (corner crosses) with Ctrl aspect lock, part Z-ordering (Send Back / Bring Forward) via `part_order`, part Mirror X/Y (scale negation), zoom & pan, grid drawing & snap-to-grid, pose silhouette guide (Phase 7), part hit-bounds, labels, and (Phase 9 Round 5) `get_guide_joint_preview()` — the preview-space position of a guide joint, used by the editor to export per-part `guide_offset`. |
+16
View File
@@ -98,5 +98,21 @@ The display should be easy to read to the right side of the screen. There should
## Task 4: Refactor facing direction / bone bend ## Task 4: Refactor facing direction / bone bend
**Implemented (Phase 9 Task 4)** — see `docs/phase9_task4_refactor_spec.md`; a new
`scripts/stickman_rig.gd` (`class_name StickmanRig`, attached to the `master_rig.tscn` root)
now owns the facing preset, the four per-joint TwoBoneIK bend flags, and the `Body/*` z-order
as exported controls; the test harness became a thin driver that reads/writes the rig script.
We need to have facing direction and bone bend to be a part of the master_rig.tscn. This rig will be used for instances of stickmen which will be able to face different directions / have different bone bending at a time. We need to have facing direction and bone bend to be a part of the master_rig.tscn. This rig will be used for instances of stickmen which will be able to face different directions / have different bone bending at a time.
So rather than keeping that data in the harness, we need it in the master rig. These should probably be exported controls too so that they can easily be accessed. So rather than keeping that data in the harness, we need it in the master rig. These should probably be exported controls too so that they can easily be accessed.
## Task 5: Rig animation
**Implemented (Phase 9 Task 5)** — see `docs/phase9_task5_animation_spec.md`; the harness adds a dropdown, play/pause/resume, stop, and loop controls that drive the rig's `AnimationPlayer` directly.
Both AnimationPlayer and AnimationTree has been added to the master_rig.tscn. Animations should be selectable and playable in the harness.
- Default animations are inside the AnimationPlayer: currently just 'walk_right'
- There should be a dropdown in the harness where the user can select an animation.
- There should also be a play/pause/stop button that will start the animation, pause/resume, or stop (animation will restart on play)
- there should be a button that toggles if the animation will loop or play just once.
+471
View File
@@ -0,0 +1,471 @@
# Phase 9 Task 4 — Refactor: Facing Direction & Bone Bend into the Rig
## Overview
RIGGING.md Task 4: facing direction and per-joint bone bend must become **part of
`master_rig.tscn`**, not the test harness. A rigged stickman instance should carry its own facing
profile and bend flags so different instances can face different directions / bend differently at
the same time. These must be **exported controls** on the rig so they are easy to access from the
inspector and from runtime code.
Today the harness owns all of this:
- `enum FacingProfile { LEFT, RIGHT, FORWARD }` (Task 1),
- `PROFILE_FLAGS` — per-profile `flip_bend_direction` sets for the 4 TwoBoneIK joints,
- `Z_ORDER_BY_PROFILE` — per-profile `Body/*` draw-order tables (Task 2),
- `BEND_JOINTS` / `BEND_JOINT_BONE_PATHS` — the 4 upper↔lower limb connectors,
- `_facing_profile`, `_bend_joint_bones`, `_bend_modifications`, `_body_container` state,
- `_apply_facing_profile()`, `_apply_body_z_order()`, `_resolve_bend_joints()`, and
`_ensure_modification_stack_enabled()`.
Task 4 moves that state + logic into a new script attached to the `master_rig.tscn` root, and the
harness becomes a thin driver that reads/writes the rig's exported properties.
## 1. New rig script — `scripts/stickman_rig.gd`
A new `class_name StickmanRig` script, `extends Node2D`, attached to the `Master` root node of
`master_rig.tscn` (the root currently has **no** script). It is the single owner of facing profile
+ per-joint bend flags + `Body/*` z-order.
### 1a. Class declaration & enums
```gdscript
class_name StickmanRig
extends Node2D
## Facing profiles. Values are used directly as facing-menu item ids in the
## harness (0/1/2), so they must stay stable.
enum FacingProfile { LEFT, RIGHT, FORWARD }
## Per-joint bend direction. INVERTED == flip_bend_direction = true.
enum BendDirection { NORMAL, INVERTED }
```
The enums live **in the rig script** (not a shared script). The harness references them as
`StickmanRig.FacingProfile.LEFT` / `StickmanRig.BendDirection.INVERTED` (idiomatic `class_name`
enum access — no autoload or singleton needed).
### 1b. Constants (moved verbatim from `test_harness.gd`)
```gdscript
const SKELETON_PATH := "Skeleton2D"
const BODY_CONTAINER_PATH := "Body"
## Bend joints (upper↔lower limb connectors) whose TwoBoneIK "Flip Bend
## Direction" flag is user-controllable.
const BEND_JOINTS: Array[String] = ["LeftArm", "RightArm", "LeftLeg", "RightLeg"]
## Lower-bone NodePath (relative to Skeleton2D) per bend joint. Used both to
## resolve the TwoBoneIK modification (via joint_two_bone2d_node) and to report
## each joint's world position for hit-testing.
const BEND_JOINT_BONE_PATHS: Dictionary = {
"LeftArm": "Torso/LeftUpperArm/LeftLowerArm",
"RightArm": "Torso/RightUpperArm/RightLowerArm",
"LeftLeg": "Torso/LeftUpperLeg/LeftLowerLeg",
"RightLeg": "Torso/RightUpperLeg/RightLowerLeg",
}
## flip_bend_direction value per facing profile, keyed by bend-joint name.
const PROFILE_FLAGS: Dictionary = {
FacingProfile.LEFT: { "LeftArm": false, "RightArm": false, "LeftLeg": true, "RightLeg": true },
FacingProfile.RIGHT: { "LeftArm": true, "RightArm": true, "LeftLeg": false, "RightLeg": false },
FacingProfile.FORWARD: { "LeftArm": false, "RightArm": true, "LeftLeg": true, "RightLeg": false },
}
## Body/* visual part node names in draw order (back-to-front) per profile.
## First entry backmost, last frontmost. Upper limbs behind lower limbs; on the
## far (behind-torso) side the arm pair draws behind the leg pair, on the near
## side the arm pair draws in front; head always frontmost.
const Z_ORDER_BY_PROFILE: Dictionary = {
FacingProfile.FORWARD: [
"Body",
"LeftUpperLeg", "RightUpperLeg",
"LeftLowerLeg", "RightLowerLeg",
"LeftUpperArm", "RightUpperArm",
"LeftLowerArm", "RightLowerArm",
"Head",
],
FacingProfile.LEFT: [
"LeftUpperArm", "LeftLowerArm",
"LeftUpperLeg", "LeftLowerLeg",
"Body",
"RightUpperLeg", "RightLowerLeg",
"RightUpperArm", "RightLowerArm",
"Head",
],
FacingProfile.RIGHT: [
"RightUpperArm", "RightLowerArm",
"RightUpperLeg", "RightLowerLeg",
"Body",
"LeftUpperLeg", "LeftLowerLeg",
"LeftUpperArm", "LeftLowerArm",
"Head",
],
}
```
### 1c. Exported properties (the "exported controls")
```gdscript
## Facing preset. Setting it overwrites the four per-joint bend values from
## PROFILE_FLAGS and reorders Body/* children. Default FORWARD.
@export var facing_profile: FacingProfile = FacingProfile.FORWARD:
set(value):
if facing_profile == value:
return
facing_profile = value
_apply_profile()
## Per-joint bend direction (the actual flip_bend_direction source of truth).
## Defaults match the FORWARD profile. These are individually overridable after
## a profile is applied (drifting away from the preset, exactly like the current
## harness right-click behavior).
@export_group("Bend Direction")
@export_enum("Normal", "Inverted") var left_arm_bend: int = BendDirection.NORMAL:
set(value):
_set_joint_bend_inverted("LeftArm", value == BendDirection.INVERTED)
@export_enum("Normal", "Inverted") var right_arm_bend: int = BendDirection.INVERTED:
set(value):
_set_joint_bend_inverted("RightArm", value == BendDirection.INVERTED)
@export_enum("Normal", "Inverted") var left_leg_bend: int = BendDirection.INVERTED:
set(value):
_set_joint_bend_inverted("LeftLeg", value == BendDirection.INVERTED)
@export_enum("Normal", "Inverted") var right_leg_bend: int = BendDirection.NORMAL:
set(value):
_set_joint_bend_inverted("RightLeg", value == BendDirection.INVERTED)
```
Semantics:
- `facing_profile` is a **preset**. Its setter writes the four `*_bend` vars (through their own
setters, so mod flags stay in sync) and then reorders `Body/*` children, then emits
`facing_profile_changed`. It early-outs if the value is unchanged (prevents redundant re-applies,
e.g. re-spawning with the default profile).
- The four `*_bend` vars are the **per-joint source of truth**. Each setter stores the value,
updates the resolved `SkeletonModification2DTwoBoneIK.flip_bend_direction`, and emits
`bend_flag_changed`. `BendDirection.INVERTED``flip_bend_direction = true`.
- All setters are **guarded by a `_nodes_ready` flag** (set at the end of `_ready()`): before the
rig has resolved its children (e.g. while `PackedScene.instantiate()` is still hydrating the
serialized exports), the setter only stores the value; `_ready()` then applies the full state
once. This makes the apply path order-independent and robust against setter timing during
instantiation.
### 1d. Signals
```gdscript
## Emitted when the facing preset changes (after flags + z-order are applied).
signal facing_profile_changed(profile: int)
## Emitted when a single joint's bend direction changes. `flipped` is the new
## flip_bend_direction value (true == inverted).
signal bend_flag_changed(joint: String, flipped: bool)
```
The harness primarily refreshes menu labels on `about_to_popup` (the established pattern used by
the editor's snap/guide menus), so the signals are not strictly required for label sync — but they
are emitted for programmatic consumers and match the repo's signal-driven data-flow convention.
### 1e. Public methods
```gdscript
## Facing preset accessors.
func set_facing_profile(profile: int) -> void
func get_facing_profile() -> int
## Per-joint bend accessors (bool form mirrors flip_bend_direction directly).
func set_joint_bend_flipped(joint: String, flipped: bool) -> void
func get_joint_bend_flipped(joint: String) -> bool
## The list of bend-joint names (["LeftArm", "RightArm", "LeftLeg", "RightLeg"]).
func get_bend_joints() -> Array[String]
## World position of a bend joint (its lower bone's global_position) for
## right-click hit-testing. Unknown joint → push_warning + Vector2.ZERO.
func get_bend_joint_global_position(joint: String) -> Vector2
```
`set_facing_profile` / `set_joint_bend_flipped` are the runtime API; the exported var setters funnel
into the same internal apply functions, so there is exactly one mutation path (no drift between the
inspector values, the internal state, and the live mod flags).
### 1f. Internal state & resolution
```gdscript
var _nodes_ready: bool = false
var _skeleton: Skeleton2D = null
var _body_container: Node2D = null
var _bend_joint_bones: Dictionary = {} # { String : Bone2D } (lower bones)
var _bend_modifications: Dictionary = {} # { String : SkeletonModification2DTwoBoneIK }
```
- `_ready()`: resolves `_skeleton` (`get_node_or_null(SKELETON_PATH)`), `_body_container`
(`get_node_or_null(BODY_CONTAINER_PATH)`), the 4 lower `Bone2D`s via `BEND_JOINT_BONE_PATHS`, and
the 4 TwoBoneIK modifications; enables the modification stack (`stack.enabled = true`); then
applies the current `facing_profile` once (`_apply_profile()`) and sets `_nodes_ready = true`.
- `_resolve_bend_modifications()` mirrors the current harness `_resolve_bend_joints()` matching: walk
`_skeleton.modification_stack`, and for each `SkeletonModification2DTwoBoneIK` match by
`joint_two_bone2d_node == NodePath(BEND_JOINT_BONE_PATHS[joint])` (**no hardcoded stack index**).
Missing bone/mod → `push_warning` + skip (never crash).
- `_apply_profile()`: writes the four `*_bend` vars from `PROFILE_FLAGS[profile]` (through their
setters), then `_apply_body_z_order()`.
- `_apply_body_z_order()`: preserves the Task 2 algorithm — walk the profile's ordered part names
**back-to-front** and `move_child(part, get_child_count() - 1)` for each existing child; unknown/
extra children stay at the back; missing parts skipped silently.
### 1g. Editor-time vs runtime
- **The script is NOT `@tool`.** Exported properties still appear in the editor inspector (they are
serialized into `master_rig.tscn` when the scene is saved), but the script body — node resolution,
`flip_bend_direction` writes, `Body/*` reordering, mod-stack enabling — runs **only at runtime**
(`_ready()` + setters on a live instance).
- Rationale: `SkeletonModificationStack2D` only solves at runtime, so an in-editor live preview of
bend direction would not be meaningful, and mutating `Body/*` child order / mod sub-resources in
the editor would dirty the scene and fight the editor's undo/ownership. Runtime-only application
is the safe minimum the task explicitly permits ("at minimum runtime"). Making it `@tool` later
(gated behind `Engine.is_editor_hint()`) is a possible future enhancement, not part of this task.
### 1h. Failure handling
Matches repo style throughout (`get_node_or_null`, `push_warning`, skip):
- Missing `Skeleton2D`, `Body`, a bend-joint bone, or a TwoBoneIK mod → `push_warning` (prefixed
`"StickmanRig: ..."`) and skip that piece; the rig never crashes.
- Unknown joint name in `set_joint_bend_flipped` / `get_joint_bend_flipped` /
`get_bend_joint_global_position``push_warning` and no-op / `false` / `Vector2.ZERO`
respectively.
- Empty `Body` container or missing part nodes → z-order reorder silently skips (as today).
## 2. `master_rig.tscn` changes
1. **Attach the script** to the root `Master` node: `script = ExtResource("…")` for
`res://scripts/stickman_rig.gd`. The exported defaults get serialized (`facing_profile = 2`,
`left_arm_bend = 0`, `right_arm_bend = 1`, `left_leg_bend = 1`, `right_leg_bend = 0`).
2. **Align authored state to the FORWARD default** (Q1 **resolved: yes**). So the
scene's as-authored state == the runtime default (and the editor view of the raw scene is
coherent):
- TwoBoneIK `flip_bend_direction` (currently authored = the **RIGHT** profile, see §6):
- `LeftArm` (`…TwoBoneIK_f0s26`, `target_nodepath = ../IK_Targets/Left_Hand`): `true`**remove the flag / `false`**.
- `RightArm` (`…TwoBoneIK_yvxej`): `true`**unchanged**.
- `RightLeg` (`…TwoBoneIK_ylko5`): absent → **unchanged** (stays `false`).
- `LeftLeg` (`…TwoBoneIK_2leu7`): absent → **add `flip_bend_direction = true`**.
- `Body/*` child order: the authored order is `Head, Body, LeftUpperLeg, RightUpperLeg,
LeftLowerLeg, RightLowerLeg, LeftUpperArm, RightUpperArm, LeftLowerArm, RightLowerArm`. Move
the `Body/Head` node to the **end** → order becomes exactly the FORWARD table. (Safe: the
`RemoteTransform2D` drivers and the adapter reference `Body/*` by name, never by index.)
Both edits are pure scene text edits. Runtime behavior is unchanged either way (the rig applies
FORWARD on `_ready`), so this only affects the as-authored appearance and makes the "default
leaves the scene as-authored" verification true.
## 3. Harness changes — `scripts/test_harness.gd`
The harness stops owning bend/facing state and drives the rig script. It keeps a thin UI mirror for
menu labels + re-application across respawns (the rig remains the authority over the actual flags).
### 3a. Removed
| Harness element | Disposition |
|---|---|
| `enum FacingProfile` | Delete — use `StickmanRig.FacingProfile`. |
| `PROFILE_FLAGS`, `Z_ORDER_BY_PROFILE` | Delete — moved to `StickmanRig`. |
| `BEND_JOINTS`, `BEND_JOINT_BONE_PATHS` | Delete — the rig exposes `get_bend_joints()` + `get_bend_joint_global_position()`. |
| `BODY_CONTAINER_PATH` | Delete — only the rig needs it. |
| `_body_container` | Delete (resolve + clear). |
| `_bend_joint_bones`, `_bend_modifications` | Delete (resolve + clear). |
| `_apply_facing_profile()` | Delete — call `_rig_script.set_facing_profile()`. |
| `_apply_body_z_order()` | Delete — moved to the rig. |
| `_resolve_bend_joints()` | Delete — moved to the rig's `_resolve_bend_modifications()`. |
| `_ensure_modification_stack_enabled()` | Delete — the rig enables its own stack in `_ready()`. |
Note: `SKELETON_PATH` **stays** in the harness (still used to resolve `_skeleton` for the bone
overlay and the coordinates panel). `_facing_profile` **stays** but is repurposed as a UI mirror
(§3b).
### 3b. State (changed / added)
```gdscript
var _rig_script: StickmanRig = null
var _facing_profile: int = StickmanRig.FacingProfile.FORWARD # UI mirror only
var _context_joint: String = ""
```
- `_facing_profile` is now only "the user's last-selected facing profile", used for the `[√] ` menu
prefix and to re-apply after each spawn. The rig's exported `facing_profile` is the actual state.
- `_context_joint` is unchanged (which joint the right-click menu targets).
### 3c. Spawn flow — `_load_and_spawn()`
1. `_free_current_rig()` (unchanged except it clears `_rig_script` instead of the deleted state).
2. `var rig := StickmanFactory.spawn(path)`; `_rig_script = rig as StickmanRig` (null-guarded:
foreign rig without the script → `push_warning` and disable facing/bend UI).
3. Connect signals **before** `add_child`:
`_rig_script.facing_profile_changed.connect(_on_facing_profile_changed)`,
`_rig_script.bend_flag_changed.connect(_on_bend_flag_changed)` (both may be no-ops; label sync
is done on `about_to_popup`, but connecting keeps future consumers working).
4. `_world.add_child(_rig)` (rig `_ready` runs here, applies its default FORWARD and enables its
mod stack).
5. `_resolve_rig_nodes()` — now only resolves `_skeleton`, `_ik_handles`, `_coord_bones` (drops
`_body_container` and `_resolve_bend_joints()`).
6. `_rig_script.set_facing_profile(_facing_profile)` — re-applies the remembered selection (no-op
when it equals the rig's FORWARD default).
### 3d. Facing menu
- Menu item ids stay `StickmanRig.FacingProfile.LEFT/RIGHT/FORWARD`.
- `_on_facing_menu_id_pressed(id)`: `_facing_profile = id`; if `_rig_script` valid →
`_rig_script.set_facing_profile(id)`; then `_update_facing_menu_labels()`.
- `_facing_menu_label(profile)`: unchanged (`[√] ` prefix based on `_facing_profile`).
### 3e. Right-click bend toggle
- `_hit_test_bend_joint(world_pos)`: iterate `_rig_script.get_bend_joints()`; position from
`_rig_script.get_bend_joint_global_position(joint)`; same `JOINT_HIT_RADIUS_PX / _camera.zoom.x`
nearest-joint selection.
- `_handle_right_click()`: unchanged flow (set `_context_joint`, set label, popup).
- `_context_menu_label(joint)`: `"Normal Bend" if _rig_script.get_joint_bend_flipped(joint) else "Invert Bend"`
(fallback `"Invert Bend"` when the rig script is missing).
- `_on_context_menu_id_pressed(_id)`: if `_context_joint` non-empty and `_rig_script` valid →
`_rig_script.set_joint_bend_flipped(_context_joint, not _rig_script.get_joint_bend_flipped(_context_joint))`.
### 3f. Signal handlers (optional but included)
```gdscript
func _on_facing_profile_changed(_profile: int) -> void:
_facing_profile = _rig_script.get_facing_profile() if _rig_script else _facing_profile
_update_facing_menu_labels()
_debug_overlay.queue_redraw()
func _on_bend_flag_changed(_joint: String, _flipped: bool) -> void:
_debug_overlay.queue_redraw()
```
(These keep the `[√] ` prefix in sync if the profile is ever changed from outside the menu; the
menu still refreshes on `about_to_popup` as the primary mechanism.)
## 4. Factory / adapter impact
- `stickman_factory.gd`: **no functional change.** `spawn_from_data` / `spawn` still return the
rig root (now carrying the `StickmanRig` script); narrow the return type to `StickmanRig`
(`return RIG_SCENE.instantiate() as StickmanRig`) for stronger typing (Q6 **resolved: yes**).
- `stk_rig_adapter.gd`: **no change.** The adapter mounts shapes onto `Body/*` by node path and
does not read or write bend/facing state. The rig's `_ready` (z-order + flags + stack-enable) runs
**after** `StkRigAdapter.apply()` (which happens inside `spawn_from_data`, before the rig enters
the tree), so mounted shape children are already present when the rig reorders `Body/*` — moving a
part node moves its whole shape group, exactly as today.
## 5. `master_rig_builder.gd`, `master_rig2.tscn`
Out of scope and untouched (per `docs/phase9_round1_bugfix_spec.md`): `master_rig_builder.gd` builds
a **different** rig (`Sticky/Stickman/.../Hip` naming), and `master_rig2.tscn` is a pose-override
variant not referenced by the factory. The new `StickmanRig` script targets `master_rig.tscn` only.
## 6. Defaults & backward compatibility
- **Default profile = FORWARD** (`facing_profile = FacingProfile.FORWARD`), matching the current
harness default `_facing_profile = FacingProfile.FORWARD`. On a fresh spawn the rig applies
FORWARD flags `{LeftArm:false, RightArm:true, LeftLeg:true, RightLeg:false}` and the FORWARD
z-order — **identical to today's runtime behavior**.
- **Authored `master_rig.tscn` flags are NOT currently FORWARD** — they are the **RIGHT** profile
(`LeftArm:true, RightArm:true, LeftLeg:false, RightLeg:false`, see `master_rig.tscn` lines 23/31
and the absent flags on the two leg mods). The harness already overwrites these at runtime, so
behavior is unchanged before/after this refactor; the mismatch only matters for the "as-authored
== default" goal (§2 / Q1).
- **No `.stk` format change.** `FILE_VERSION` stays `"1.5"`. Bend/facing state is rig-instance
state, not figure data; it is never serialized into `.stk`.
- **No `settings.json` change.** The harness facing selection stays non-persistent (as today).
- Old `.stk` files, foreign rigs, and partial figures load unchanged (null-guarded resolution).
## 7. Files modified
| File | Changes |
|---|---|
| `scripts/stickman_rig.gd` | **New.** `class_name StickmanRig extends Node2D` — enums, constants, exported properties, signals, methods, runtime resolution/apply. |
| `master_rig.tscn` | Attach `StickmanRig` to root `Master`; (recommended) align authored TwoBoneIK flags + `Body/*` order to FORWARD (§2). |
| `scripts/test_harness.gd` | Delete bend/facing ownership (§3a); add `_rig_script` + repurposed `_facing_profile`; rewire spawn, facing menu, right-click toggle (§3c3f). |
| `scripts/stickman_factory.gd` | Narrow `spawn_from_data`/`spawn` return type to `StickmanRig` (Q6). |
| `docs/phase9_task4_refactor_spec.md` | This file. |
| `AGENTS.md` | Add `scripts/stickman_rig.gd` bullet; update `test_harness.gd` bullet (facing/bend now drive the rig); note `master_rig.tscn` root script. |
| `README.md` | Project-structure table row for `stickman_rig.gd`; update test-harness + factory bullets. |
| `RIGGING.md` | Mark Task 4 implemented. |
## 8. Edge cases
- **No rig script / foreign rig** (`rig as StickmanRig` == null): `push_warning`; facing menu and
right-click bend toggle become no-ops; bone overlay/coords still work.
- **Missing `Skeleton2D` / `Body` / bones / mods**: `push_warning` + skip per section; rig never
crashes.
- **Setters before `_ready`** (during instantiation): guarded by `_nodes_ready`; `_ready` applies the
full state once — no ordering bug.
- **Re-spawn**: the rig is freed and a fresh one spawns; the harness re-applies the remembered
`_facing_profile` (§3c). `_free_current_rig()` clears `_rig_script` (not the deleted state).
- **Manual bend override then profile change**: setting a profile overwrites all four per-joint
flags (the preset wins), exactly like the current `_apply_facing_profile()`.
- **Unknown `Body/*` children**: stay at the back during reorder (unchanged Task 2 semantics).
- **Head always frontmost**: preserved in all three `Z_ORDER_BY_PROFILE` tables.
## 9. Design decisions
| # | Decision | Justification |
|---|---|---|
| D1 | New `class_name StickmanRig extends Node2D` on the `master_rig.tscn` root | The rig is the natural owner of per-instance facing/bend; a `class_name` script makes it a typed, reusable API for the harness and future consumers. |
| D2 | Enums live in the rig script (`StickmanRig.FacingProfile` / `StickmanRig.BendDirection`) | No autoload/singleton; idiomatic `class_name` enum access; single source of truth both sides can reference. |
| D3 | `facing_profile` is a preset; the four `*_bend` enum exports are the per-joint source of truth | Matches the harness's existing preset-then-override behavior (RIGGING.md: "user is free to change bend manually"); the enum dropdowns give readable "Normal"/"Inverted" inspector labels (the task's "per-joint bend enums" hint). |
| D4 | Public bool-form methods (`set_joint_bend_flipped`/`get_joint_bend_flipped`) alongside the enum exports | `flip_bend_direction` is a bool; the bool form is the engine-accurate contract and keeps the harness context-menu label logic unchanged. |
| D5 | TwoBoneIK resolved by `joint_two_bone2d_node` NodePath matching, not stack index | Preserves the existing (Task 1) resolution approach; robust to stack reordering. |
| D6 | Non-`@tool` script; apply only at runtime | IK doesn't solve in-editor; mutating `Body/*` order / mod flags in-editor would dirty the scene; runtime-only is the task's permitted minimum. |
| D7 | Harness keeps a `_facing_profile` **UI mirror** but not the bend authority | Preserves current UX (selection persists across respawns, `[√] ` label) without the harness owning flags/z-order. |
| D8 | Rig enables its own mod stack in `_ready()` | Facing/bend are meaningless until the stack is live; the rig should self-enable at runtime (single consumer today always enables it). |
| D9 | Rig exposes `get_bend_joint_global_position()` | Lets the harness drop `BEND_JOINT_BONE_PATHS`/`_bend_joint_bones` entirely; the rig owns the whole bend domain. |
## 10. Test plan
1. **Parse check** (same as prior tasks):
`..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit` — no errors.
2. **Headless SceneTree verification** (temporary script, deleted after; pattern from Round 7):
- `StickmanFactory.spawn("res://stickmen/basic.stk")` → cast root to `StickmanRig` (non-null);
`add_child` it; assert `get_facing_profile() == FacingProfile.FORWARD` and the 4 mods' flags
equal the FORWARD set; assert `Body` child order == `Z_ORDER_BY_PROFILE[FORWARD]` and `Body/Head`
is last.
- `set_facing_profile(FacingProfile.LEFT)` → assert flags + `Body` order match LEFT (left pairs
before torso, head last); repeat `RIGHT`.
- `set_joint_bend_flipped("LeftArm", true)` → assert `get_joint_bend_flipped("LeftArm") == true`,
the matched TwoBoneIK mod `flip_bend_direction == true`, and the other three unchanged.
- Instantiate a rig **without** adding it to a tree, set `facing_profile` before `add_child`, then
`add_child` → assert the pre-set profile was honored by `_ready` (guards D-setters).
3. **Harness code review**: facing menu ids use `StickmanRig.FacingProfile`; spawn connects signals
before `add_child` and re-applies `_facing_profile`; right-click toggle reads/writes the rig
script, not a mod directly; `_resolve_rig_nodes()` no longer touches bend/z-order state.
4. **Manual F6** (`res://scenes/test_harness.tscn`):
- Load `basic.stk`; figure starts FORWARD (unchanged from before).
- Facing menu → Left/Right: limbs tuck behind the torso correctly, head stays frontmost; `[√] `
moves; context menu labels reflect the new per-joint flags.
- Right-click an elbow/knee → "Invert Bend"/"Normal Bend" toggles the rig property and the label
flips; the limb visibly bends the other way.
- Load another `.stk` → the remembered facing profile re-applies to the fresh rig.
5. **Cleanup** temp verification files.
## 11. Implementation order
1. `scripts/stickman_rig.gd` — enums, constants, exports, signals, methods, runtime apply.
2. `master_rig.tscn` — attach script; align authored flags + `Body/*` order to FORWARD (Q1).
3. `scripts/test_harness.gd` — remove ownership, add `_rig_script`, rewire spawn/menus/toggle.
4. `scripts/stickman_factory.gd` — (optional) narrow return type.
5. Parse check + headless verification (temp, then removed).
6. Docs: `AGENTS.md`, `README.md`, `RIGGING.md`, this spec.
## 12. Open questions — RESOLVED (user approval)
- **Q1 — Authored scene ≠ FORWARD default.** **Resolved: YES — edit `master_rig.tscn`** to align
the authored TwoBoneIK flags and `Body/*` order to FORWARD (§2).
- **Q2 — Per-joint export representation.** **Resolved: enum form** — `BendDirection` with
`@export_enum("Normal","Inverted")` dropdowns.
- **Q3 — `@tool` vs runtime-only.** **Resolved: runtime-only** (non-`@tool`; exports still
inspector-editable).
- **Q4 — Mod-stack enabling.** **Resolved: YES** — move `stack.enabled = true` into the rig's
`_ready()`; harness drops `_ensure_modification_stack_enabled()`.
- **Q5 — Harness `_facing_profile` UI mirror.** **Resolved: YES** — keep the lightweight mirror
for the `[√] ` label + respawn re-application; the rig is the authority.
- **Q6 — Factory return type.** **Resolved: YES** — narrow `spawn_from_data`/`spawn` return type
to `StickmanRig`.
+398
View File
@@ -0,0 +1,398 @@
# Phase 9 Task 5 — Feature: Rig Animation in the Test Harness
## Overview
RIGGING.md Task 5: `master_rig.tscn` already ships an `AnimationPlayer` and an `AnimationTree`.
The test harness must let the user **select** an animation from a dropdown, **play / pause /
resume / stop** it, and **toggle loop vs. play-once**. Concretely:
1. A **dropdown** listing the rig's animations (from `AnimationPlayer.get_animation_list()`).
2. A **play/pause/resume** button (single button whose label reflects playback state) plus a
**Stop** button. Per RIGGING.md, **Play always restarts from the beginning**; **Stop resets to
the start** so the next Play restarts.
3. A **Loop** checkbox that makes the selected animation loop or play just once.
This is a **harness-only** change. `master_rig.tscn` is **not** modified (the two animation nodes
already exist and are sufficient). `scripts/stickman_rig.gd` is **not** modified (the harness
resolves the `AnimationPlayer` directly by node path, exactly as it already resolves `Skeleton2D`
and the IK handles). Only `scripts/test_harness.gd` changes.
## 1. Findings — `master_rig.tscn` animation nodes
### 1a. Node paths (both are direct children of the `Master` rig root)
| Node | Path (rig-relative) | Properties |
|---|---|---|
| `AnimationPlayer` | `AnimationPlayer` | `libraries/ = AnimationLibrary_t75yq`; no `active` override (defaults to `true`). |
| `AnimationTree` | `AnimationTree` | `active = false`; `tree_root = AnimationNodeStateMachine_6rw38`; `anim_player = NodePath("../AnimationPlayer")`. |
The harness reaches the player via `_rig.get_node_or_null(NodePath("AnimationPlayer"))` — the
rig root is the `Master` node (`_rig`, a `StickmanRig`), and `AnimationPlayer` is a direct child
(`parent="."`).
### 1b. Animation library — two animations (not one)
The `AnimationLibrary_t75yq._data` dictionary holds **two** animations (RIGGING.md says "currently
just 'walk_right'", but there is also a pose-reset helper):
| Name | Length | `loop_mode` | Tracks |
|---|---|---|---|
| `RESET` | `0.001` | *(absent → `LOOP_NONE`)* | `.:facing_profile` (discrete, value `2` = `FacingProfile.FORWARD`) |
| `walk_right` | `0.8` | `1` (`LOOP_LINEAR`) | 6 IK-target position tracks + `.:facing_profile` (discrete, value `1` = `RIGHT`) |
`AnimationPlayer.get_animation_list()` therefore returns `["RESET", "walk_right"]` (library
dictionary insertion order). The dropdown lists both; the harness prefers `walk_right` as the
initially-selected item (see §5b).
### 1c. What `walk_right` animates
`walk_right` does **not** key `Bone2D` rotations directly. It animates the **6 `IK_Targets`
`Marker2D` positions** (the `TwoBoneIK`/`LookAt` solvers then flex the bones) plus a discrete
`facing_profile` set on the rig root:
- `IK_Targets/Torso:position` (5 keys, cubic interp) — bobbing; the Torso marker's child
`RemoteTransform2D` (`remote_path = ../../../Skeleton2D/Torso`) translates the whole skeleton.
- `IK_Targets/Head:position`, `IK_Targets/Right_Leg:position`, `IK_Targets/Left_Leg:position`,
`IK_Targets/Right_Hand:position`, `IK_Targets/Left_Hand:position` (5 keys each, cubic interp).
- `.:facing_profile` (discrete, `update = 1`, value `1` = `FacingProfile.RIGHT`).
The `.:facing_profile` track writes through the `StickmanRig.facing_profile` export setter
(animation tracks write via `Object.set()`, which triggers the setter), so **playing an animation
can change the facing profile** and emits `facing_profile_changed` — which the harness already
handles via `_on_facing_profile_changed` (updates the `_facing_profile` mirror, the `[√] ` menu
prefix, and the debug redraw). No special harness handling is required; it is documented behavior.
`RESET` likewise sets `facing_profile = FORWARD`.
### 1d. Is `AnimationTree` configured? — **No (placeholder)**
`AnimationTree` has `active = false` and an **empty** `AnimationNodeStateMachine` root
(`AnimationNodeStateMachine_6rw38` has no states, no transitions, and no `start_node`). There is
**no** `AnimationNodeAnimation`, no `AnimationNodeBlendTree`, and no output node set. It is a
placeholder.
**Decision (D1): the harness drives `AnimationPlayer` directly; configuring `AnimationTree` is
out of scope.** Justification: the task only needs select/play/pause/stop/loop, all of which
`AnimationPlayer` provides directly; a state-machine/blend-tree setup adds nothing for a single
animation stream and would require editing `master_rig.tscn`. The `AnimationTree` node is left
untouched for a future blending phase.
## 2. Godot 4 API notes (verified for 4.7)
- `AnimationPlayer.get_animation_list() -> PackedStringArray` — animation names.
- `AnimationPlayer.get_animation(name: StringName) -> Animation` — the `Animation` resource.
- `AnimationPlayer.play(name: StringName, ...)` — if the player is **stopped**, calling `play(name)`
**restarts from position 0**. If the player is **paused** on the same animation, `play(name)` (or
`play()` with no args) **resumes**. We rely on the documented distinction: *"the assigned
animation will resume playing if it was paused, or restart if it was stopped."*
- `AnimationPlayer.pause()` — pauses, keeps position.
- `AnimationPlayer.stop()` — default `keep_state = false`: stops and **resets position to 0**.
- `Animation.loop_mode``Animation.LOOP_NONE` (0) / `Animation.LOOP_LINEAR` (1). Loop is a
property of the **`Animation` resource**, not of `play()`, so the toggle writes
`anim.loop_mode` on the selected animation before playing.
- `AnimationPlayer.animation_finished(anim_name: StringName)` — emitted when an animation reaches
its end and stops. **Not** emitted on `pause()`/`stop()`. For **looping** animations the emit-on-
wrap behavior varies across 4.x versions, so the handler ignores the signal while `_loop` is true
(see §7 D4) — this is safe under either engine behavior.
## 3. UI design
New controls in the top-bar `HBox`, inserted **immediately after the "Facing" `MenuButton` and
before the "Open .stk…" button** (keeps the two rig-behavior control clusters — Facing + Animation
— adjacent at the left edge, and leaves the load / debug-display clusters untouched):
```
[ Facing ][ AnimDropdown ][ Play/Pause ][ Stop ][ ☑ Loop ][ Open .stk… ][ Break ][ Basic ][ Test ][ Show Bones ][ Show IK Handles ][ Show Coords ] …status…
```
| Node | Type | Text / state | Purpose |
|---|---|---|---|
| `_anim_dropdown` | `OptionButton` | populated per spawn | Select the animation. |
| `_play_button` | `Button` | `"Play"` / `"Pause"` / `"Resume"` (label swaps) | Play-from-start / pause / resume. |
| `_stop_button` | `Button` | `"Stop"` | Stop and reset to start. |
| `_loop_check` | `CheckBox` | `"Loop"`, `button_pressed = true` | Loop vs. play-once. |
Controls are **always enabled** (matching the harness's existing "Facing" menu / checkbox style);
each handler no-op-guards on a missing `AnimationPlayer` instead of disabling the control.
## 4. Implementation — `scripts/test_harness.gd`
### 4a. Constants
```gdscript
## AnimationPlayer node path (relative to rig root).
const ANIMATION_PLAYER_PATH := "AnimationPlayer"
## Initially-selected animation in the dropdown (RIGGING.md default).
const DEFAULT_ANIMATION := "walk_right"
```
### 4b. Enum
```gdscript
## Harness-tracked playback state (the harness is the sole driver of the
## AnimationPlayer, so it tracks state authoritatively via button handlers and
## the animation_finished signal rather than polling is_playing()).
enum PlaybackState { STOPPED, PLAYING, PAUSED }
```
### 4c. Runtime-built node references (added to the existing block)
```gdscript
var _anim_dropdown: OptionButton
var _play_button: Button
var _stop_button: Button
var _loop_check: CheckBox
```
### 4d. State (added to the existing block)
```gdscript
var _anim_player: AnimationPlayer = null
var _selected_animation: String = ""
var _playback_state: int = PlaybackState.STOPPED
var _loop: bool = true # harness-level, persists across respawns (like _show_coords)
```
### 4e. UI construction — insert in `_build_ui()`
Insert after `hbox.add_child(_facing_button)` and before `var open_btn := Button.new()`:
```gdscript
_anim_dropdown = OptionButton.new()
_anim_dropdown.item_selected.connect(_on_anim_dropdown_selected)
hbox.add_child(_anim_dropdown)
_play_button = Button.new()
_play_button.text = "Play"
_play_button.pressed.connect(_on_play_pressed)
hbox.add_child(_play_button)
_stop_button = Button.new()
_stop_button.text = "Stop"
_stop_button.pressed.connect(_on_stop_pressed)
hbox.add_child(_stop_button)
_loop_check = CheckBox.new()
_loop_check.text = "Loop"
_loop_check.button_pressed = true
_loop_check.toggled.connect(_on_loop_toggled)
hbox.add_child(_loop_check)
```
### 4f. Resolution — `_resolve_anim_player()` (new)
Called from `_resolve_rig_nodes()` (add the call at its end, after `_resolve_coord_bones()`):
```gdscript
func _resolve_anim_player() -> void:
_anim_player = _rig.get_node_or_null(NodePath(ANIMATION_PLAYER_PATH)) as AnimationPlayer
_populate_animation_dropdown()
if _anim_player == null:
push_warning("TestHarness: missing '%s' node in rig." % ANIMATION_PLAYER_PATH)
return
_anim_player.animation_finished.connect(_on_animation_finished)
```
```gdscript
func _populate_animation_dropdown() -> void:
_anim_dropdown.clear()
_selected_animation = ""
_playback_state = PlaybackState.STOPPED
_update_play_button()
if _anim_player == null:
return
var preferred_idx := 0
var anim_list: PackedStringArray = _anim_player.get_animation_list()
for i: int in anim_list.size():
var anim_name: String = anim_list[i]
_anim_dropdown.add_item(anim_name)
if anim_name == DEFAULT_ANIMATION:
preferred_idx = i
if _anim_dropdown.item_count > 0:
_anim_dropdown.select(preferred_idx)
_selected_animation = _anim_dropdown.get_item_text(preferred_idx)
```
### 4g. Handlers
```gdscript
func _on_anim_dropdown_selected(index: int) -> void:
_selected_animation = _anim_dropdown.get_item_text(index)
# Changing selection stops any in-progress playback (Play restarts it).
if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop()
_playback_state = PlaybackState.STOPPED
_update_play_button()
func _on_play_pressed() -> void:
if _anim_player == null or not is_instance_valid(_anim_player):
return
if _selected_animation.is_empty():
return
match _playback_state:
PlaybackState.STOPPED:
_apply_loop_mode()
_anim_player.play(_selected_animation) # restart from position 0
_playback_state = PlaybackState.PLAYING
PlaybackState.PLAYING:
_anim_player.pause()
_playback_state = PlaybackState.PAUSED
PlaybackState.PAUSED:
_anim_player.play() # resume the assigned (paused) animation
_playback_state = PlaybackState.PLAYING
_update_play_button()
func _on_stop_pressed() -> void:
if _anim_player == null or not is_instance_valid(_anim_player):
return
_anim_player.stop() # resets position to 0 and stops
_playback_state = PlaybackState.STOPPED
_update_play_button()
func _on_loop_toggled(pressed: bool) -> void:
_loop = pressed
_apply_loop_mode()
func _on_animation_finished(_anim_name: StringName) -> void:
if _loop:
return # looping: never treat a wrap as "finished"
_playback_state = PlaybackState.STOPPED
_update_play_button()
```
### 4h. Helpers
```gdscript
func _apply_loop_mode() -> void:
if _anim_player == null or not is_instance_valid(_anim_player):
return
if _selected_animation.is_empty():
return
var anim: Animation = _anim_player.get_animation(_selected_animation)
if anim != null:
anim.loop_mode = Animation.LOOP_LINEAR if _loop else Animation.LOOP_NONE
func _update_play_button() -> void:
if _play_button == null:
return
match _playback_state:
PlaybackState.STOPPED:
_play_button.text = "Play"
PlaybackState.PLAYING:
_play_button.text = "Pause"
PlaybackState.PAUSED:
_play_button.text = "Resume"
```
### 4i. Lifecycle
- **`_resolve_rig_nodes()`** — add `_resolve_anim_player()` after `_resolve_coord_bones()`. Each
spawn re-resolves the player, repopulates the dropdown (fresh `AnimationPlayer` → fresh list),
resets `_playback_state` to `STOPPED`, and re-connects `animation_finished`.
- **`_free_current_rig()`** — add:
```gdscript
_anim_player = null
_anim_dropdown.clear()
_selected_animation = ""
_playback_state = PlaybackState.STOPPED
_update_play_button()
```
(The old player is `queue_free`d with the rig; its `animation_finished` connection dies with it.
`_loop` is **not** reset — it is harness-level state that persists across respawns, like
`_show_coords` / `_facing_profile`.)
- **`_process(delta)`** — **unchanged.** The coordinates readout keeps updating during playback
(it reads `global_position`/`global_rotation` every frame), and `AnimationPlayer` self-animates
independent of the harness `_process`. No playback-state polling is added (state is tracked via
handlers + `animation_finished`).
## 5. Files modified
| File | Changes |
|---|---|
| `scripts/test_harness.gd` | `ANIMATION_PLAYER_PATH`, `DEFAULT_ANIMATION`, `PlaybackState`, `_anim_dropdown`/`_play_button`/`_stop_button`/`_loop_check`, `_anim_player`/`_selected_animation`/`_playback_state`/`_loop`, UI block in `_build_ui()`, `_resolve_anim_player()`, `_populate_animation_dropdown()`, `_on_anim_dropdown_selected()`, `_on_play_pressed()`, `_on_stop_pressed()`, `_on_loop_toggled()`, `_on_animation_finished()`, `_apply_loop_mode()`, `_update_play_button()`; lifecycle hooks in `_resolve_rig_nodes()` + `_free_current_rig()`. |
| `docs/phase9_task5_animation_spec.md` | This file. |
| `AGENTS.md` | Test-harness section: "Phase 9 Task 5 rig animation" bullet (dropdown + play/pause/stop + loop, drives `AnimationPlayer` directly). |
| `README.md` | Test-harness bullet: animation select/play/pause/stop/loop controls. |
| `RIGGING.md` | Mark Task 5 implemented. |
**Not modified:** `master_rig.tscn`, `scripts/stickman_rig.gd`, `scripts/stickman_factory.gd`,
`scripts/stk_rig_adapter.gd`.
## 6. Edge cases
- **No rig loaded / no `AnimationPlayer`** (foreign rig): `_resolve_anim_player()` warns once;
the dropdown is empty; `_on_play_pressed`/`_on_stop_pressed`/`_on_loop_toggled` no-op-guard.
- **Re-spawn**: dropdown repopulated, `_playback_state` reset to `STOPPED`, play button label back
to `"Play"`; `_loop` toggle persists and is re-applied on the next play (via `_apply_loop_mode()`).
- **Changing the dropdown selection mid-play**: the current animation stops and state → `STOPPED`
(the newly selected animation is not auto-started).
- **Non-looping animation finishes**: `animation_finished` → state → `STOPPED`, button → `"Play"`.
- **Looping animation**: `animation_finished` (if emitted on wrap in this engine version) is
ignored by the `_loop` guard; the button stays `"Pause"` indefinitely.
- **Manual IK dragging during playback**: not blocked; but the animated tracks overwrite the dragged
handles' positions on the next frame, so dragging an animated handle while playing has no lasting
effect (expected; documented, not "fixed").
- **Animation changes the facing profile**: `walk_right` → `RIGHT`, `RESET` → `FORWARD`; flows
through the rig setter and the existing `_on_facing_profile_changed` (menu `[√] ` + redraw).
- **Stopping does not restore the rest pose**: `stop()` resets the *playhead* to 0 but leaves
properties at their last keyed values. The `RESET` animation is available to restore facing;
full rest-pose restoration on stop is out of scope.
- **The 0.001s `RESET` animation + loop ON**: selecting it with loop ON makes a harmless tight
loop (facing stays FORWARD). Not special-cased.
## 7. Design decisions
| # | Decision | Justification |
|---|---|---|
| D1 | Drive `AnimationPlayer` directly; `AnimationTree` out of scope | `AnimationTree` is an unconfigured placeholder (`active = false`, empty state machine). Select/play/pause/stop/loop are all first-class `AnimationPlayer` APIs; wiring a blend tree would require editing `master_rig.tscn` for no gain here. |
| D2 | Single play/pause/resume button + separate Stop button | Matches RIGGING.md "pause/resume" (toggling) vs "stop (restart on play)" as distinct states; the label swap is the harness's existing dynamic-text pattern (cf. the editor's snap/guide menus). |
| D3 | Track playback state via `_playback_state` + `animation_finished`, not `is_playing()` polling | The harness is the sole driver, so state is deterministic. `animation_finished` reliably fires for non-looping end-of-play and never fires on `pause()`/`stop()`; the `_loop` guard makes the looping-wrap ambiguity moot. Avoids adding per-frame polling to `_process`. |
| D4 | Loop = write `Animation.loop_mode` on the selected `Animation` before playing | Loop is an `Animation`-resource property, not a `play()` argument; this is the only way to override the authored value. Re-applied on every play so the harness `_loop` toggle is authoritative regardless of authored `loop_mode`. |
| D5 | Loop default **ON** | Matches the authored `walk_right` (`loop_mode = 1`), and a walk cycle is the natural looping case. |
| D6 | Dropdown populated dynamically per spawn from `get_animation_list()` | The list comes from the rig's own library, so future animations appear automatically; no hardcoded list. |
| D7 | Prefer `walk_right` as initial selection (`DEFAULT_ANIMATION`) | Matches RIGGING.md's "currently just 'walk_right'" default even though the library also contains `RESET`. |
| D8 | No `stickman_rig.gd` change | The harness already resolves rig children by node path (`SKELETON_PATH`, `IK_HANDLE_PATHS`); `ANIMATION_PLAYER_PATH` follows that established pattern. A rig-level accessor is unnecessary. |
| D9 | Controls never disabled; handlers no-op-guard | Matches the existing harness style (the "Facing" menu and checkboxes are always enabled). |
## 8. Verification
Only `scripts/test_harness.gd` changes, so the syntax checks target that script.
1. **Whole-project parse check** (established form used by every prior phase — the plain
`--check-only` form hangs on renderer init in 4.7.x, so use the `--headless --check-only --quit`
variant). Run from the project dir `C:\Godot4\stickman`:
```
..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit
```
2. **Single-script check** (user-specified form; run from anywhere):
```
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --check-only --script "res://scripts/test_harness.gd"
```
3. **Manual F6 check** (`res://scenes/test_harness.tscn`):
- Load `stickmen/basic.stk` → dropdown lists `RESET` and `walk_right`, `walk_right` selected,
play button shows `"Play"`, Loop checked.
- Press **Play** → button `"Pause"`; the figure walks (IK targets animate, legs/arms swing,
facing menu flips to `[√] Right`). Coordinates readout updates live.
- Press **Pause** → button `"Resume"`; figure freezes. Press again → resumes.
- Press **Stop** → figure stops, button `"Play"`; press **Play** → restarts from the beginning
(not from the paused position).
- Uncheck **Loop** → press **Play** → animation plays once, then the button returns to `"Play"`
by itself.
- Select `RESET` → Play → facing returns to `[√] Forward`.
- Load a different `.stk` → dropdown repopulated, playback reset, Loop checkbox state kept.
## 9. Implementation order
1. `scripts/test_harness.gd` — constants, enum, node refs, state, `_build_ui()` block, handlers,
helpers, lifecycle hooks.
2. Parse checks (§8 items 12) + manual F6 (§8 item 3).
3. Docs: `AGENTS.md`, `README.md`, `RIGGING.md`, this spec.
+166 -39
View File
@@ -1,5 +1,7 @@
[gd_scene format=3 uid="uid://dr5ef3l2var2s"] [gd_scene format=3 uid="uid://dr5ef3l2var2s"]
[ext_resource type="Script" uid="uid://b4rsae8suaxj7" path="res://scripts/stickman_rig.gd" id="1_rig_script"]
[sub_resource type="GDScript" id="GDScript_f0s26"] [sub_resource type="GDScript" id="GDScript_f0s26"]
script/source = "@tool script/source = "@tool
extends Node2D extends Node2D
@@ -28,7 +30,6 @@ joint_two_bone2d_node = NodePath("Torso/RightUpperArm/RightLowerArm")
[sub_resource type="SkeletonModification2DTwoBoneIK" id="SkeletonModification2DTwoBoneIK_f0s26"] [sub_resource type="SkeletonModification2DTwoBoneIK" id="SkeletonModification2DTwoBoneIK_f0s26"]
target_nodepath = NodePath("../IK_Targets/Left_Hand") target_nodepath = NodePath("../IK_Targets/Left_Hand")
flip_bend_direction = true
joint_one_bone_idx = 2 joint_one_bone_idx = 2
joint_one_bone2d_node = NodePath("Torso/LeftUpperArm") joint_one_bone2d_node = NodePath("Torso/LeftUpperArm")
joint_two_bone_idx = 3 joint_two_bone_idx = 3
@@ -43,6 +44,7 @@ joint_two_bone2d_node = NodePath("Torso/RightUpperLeg/RightLowerLeg")
[sub_resource type="SkeletonModification2DTwoBoneIK" id="SkeletonModification2DTwoBoneIK_2leu7"] [sub_resource type="SkeletonModification2DTwoBoneIK" id="SkeletonModification2DTwoBoneIK_2leu7"]
target_nodepath = NodePath("../IK_Targets/Left_Leg") target_nodepath = NodePath("../IK_Targets/Left_Leg")
flip_bend_direction = true
joint_one_bone_idx = 6 joint_one_bone_idx = 6
joint_one_bone2d_node = NodePath("Torso/LeftUpperLeg") joint_one_bone2d_node = NodePath("Torso/LeftUpperLeg")
joint_two_bone_idx = 7 joint_two_bone_idx = 7
@@ -59,6 +61,7 @@ constraint_angle_invert = true
constraint_in_localspace = true constraint_in_localspace = true
[sub_resource type="SkeletonModificationStack2D" id="SkeletonModificationStack2D_j4hao"] [sub_resource type="SkeletonModificationStack2D" id="SkeletonModificationStack2D_j4hao"]
enabled = true
modification_count = 5 modification_count = 5
modifications/0 = SubResource("SkeletonModification2DTwoBoneIK_yvxej") modifications/0 = SubResource("SkeletonModification2DTwoBoneIK_yvxej")
modifications/1 = SubResource("SkeletonModification2DTwoBoneIK_f0s26") modifications/1 = SubResource("SkeletonModification2DTwoBoneIK_f0s26")
@@ -68,6 +71,18 @@ modifications/4 = SubResource("SkeletonModification2DLookAt_j4hao")
[sub_resource type="Animation" id="Animation_2leu7"] [sub_resource type="Animation" id="Animation_2leu7"]
length = 0.001 length = 0.001
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath(".:facing_profile")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [2]
}
[sub_resource type="Animation" id="Animation_ylko5"] [sub_resource type="Animation" id="Animation_ylko5"]
length = 0.8 length = 0.8
@@ -75,67 +90,67 @@ loop_mode = 1
tracks/0/type = "value" tracks/0/type = "value"
tracks/0/imported = false tracks/0/imported = false
tracks/0/enabled = true tracks/0/enabled = true
tracks/0/path = NodePath("IK_Targets/Torso:position") tracks/0/path = NodePath(".:facing_profile")
tracks/0/interp = 2 tracks/0/interp = 1
tracks/0/loop_wrap = true tracks/0/loop_wrap = true
tracks/0/keys = { tracks/0/keys = {
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8), "times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1), "transitions": PackedFloat32Array(1),
"update": 0, "update": 1,
"values": [Vector2(0, 10), Vector2(0, -15), Vector2(0, 10), Vector2(0, -15), Vector2(0, 10)] "values": [0]
} }
tracks/1/type = "value" tracks/1/type = "value"
tracks/1/imported = false tracks/1/imported = false
tracks/1/enabled = true tracks/1/enabled = true
tracks/1/path = NodePath("IK_Targets/Head:position") tracks/1/path = NodePath("IK_Targets/Torso:position")
tracks/1/interp = 2 tracks/1/interp = 2
tracks/1/loop_wrap = true tracks/1/loop_wrap = true
tracks/1/keys = { tracks/1/keys = {
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8), "times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1), "transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0, "update": 0,
"values": [Vector2(100, -614), Vector2(100, -639), Vector2(100, -614), Vector2(100, -639), Vector2(100, -614)] "values": [Vector2(0, 10), Vector2(0, -15), Vector2(0, 10), Vector2(0, -15), Vector2(0, 10)]
} }
tracks/2/type = "value" tracks/2/type = "value"
tracks/2/imported = false tracks/2/imported = false
tracks/2/enabled = true tracks/2/enabled = true
tracks/2/path = NodePath("IK_Targets/Right_Leg:position") tracks/2/path = NodePath("IK_Targets/Head:position")
tracks/2/interp = 2 tracks/2/interp = 2
tracks/2/loop_wrap = true tracks/2/loop_wrap = true
tracks/2/keys = { tracks/2/keys = {
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8), "times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1), "transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0, "update": 0,
"values": [Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390)] "values": [Vector2(-100, -614), Vector2(-100, -639), Vector2(-100, -614), Vector2(-100, -639), Vector2(-100, -614)]
} }
tracks/3/type = "value" tracks/3/type = "value"
tracks/3/imported = false tracks/3/imported = false
tracks/3/enabled = true tracks/3/enabled = true
tracks/3/path = NodePath("IK_Targets/Left_Leg:position") tracks/3/path = NodePath("IK_Targets/Right_Leg:position")
tracks/3/interp = 2 tracks/3/interp = 2
tracks/3/loop_wrap = true tracks/3/loop_wrap = true
tracks/3/keys = { tracks/3/keys = {
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8), "times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1), "transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0, "update": 0,
"values": [Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380)] "values": [Vector2(-110, 390), Vector2(0, 397), Vector2(110, 380), Vector2(20, 320), Vector2(-110, 390)]
} }
tracks/4/type = "value" tracks/4/type = "value"
tracks/4/imported = false tracks/4/imported = false
tracks/4/enabled = true tracks/4/enabled = true
tracks/4/path = NodePath("IK_Targets/Right_Hand:position") tracks/4/path = NodePath("IK_Targets/Left_Leg:position")
tracks/4/interp = 2 tracks/4/interp = 2
tracks/4/loop_wrap = true tracks/4/loop_wrap = true
tracks/4/keys = { tracks/4/keys = {
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8), "times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1), "transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0, "update": 0,
"values": [Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110)] "values": [Vector2(110, 380), Vector2(20, 320), Vector2(-110, 390), Vector2(0, 397), Vector2(110, 380)]
} }
tracks/5/type = "value" tracks/5/type = "value"
tracks/5/imported = false tracks/5/imported = false
tracks/5/enabled = true tracks/5/enabled = true
tracks/5/path = NodePath("IK_Targets/Left_Hand:position") tracks/5/path = NodePath("IK_Targets/Right_Hand:position")
tracks/5/interp = 2 tracks/5/interp = 2
tracks/5/loop_wrap = true tracks/5/loop_wrap = true
tracks/5/keys = { tracks/5/keys = {
@@ -144,25 +159,121 @@ tracks/5/keys = {
"update": 0, "update": 0,
"values": [Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110)] "values": [Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110)]
} }
tracks/6/type = "value"
tracks/6/imported = false
tracks/6/enabled = true
tracks/6/path = NodePath("IK_Targets/Left_Hand:position")
tracks/6/interp = 2
tracks/6/loop_wrap = true
tracks/6/keys = {
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110)]
}
[sub_resource type="Animation" id="Animation_t75yq"]
length = 0.8
loop_mode = 1
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath(".:facing_profile")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [1]
}
tracks/1/type = "value"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("IK_Targets/Torso:position")
tracks/1/interp = 2
tracks/1/loop_wrap = true
tracks/1/keys = {
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(0, 10), Vector2(0, -15), Vector2(0, 10), Vector2(0, -15), Vector2(0, 10)]
}
tracks/2/type = "value"
tracks/2/imported = false
tracks/2/enabled = true
tracks/2/path = NodePath("IK_Targets/Head:position")
tracks/2/interp = 2
tracks/2/loop_wrap = true
tracks/2/keys = {
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(100, -614), Vector2(100, -639), Vector2(100, -614), Vector2(100, -639), Vector2(100, -614)]
}
tracks/3/type = "value"
tracks/3/imported = false
tracks/3/enabled = true
tracks/3/path = NodePath("IK_Targets/Right_Leg:position")
tracks/3/interp = 2
tracks/3/loop_wrap = true
tracks/3/keys = {
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390)]
}
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.2, 0.4, 0.6, 0.8),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380)]
}
tracks/5/type = "value"
tracks/5/imported = false
tracks/5/enabled = true
tracks/5/path = NodePath("IK_Targets/Right_Hand:position")
tracks/5/interp = 2
tracks/5/loop_wrap = true
tracks/5/keys = {
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110)]
}
tracks/6/type = "value"
tracks/6/imported = false
tracks/6/enabled = true
tracks/6/path = NodePath("IK_Targets/Left_Hand:position")
tracks/6/interp = 2
tracks/6/loop_wrap = true
tracks/6/keys = {
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 0,
"values": [Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110)]
}
[sub_resource type="AnimationLibrary" id="AnimationLibrary_t75yq"] [sub_resource type="AnimationLibrary" id="AnimationLibrary_t75yq"]
_data = { _data = {
&"RESET": SubResource("Animation_2leu7"), &"RESET": SubResource("Animation_2leu7"),
&"walk_right": SubResource("Animation_ylko5") &"walk_left": SubResource("Animation_ylko5"),
&"walk_right": SubResource("Animation_t75yq")
} }
[sub_resource type="AnimationNodeStateMachine" id="AnimationNodeStateMachine_6rw38"] [sub_resource type="AnimationNodeStateMachine" id="AnimationNodeStateMachine_6rw38"]
[node name="Master" type="Node2D" unique_id=319103476] [node name="Master" type="Node2D" unique_id=319103476]
script = ExtResource("1_rig_script")
[node name="Body" type="Node2D" parent="." unique_id=327811712] [node name="Body" type="Node2D" parent="." unique_id=327811712]
[node name="Head" type="Node2D" parent="Body" unique_id=864822355]
position = Vector2(-0.03859164, -453.50787)
rotation = 0.00055408623
scale = Vector2(0.9999996, 0.9999996)
script = SubResource("GDScript_f0s26")
[node name="Body" type="Line2D" parent="Body" unique_id=1290443463] [node name="Body" type="Line2D" parent="Body" unique_id=1290443463]
position = Vector2(9.313226e-10, 10.000001) position = Vector2(9.313226e-10, 10.000001)
rotation = -3.1415925 rotation = -3.1415925
@@ -171,64 +282,71 @@ width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1) default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="LeftUpperLeg" type="Line2D" parent="Body" unique_id=199373156] [node name="LeftUpperLeg" type="Line2D" parent="Body" unique_id=199373156]
position = Vector2(1.6098846e-05, 10.000002) position = Vector2(1.6037084e-05, 10.000003)
rotation = 0.4947604 rotation = 0.55427897
points = PackedVector2Array(0, 0, 0, 200) points = PackedVector2Array(0, 0, 0, 200)
width = 16.0 width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1) default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="RightUpperLeg" type="Line2D" parent="Body" unique_id=1396556308] [node name="RightUpperLeg" type="Line2D" parent="Body" unique_id=1396556308]
position = Vector2(-2.3252098e-05, 10.000022) position = Vector2(-2.4553774e-05, 10.000021)
rotation = -0.49392816 rotation = -0.43021643
scale = Vector2(0.99999994, 0.99999994) scale = Vector2(0.99999994, 0.99999994)
points = PackedVector2Array(0, 0, 0, 200) points = PackedVector2Array(0, 0, 0, 200)
width = 16.0 width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1) default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="LeftLowerLeg" type="Line2D" parent="Body" unique_id=1590485904] [node name="LeftLowerLeg" type="Line2D" parent="Body" unique_id=1590485904]
position = Vector2(-94.96416, 186.0165) position = Vector2(-105.26607, 180.05603)
rotation = 0.0051915743 rotation = 0.023672067
scale = Vector2(0.99999994, 0.99999994) scale = Vector2(0.99999994, 0.99999994)
points = PackedVector2Array(0, 0, 0, 200) points = PackedVector2Array(0, 0, 0, 200)
width = 16.0 width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1) default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="RightLowerLeg" type="Line2D" parent="Body" unique_id=29203948] [node name="RightLowerLeg" type="Line2D" parent="Body" unique_id=29203948]
position = Vector2(94.817635, 186.09546) position = Vector2(83.4135, 191.77509)
rotation = -0.0034907677 rotation = -0.13332568
scale = Vector2(0.9999998, 0.9999998) scale = Vector2(0.9999997, 0.9999997)
points = PackedVector2Array(0, 0, 0, 200) points = PackedVector2Array(0, 0, 0, 200)
width = 16.0 width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1) default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="LeftUpperArm" type="Line2D" parent="Body" unique_id=744524157] [node name="LeftUpperArm" type="Line2D" parent="Body" unique_id=744524157]
position = Vector2(9.313226e-10, -238) position = Vector2(9.313226e-10, -238)
rotation = 1.6184965 rotation = -0.48905078
points = PackedVector2Array(0, 0, 0, 175) points = PackedVector2Array(0, 0, 0, 175)
width = 16.0 width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1) default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="RightUpperArm" type="Line2D" parent="Body" unique_id=1593646765] [node name="RightUpperArm" type="Line2D" parent="Body" unique_id=1593646765]
position = Vector2(9.313226e-10, -238) position = Vector2(9.313226e-10, -238)
rotation = -1.6184964 rotation = 0.48905125
scale = Vector2(0.99999994, 0.99999994)
points = PackedVector2Array(0, 0, 0, 175) points = PackedVector2Array(0, 0, 0, 175)
width = 16.0 width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1) default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="LeftLowerArm" type="Line2D" parent="Body" unique_id=2142117097] [node name="LeftLowerArm" type="Line2D" parent="Body" unique_id=2142117097]
position = Vector2(-167.80888, -246.01059) position = Vector2(78.92438, -89.6931)
rotation = 3.1406152 rotation = -0.055406693
points = PackedVector2Array(0, 0, 0, 200) points = PackedVector2Array(0, 0, 0, 200)
width = 16.0 width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1) default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="RightLowerArm" type="Line2D" parent="Body" unique_id=1200980480] [node name="RightLowerArm" type="Line2D" parent="Body" unique_id=1200980480]
position = Vector2(167.80891, -246.01057) position = Vector2(-78.924484, -89.69313)
rotation = -3.1406155 rotation = 0.055405635
points = PackedVector2Array(0, 0, 0, 200) points = PackedVector2Array(0, 0, 0, 200)
width = 16.0 width = 16.0
default_color = Color(0.445488, 0.445488, 0.445488, 1) default_color = Color(0.445488, 0.445488, 0.445488, 1)
[node name="Head" type="Node2D" parent="Body" unique_id=864822355]
position = Vector2(28.397142, -447.61594)
rotation = 0.40666866
scale = Vector2(0.9999996, 0.9999996)
script = SubResource("GDScript_f0s26")
[node name="Skeleton2D" type="Skeleton2D" parent="." unique_id=1854735445] [node name="Skeleton2D" type="Skeleton2D" parent="." unique_id=1854735445]
rotation = -0.0006991282 rotation = -0.0006991282
modification_stack = SubResource("SkeletonModificationStack2D_j4hao") modification_stack = SubResource("SkeletonModificationStack2D_j4hao")
@@ -240,16 +358,17 @@ rest = Transform2D(0.99999976, 0.00069912814, -0.00069912814, 0.99999976, 0, 0)
[node name="Head" type="Bone2D" parent="Skeleton2D/Torso" unique_id=1487357366] [node name="Head" type="Bone2D" parent="Skeleton2D/Torso" unique_id=1487357366]
position = Vector2(-0.12882307, -391.5079) position = Vector2(-0.12882307, -391.5079)
rotation = 0.00055408623
scale = Vector2(0.9999996, 0.9999996) scale = Vector2(0.9999996, 0.9999996)
rest = Transform2D(0.9998273, 0.0005539903, -0.0005539903, 0.9998273, -0.12882307, -391.5079) rest = Transform2D(0.9998273, 0.0005539903, -0.0005539903, 0.9998273, -0.12882307, -391.5079)
auto_calculate_length_and_angle = false auto_calculate_length_and_angle = false
length = 90.0 length = 90.0
bone_angle = -90.0 bone_angle = -90.0
metadata/_local_pose_override_enabled_ = true
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/Head" unique_id=396112729] [node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/Head" unique_id=396112729]
position = Vector2(0.050337255, -72.00002) position = Vector2(0.050337255, -72.00002)
remote_path = NodePath("../../../../Body/Head") remote_path = NodePath("../../../../Body/Head")
update_scale = false
[node name="RayCast_Aim" type="RayCast2D" parent="Skeleton2D/Torso/Head" unique_id=2000000004] [node name="RayCast_Aim" type="RayCast2D" parent="Skeleton2D/Torso/Head" unique_id=2000000004]
position = Vector2(0, -90) position = Vector2(0, -90)
@@ -262,6 +381,7 @@ rest = Transform2D(0.9988364, 0.04768082, -0.04768082, 0.9988364, 0, -248)
auto_calculate_length_and_angle = false auto_calculate_length_and_angle = false
length = 168.0 length = 168.0
bone_angle = -180.0 bone_angle = -180.0
metadata/_local_pose_override_enabled_ = true
[node name="LeftLowerArm" type="Bone2D" parent="Skeleton2D/Torso/LeftUpperArm" unique_id=721380545] [node name="LeftLowerArm" type="Bone2D" parent="Skeleton2D/Torso/LeftUpperArm" unique_id=721380545]
position = Vector2(-168, 0) position = Vector2(-168, 0)
@@ -270,6 +390,7 @@ rest = Transform2D(0.9987903, -0.04865717, 0.04865717, 0.9987903, -168, 0)
auto_calculate_length_and_angle = false auto_calculate_length_and_angle = false
length = 200.0 length = 200.0
bone_angle = -90.0 bone_angle = -90.0
metadata/_local_pose_override_enabled_ = true
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/LeftUpperArm/LeftLowerArm" unique_id=1189277122] [node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/LeftUpperArm/LeftLowerArm" unique_id=1189277122]
position = Vector2(3.0517578e-05, 1.9073486e-06) position = Vector2(3.0517578e-05, 1.9073486e-06)
@@ -287,6 +408,7 @@ rest = Transform2D(0.9988364, -0.047680702, 0.047680702, 0.9988364, 0, -248)
auto_calculate_length_and_angle = false auto_calculate_length_and_angle = false
length = 168.00006 length = 168.00006
bone_angle = 0.0 bone_angle = 0.0
metadata/_local_pose_override_enabled_ = true
[node name="RightLowerArm" type="Bone2D" parent="Skeleton2D/Torso/RightUpperArm" unique_id=1018820563] [node name="RightLowerArm" type="Bone2D" parent="Skeleton2D/Torso/RightUpperArm" unique_id=1018820563]
position = Vector2(168, 0) position = Vector2(168, 0)
@@ -295,6 +417,7 @@ rest = Transform2D(0.048656836, -0.9987903, 0.9987903, 0.048656836, 168, 0)
auto_calculate_length_and_angle = false auto_calculate_length_and_angle = false
length = 200.0 length = 200.0
bone_angle = 0.0 bone_angle = 0.0
metadata/_local_pose_override_enabled_ = true
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/RightUpperArm/RightLowerArm" unique_id=1282597655] [node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/RightUpperArm/RightLowerArm" unique_id=1282597655]
rotation = -1.5707964 rotation = -1.5707964
@@ -311,6 +434,7 @@ rest = Transform2D(0.88005984, 0.47480857, -0.47480857, 0.88005984, -9.536743e-0
auto_calculate_length_and_angle = false auto_calculate_length_and_angle = false
length = 200.0 length = 200.0
bone_angle = 90.0 bone_angle = 90.0
metadata/_local_pose_override_enabled_ = true
[node name="LeftLowerLeg" type="Bone2D" parent="Skeleton2D/Torso/LeftUpperLeg" unique_id=81288294] [node name="LeftLowerLeg" type="Bone2D" parent="Skeleton2D/Torso/LeftUpperLeg" unique_id=81288294]
position = Vector2(0, 200) position = Vector2(0, 200)
@@ -319,6 +443,7 @@ rest = Transform2D(0.4702357, 0.8825176, -0.8825176, 0.4702357, 0, 200)
auto_calculate_length_and_angle = false auto_calculate_length_and_angle = false
length = 200.0 length = 200.0
bone_angle = 0.0 bone_angle = 0.0
metadata/_local_pose_override_enabled_ = true
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg" unique_id=1673135542] [node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg" unique_id=1673135542]
rotation = -1.5707964 rotation = -1.5707964
@@ -335,6 +460,7 @@ rest = Transform2D(0.8800552, -0.47482592, 0.47482592, 0.8800552, 0, 0)
auto_calculate_length_and_angle = false auto_calculate_length_and_angle = false
length = 200.0 length = 200.0
bone_angle = 90.0 bone_angle = 90.0
metadata/_local_pose_override_enabled_ = true
[node name="RightLowerLeg" type="Bone2D" parent="Skeleton2D/Torso/RightUpperLeg" unique_id=1819999778] [node name="RightLowerLeg" type="Bone2D" parent="Skeleton2D/Torso/RightUpperLeg" unique_id=1819999778]
position = Vector2(0, 200) position = Vector2(0, 200)
@@ -344,6 +470,7 @@ rest = Transform2D(-0.47026652, 0.8825017, -0.8825017, -0.47026652, 0, 200)
auto_calculate_length_and_angle = false auto_calculate_length_and_angle = false
length = 200.0 length = 200.0
bone_angle = 0.0 bone_angle = 0.0
metadata/_local_pose_override_enabled_ = true
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/RightUpperLeg/RightLowerLeg" unique_id=278100224] [node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/RightUpperLeg/RightLowerLeg" unique_id=278100224]
rotation = -1.5707964 rotation = -1.5707964
+3 -3
View File
@@ -1,8 +1,8 @@
[gd_scene load_steps=2 format=3] [gd_scene format=3 uid="uid://bleeb15l02i3d"]
[ext_resource type="Script" path="res://scripts/test_harness.gd" id="1_harness"] [ext_resource type="Script" uid="uid://b7wkt57rkpfo4" path="res://scripts/test_harness.gd" id="1_harness"]
[node name="TestHarness" type="Control"] [node name="TestHarness" type="Control" unique_id=724700228]
layout_mode = 3 layout_mode = 3
anchors_preset = 15 anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0
+42 -39
View File
@@ -1,60 +1,63 @@
@tool @tool
extends Node2D extends EditorScript
@export var build_walk_animation: bool = false: func _run() -> void:
set(value): var root = EditorInterface.get_edited_scene_root()
if value: if not root:
_create_walk_right_animation() push_error("EditorScript: No active scene open.")
func _create_walk_right_animation() -> void:
var anim_player: AnimationPlayer = get_node_or_null("AnimationPlayer")
if not anim_player:
push_error("AnimationPlayer node not found as direct child of Master!")
return 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() var anim = Animation.new()
anim.length = 0.8 anim.length = 0.8
anim.loop_mode = Animation.LOOP_LINEAR anim.loop_mode = Animation.LOOP_LINEAR
var dir_mult: float = -1.0 if flip_x else 1.0
var times = [0.0, 0.2, 0.4, 0.6, 0.8] # 1. Profile Track (0 = LEFT, 1 = RIGHT)
var profile_track = anim.add_track(Animation.TYPE_VALUE)
# Keyframe tracks mapped relative to root Master node anim.track_set_path(profile_track, ".:facing_profile")
var tracks = { anim.value_track_set_update_mode(profile_track, Animation.UPDATE_DISCRETE)
"IK_Targets/Torso:position": [ anim.track_insert_key(profile_track, 0.0, profile_enum)
Vector2(0, 10), Vector2(0, -15), Vector2(0, 10), Vector2(0, -15), Vector2(0, 10)
], # 2. Keyframe positions
"IK_Targets/Head:position": [ var raw_tracks = {
Vector2(100, -614), Vector2(100, -639), Vector2(100, -614), Vector2(100, -639), Vector2(100, -614) "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": [ "IK_Targets/Right_Leg:position": [Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390)],
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_Leg:position": [ "IK_Targets/Left_Hand:position": [Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110)]
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)
]
} }
for path in tracks: 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) var track_idx = anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(track_idx, path) anim.track_set_path(track_idx, path)
anim.track_set_interpolation_type(track_idx, Animation.INTERPOLATION_CUBIC) anim.track_set_interpolation_type(track_idx, Animation.INTERPOLATION_CUBIC)
for i in range(times.size()): for i in range(times.size()):
anim.track_insert_key(track_idx, times[i], tracks[path][i]) 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 or retrieve default library # 3. Attach animation to AnimationPlayer library
var lib = anim_player.get_animation_library("") var lib = anim_player.get_animation_library("")
if not lib: if not lib:
lib = AnimationLibrary.new() lib = AnimationLibrary.new()
anim_player.add_animation_library("", lib) anim_player.add_animation_library("", lib)
if lib.has_animation("walk_right"): if lib.has_animation(anim_name):
lib.remove_animation("walk_right") lib.remove_animation(anim_name)
lib.add_animation("walk_right", anim) lib.add_animation(anim_name, anim)
print("Successfully generated 'walk_right' animation in AnimationPlayer!") print("Successfully generated '%s' animation!" % anim_name)
+3 -3
View File
@@ -34,13 +34,13 @@ static func load_stk(path: String) -> Dictionary:
return parsed as Dictionary return parsed as Dictionary
static func spawn_from_data(stk_data: Dictionary) -> Node2D: static func spawn_from_data(stk_data: Dictionary) -> StickmanRig:
var rig := RIG_SCENE.instantiate() as Node2D var rig := RIG_SCENE.instantiate() as StickmanRig
StkRigAdapter.apply(stk_data, rig) StkRigAdapter.apply(stk_data, rig)
return rig return rig
static func spawn(path: String) -> Node2D: static func spawn(path: String) -> StickmanRig:
var data := load_stk(path) var data := load_stk(path)
if data.is_empty(): if data.is_empty():
return null return null
+304
View File
@@ -0,0 +1,304 @@
class_name StickmanRig
extends Node2D
## StickmanRig - Runtime owner of facing direction and per-joint bone bend for
## master_rig.tscn (Phase 9 / Task 4).
##
## Attached to the `Master` root node of master_rig.tscn. Owns the facing
## preset, the four per-joint TwoBoneIK "Flip Bend Direction" flags, and the
## Body/* draw order. Non-@tool: node resolution, flag writes and z-order
## reordering run only at runtime (_ready + setters on a live instance).
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
## Facing profiles. Values are used directly as facing-menu item ids in the
## harness (0/1/2), so they must stay stable.
enum FacingProfile { LEFT, RIGHT, FORWARD }
## Per-joint bend direction. INVERTED == flip_bend_direction = true.
enum BendDirection { NORMAL, INVERTED }
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const SKELETON_PATH := "Skeleton2D"
const BODY_CONTAINER_PATH := "Body"
## Bend joints (upper↔lower limb connectors) whose TwoBoneIK "Flip Bend
## Direction" flag is user-controllable.
const BEND_JOINTS: Array[String] = ["LeftArm", "RightArm", "LeftLeg", "RightLeg"]
## Lower-bone NodePath (relative to Skeleton2D) per bend joint. Used both to
## resolve the TwoBoneIK modification (via joint_two_bone2d_node) and to report
## each joint's world position for hit-testing.
const BEND_JOINT_BONE_PATHS: Dictionary = {
"LeftArm": "Torso/LeftUpperArm/LeftLowerArm",
"RightArm": "Torso/RightUpperArm/RightLowerArm",
"LeftLeg": "Torso/LeftUpperLeg/LeftLowerLeg",
"RightLeg": "Torso/RightUpperLeg/RightLowerLeg",
}
## flip_bend_direction value per facing profile, keyed by bend-joint name.
const PROFILE_FLAGS: Dictionary = {
FacingProfile.LEFT: { "LeftArm": false, "RightArm": false, "LeftLeg": true, "RightLeg": true },
FacingProfile.RIGHT: { "LeftArm": true, "RightArm": true, "LeftLeg": false, "RightLeg": false },
FacingProfile.FORWARD: { "LeftArm": false, "RightArm": true, "LeftLeg": true, "RightLeg": false },
}
## Body/* visual part node names in draw order (back-to-front) per profile.
## First entry backmost, last frontmost. Upper limbs behind lower limbs; on the
## far (behind-torso) side the arm pair draws behind the leg pair, on the near
## side the arm pair draws in front; head always frontmost.
const Z_ORDER_BY_PROFILE: Dictionary = {
FacingProfile.FORWARD: [
"Body",
"LeftUpperLeg", "RightUpperLeg",
"LeftLowerLeg", "RightLowerLeg",
"LeftUpperArm", "RightUpperArm",
"LeftLowerArm", "RightLowerArm",
"Head",
],
FacingProfile.LEFT: [
"LeftUpperArm", "LeftLowerArm",
"LeftUpperLeg", "LeftLowerLeg",
"Body",
"RightUpperLeg", "RightLowerLeg",
"RightUpperArm", "RightLowerArm",
"Head",
],
FacingProfile.RIGHT: [
"RightUpperArm", "RightLowerArm",
"RightUpperLeg", "RightLowerLeg",
"Body",
"LeftUpperLeg", "LeftLowerLeg",
"LeftUpperArm", "LeftLowerArm",
"Head",
],
}
# ---------------------------------------------------------------------------
# Exported controls
# ---------------------------------------------------------------------------
## Facing preset. Setting it overwrites the four per-joint bend values from
## PROFILE_FLAGS and reorders Body/* children. Default FORWARD.
@export var facing_profile: FacingProfile = FacingProfile.FORWARD:
set(value):
if facing_profile == value:
return
facing_profile = value
if _nodes_ready:
_apply_profile()
## Per-joint bend direction (the actual flip_bend_direction source of truth).
## Defaults match the FORWARD profile. Individually overridable after a profile
## is applied (drifting away from the preset, matching the harness right-click).
@export_group("Bend Direction")
@export_enum("Normal", "Inverted") var left_arm_bend: int = BendDirection.NORMAL:
set(value):
left_arm_bend = value
_set_joint_bend_inverted("LeftArm", value == BendDirection.INVERTED)
@export_enum("Normal", "Inverted") var right_arm_bend: int = BendDirection.INVERTED:
set(value):
right_arm_bend = value
_set_joint_bend_inverted("RightArm", value == BendDirection.INVERTED)
@export_enum("Normal", "Inverted") var left_leg_bend: int = BendDirection.INVERTED:
set(value):
left_leg_bend = value
_set_joint_bend_inverted("LeftLeg", value == BendDirection.INVERTED)
@export_enum("Normal", "Inverted") var right_leg_bend: int = BendDirection.NORMAL:
set(value):
right_leg_bend = value
_set_joint_bend_inverted("RightLeg", value == BendDirection.INVERTED)
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted when the facing preset changes (after flags + z-order are applied).
signal facing_profile_changed(profile: int)
## Emitted when a single joint's bend direction changes. `flipped` is the new
## flip_bend_direction value (true == inverted).
signal bend_flag_changed(joint: String, flipped: bool)
# ---------------------------------------------------------------------------
# Internal state
# ---------------------------------------------------------------------------
var _nodes_ready: bool = false
var _skeleton: Skeleton2D = null
var _body_container: Node2D = null
var _bend_joint_bones: Dictionary = {} # { String : Bone2D } (lower bones)
var _bend_modifications: Dictionary = {} # { String : SkeletonModification2DTwoBoneIK }
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
# Resolve all runtime node references.
_skeleton = get_node_or_null(NodePath(SKELETON_PATH)) as Skeleton2D
if _skeleton == null:
push_warning("StickmanRig: missing '%s' node in rig." % SKELETON_PATH)
_body_container = get_node_or_null(NodePath(BODY_CONTAINER_PATH)) as Node2D
if _body_container == null:
push_warning("StickmanRig: missing '%s' node in rig." % BODY_CONTAINER_PATH)
_resolve_bend_modifications()
# Enable the modification stack (IK solves only at runtime).
if _skeleton != null:
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
if stack != null:
stack.enabled = true
else:
push_warning("StickmanRig: Skeleton2D has no modification_stack assigned.")
# Apply the authored/default profile once: writes the four mod flags from the
# current var values, reorders Body/* children, and emits the profile signal.
# _nodes_ready is set first so the per-joint setters route their mod updates
# + signal emissions through the live apply path (during instantiation the
# setters only stored values).
_nodes_ready = true
_apply_profile()
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
func set_facing_profile(profile: int) -> void:
if not PROFILE_FLAGS.has(profile):
push_warning("StickmanRig: unknown facing profile %d; ignored." % profile)
return
facing_profile = profile
func get_facing_profile() -> int:
return int(facing_profile)
func set_joint_bend_flipped(joint: String, flipped: bool) -> void:
match joint:
"LeftArm":
left_arm_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL
"RightArm":
right_arm_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL
"LeftLeg":
left_leg_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL
"RightLeg":
right_leg_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL
_:
push_warning("StickmanRig: unknown bend joint '%s'; ignored." % joint)
func get_joint_bend_flipped(joint: String) -> bool:
match joint:
"LeftArm":
return left_arm_bend == BendDirection.INVERTED
"RightArm":
return right_arm_bend == BendDirection.INVERTED
"LeftLeg":
return left_leg_bend == BendDirection.INVERTED
"RightLeg":
return right_leg_bend == BendDirection.INVERTED
_:
push_warning("StickmanRig: unknown bend joint '%s'." % joint)
return false
func get_bend_joints() -> Array[String]:
return BEND_JOINTS
func get_bend_joint_global_position(joint: String) -> Vector2:
var bone := _bend_joint_bones.get(joint) as Bone2D
if bone == null or not is_instance_valid(bone):
push_warning("StickmanRig: unknown or missing bend joint '%s'." % joint)
return Vector2.ZERO
return bone.global_position
# ---------------------------------------------------------------------------
# Internal resolution / apply
# ---------------------------------------------------------------------------
func _resolve_bend_modifications() -> void:
_bend_joint_bones.clear()
_bend_modifications.clear()
if _skeleton == null:
return
for joint: String in BEND_JOINTS:
var path: String = BEND_JOINT_BONE_PATHS[joint]
var bone := _skeleton.get_node_or_null(NodePath(path)) as Bone2D
if bone != null and is_instance_valid(bone):
_bend_joint_bones[joint] = bone
else:
push_warning("StickmanRig: missing bend-joint bone '%s'." % path)
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
if stack == null:
return
for i: int in stack.modification_count:
var mod := stack.get_modification(i)
if not (mod is SkeletonModification2DTwoBoneIK):
continue
var ik := mod as SkeletonModification2DTwoBoneIK
for joint: String in BEND_JOINTS:
if ik.joint_two_bone2d_node == NodePath(BEND_JOINT_BONE_PATHS[joint]):
_bend_modifications[joint] = ik
break
for joint: String in BEND_JOINTS:
if not _bend_modifications.has(joint):
push_warning("StickmanRig: missing TwoBoneIK modification for bend joint '%s'." % joint)
## Writes the four per-joint bend vars from PROFILE_FLAGS (through their setters,
## so the live mods stay in sync), reorders Body/* children, then emits
## facing_profile_changed.
func _apply_profile() -> void:
var flags: Dictionary = PROFILE_FLAGS.get(facing_profile, PROFILE_FLAGS[FacingProfile.FORWARD])
left_arm_bend = BendDirection.INVERTED if bool(flags.get("LeftArm", false)) else BendDirection.NORMAL
right_arm_bend = BendDirection.INVERTED if bool(flags.get("RightArm", false)) else BendDirection.NORMAL
left_leg_bend = BendDirection.INVERTED if bool(flags.get("LeftLeg", false)) else BendDirection.NORMAL
right_leg_bend = BendDirection.INVERTED if bool(flags.get("RightLeg", false)) else BendDirection.NORMAL
_apply_body_z_order()
_apply_head_flip()
facing_profile_changed.emit(int(facing_profile))
func _apply_head_flip() -> void:
if _body_container == null:
return
var head := _body_container.get_node_or_null("Head") as Node2D
if head != null:
var is_left := (facing_profile == FacingProfile.LEFT)
# Keep local X positive (neck upright), invert local Y (mirror brim & face)
head.scale = Vector2(1.0, -1.0) if is_left else Vector2(1.0, 1.0)
## Per-joint setter notify: updates the resolved TwoBoneIK mod's
## flip_bend_direction and emits bend_flag_changed. No-op before _ready (the
## setter only stored the backing value during instantiation).
func _set_joint_bend_inverted(joint: String, inverted: bool) -> void:
if not _nodes_ready:
return
var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK
if mod != null:
mod.flip_bend_direction = inverted
bend_flag_changed.emit(joint, inverted)
## Task 2 algorithm: walk the profile's ordered part names back-to-front and
## move_child(part, count - 1) each existing part; unknown/extra children stay
## at the back; missing parts skipped silently.
func _apply_body_z_order() -> void:
if _body_container == null or not is_instance_valid(_body_container):
return
var order: Array = Z_ORDER_BY_PROFILE.get(facing_profile, Z_ORDER_BY_PROFILE[FacingProfile.FORWARD])
for part_name: String in order:
var part := _body_container.get_node_or_null(NodePath(part_name))
if part != null:
_body_container.move_child(part, _body_container.get_child_count() - 1)
+1
View File
@@ -0,0 +1 @@
uid://b4rsae8suaxj7
+201 -148
View File
@@ -28,7 +28,12 @@ const HANDLE_COLOR_HEAD := Color(1.00, 1.00, 0.00) # yellow
const HANDLE_COLOR_TORSO := Color(1.00, 0.00, 1.00) # magenta const HANDLE_COLOR_TORSO := Color(1.00, 0.00, 1.00) # magenta
const SKELETON_PATH := "Skeleton2D" const SKELETON_PATH := "Skeleton2D"
const BODY_CONTAINER_PATH := "Body"
## AnimationPlayer node path (relative to rig root).
const ANIMATION_PLAYER_PATH := "AnimationPlayer"
## Initially-selected animation in the dropdown (RIGGING.md default).
const DEFAULT_ANIMATION := "walk_right"
## Width of the coordinates readout panel (Task 3). ## Width of the coordinates readout panel (Task 3).
const COORDS_PANEL_WIDTH: float = 320.0 const COORDS_PANEL_WIDTH: float = 320.0
@@ -80,63 +85,12 @@ const LEAF_BONE_IK_PATHS: Dictionary = {
"RightLowerLeg": "IK_Targets/Right_Leg", "RightLowerLeg": "IK_Targets/Right_Leg",
} }
## Facing profiles for the rig's TwoBoneIK "Flip Bend Direction" flags (Phase
## 9 / Task 1). LEFT = facing left, RIGHT = facing right, FORWARD = facing the
## user. The enum values are used directly as the facing-menu item ids.
enum FacingProfile { LEFT, RIGHT, FORWARD }
const JOINT_HIT_RADIUS_PX: float = 14.0 # screen-space grab radius for bend joints const JOINT_HIT_RADIUS_PX: float = 14.0 # screen-space grab radius for bend joints
## Bend joints (upper↔lower limb connectors) whose TwoBoneIK "Flip Bend ## Harness-tracked playback state (the harness is the sole driver of the
## Direction" flag is user-controllable. Keyed by joint name; values are the ## AnimationPlayer, so it tracks state authoritatively via button handlers and
## lower bone's NodePath relative to the Skeleton2D. ## the animation_finished signal rather than polling is_playing()).
const BEND_JOINTS: Array[String] = ["LeftArm", "RightArm", "LeftLeg", "RightLeg"] enum PlaybackState { STOPPED, PLAYING, PAUSED }
const BEND_JOINT_BONE_PATHS: Dictionary = {
"LeftArm": "Torso/LeftUpperArm/LeftLowerArm",
"RightArm": "Torso/RightUpperArm/RightLowerArm",
"LeftLeg": "Torso/LeftUpperLeg/LeftLowerLeg",
"RightLeg": "Torso/RightUpperLeg/RightLowerLeg",
}
## flip_bend_direction value per facing profile, keyed by bend-joint name.
const PROFILE_FLAGS: Dictionary = {
FacingProfile.LEFT: { "LeftArm": false, "RightArm": false, "LeftLeg": true, "RightLeg": true },
FacingProfile.RIGHT: { "LeftArm": true, "RightArm": true, "LeftLeg": false, "RightLeg": false },
FacingProfile.FORWARD: { "LeftArm": false, "RightArm": true, "LeftLeg": true, "RightLeg": false },
}
## Body/* visual part node names in draw order (back-to-front) per facing
## profile (Phase 9 / Task 2). First entry draws backmost, last frontmost.
## Upper limbs stay behind lower limbs; on the far (behind-torso) side the arm
## pair draws behind the leg pair, on the near (in-front) side the arm pair
## draws in front of the leg pair; the head is always frontmost.
const Z_ORDER_BY_PROFILE: Dictionary = {
FacingProfile.FORWARD: [
"Body",
"LeftUpperLeg", "RightUpperLeg",
"LeftLowerLeg", "RightLowerLeg",
"LeftUpperArm", "RightUpperArm",
"LeftLowerArm", "RightLowerArm",
"Head",
],
FacingProfile.LEFT: [
"LeftUpperArm", "LeftLowerArm",
"LeftUpperLeg", "LeftLowerLeg",
"Body",
"RightUpperLeg", "RightLowerLeg",
"RightUpperArm", "RightLowerArm",
"Head",
],
FacingProfile.RIGHT: [
"RightUpperArm", "RightLowerArm",
"RightUpperLeg", "RightLowerLeg",
"Body",
"LeftUpperLeg", "LeftLowerLeg",
"LeftUpperArm", "LeftLowerArm",
"Head",
],
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Runtime-built node references # Runtime-built node references
@@ -151,14 +105,18 @@ var _status_label: Label
var _file_dialog: FileDialog var _file_dialog: FileDialog
var _coords_panel: PanelContainer var _coords_panel: PanelContainer
var _coords_label: RichTextLabel var _coords_label: RichTextLabel
var _anim_dropdown: OptionButton
var _play_button: Button
var _stop_button: Button
var _loop_check: CheckBox
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# State # State
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
var _rig: Node2D = null var _rig: Node2D = null
var _rig_script: StickmanRig = null
var _skeleton: Skeleton2D = null var _skeleton: Skeleton2D = null
var _body_container: Node2D = null
var _ik_handles: Dictionary = {} # { String : Marker2D } var _ik_handles: Dictionary = {} # { String : Marker2D }
var _coord_bones: Dictionary = {} # { String : Bone2D } var _coord_bones: Dictionary = {} # { String : Bone2D }
@@ -170,15 +128,20 @@ var _is_panning: bool = false
var _pan_last: Vector2 = Vector2.ZERO var _pan_last: Vector2 = Vector2.ZERO
var _dragging_handle: Marker2D = null var _dragging_handle: Marker2D = null
# Task 1: IK bend-direction state (survives respawn; matches authored defaults). # UI mirror of the last-selected facing profile (persists across respawns; the
var _facing_profile: int = FacingProfile.FORWARD # rig's StickmanRig script owns the actual flags/z-order). Used for the [√] menu
# prefix and re-application after each spawn.
var _facing_profile: int = StickmanRig.FacingProfile.FORWARD
var _context_joint: String = "" var _context_joint: String = ""
var _bend_joint_bones: Dictionary = {} # { String : Bone2D }
var _bend_modifications: Dictionary = {} # { String : SkeletonModification2DTwoBoneIK }
var _facing_button: MenuButton var _facing_button: MenuButton
var _facing_menu: PopupMenu var _facing_menu: PopupMenu
var _context_menu: PopupMenu var _context_menu: PopupMenu
var _anim_player: AnimationPlayer = null
var _selected_animation: String = ""
var _playback_state: int = PlaybackState.STOPPED
var _loop: bool = true # harness-level, persists across respawns (like _show_coords)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Lifecycle # Lifecycle
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -222,13 +185,33 @@ func _build_ui() -> void:
_facing_button = MenuButton.new() _facing_button = MenuButton.new()
_facing_button.text = "Facing" _facing_button.text = "Facing"
_facing_menu = _facing_button.get_popup() _facing_menu = _facing_button.get_popup()
_facing_menu.add_item(_facing_menu_label(FacingProfile.LEFT), FacingProfile.LEFT) _facing_menu.add_item(_facing_menu_label(StickmanRig.FacingProfile.LEFT), StickmanRig.FacingProfile.LEFT)
_facing_menu.add_item(_facing_menu_label(FacingProfile.RIGHT), FacingProfile.RIGHT) _facing_menu.add_item(_facing_menu_label(StickmanRig.FacingProfile.RIGHT), StickmanRig.FacingProfile.RIGHT)
_facing_menu.add_item(_facing_menu_label(FacingProfile.FORWARD), FacingProfile.FORWARD) _facing_menu.add_item(_facing_menu_label(StickmanRig.FacingProfile.FORWARD), StickmanRig.FacingProfile.FORWARD)
_facing_menu.id_pressed.connect(_on_facing_menu_id_pressed) _facing_menu.id_pressed.connect(_on_facing_menu_id_pressed)
_facing_menu.about_to_popup.connect(_update_facing_menu_labels) _facing_menu.about_to_popup.connect(_update_facing_menu_labels)
hbox.add_child(_facing_button) hbox.add_child(_facing_button)
_anim_dropdown = OptionButton.new()
_anim_dropdown.item_selected.connect(_on_anim_dropdown_selected)
hbox.add_child(_anim_dropdown)
_play_button = Button.new()
_play_button.text = "Play"
_play_button.pressed.connect(_on_play_pressed)
hbox.add_child(_play_button)
_stop_button = Button.new()
_stop_button.text = "Stop"
_stop_button.pressed.connect(_on_stop_pressed)
hbox.add_child(_stop_button)
_loop_check = CheckBox.new()
_loop_check.text = "Loop"
_loop_check.button_pressed = true
_loop_check.toggled.connect(_on_loop_toggled)
hbox.add_child(_loop_check)
var open_btn := Button.new() var open_btn := Button.new()
open_btn.text = "Open .stk…" open_btn.text = "Open .stk…"
open_btn.pressed.connect(_on_open_pressed) open_btn.pressed.connect(_on_open_pressed)
@@ -266,6 +249,10 @@ func _build_ui() -> void:
_status_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS _status_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
hbox.add_child(_status_label) hbox.add_child(_status_label)
# Rig-behavior controls (Facing + animation) are hidden until an .stk is
# loaded — they are meaningless without a spawned rig.
_set_rig_controls_visible(false)
# Viewport area (fills everything below the top bar). # Viewport area (fills everything below the top bar).
_viewport_container = SubViewportContainer.new() _viewport_container = SubViewportContainer.new()
_viewport_container.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) _viewport_container.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
@@ -360,6 +347,12 @@ func _on_viewport_container_resized() -> void:
return return
_viewport.size = Vector2i(size) _viewport.size = Vector2i(size)
func _set_rig_controls_visible(visible: bool) -> void:
for control: Control in [_facing_button, _anim_dropdown, _play_button, _stop_button, _loop_check]:
if control != null:
control.visible = visible
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Input handling # Input handling
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -408,14 +401,13 @@ func _handle_right_click(screen_pos: Vector2) -> void:
func _hit_test_bend_joint(world_pos: Vector2) -> String: func _hit_test_bend_joint(world_pos: Vector2) -> String:
if _rig_script == null or not is_instance_valid(_rig_script):
return ""
var hit_radius := JOINT_HIT_RADIUS_PX / _camera.zoom.x var hit_radius := JOINT_HIT_RADIUS_PX / _camera.zoom.x
var best_joint := "" var best_joint := ""
var best_dist := hit_radius var best_dist := hit_radius
for joint: String in BEND_JOINTS: for joint: String in _rig_script.get_bend_joints():
var bone: Bone2D = _bend_joint_bones.get(joint) as Bone2D var dist := world_pos.distance_to(_rig_script.get_bend_joint_global_position(joint))
if bone == null or not is_instance_valid(bone):
continue
var dist := world_pos.distance_to(bone.global_position)
if dist <= best_dist: if dist <= best_dist:
best_joint = joint best_joint = joint
best_dist = dist best_dist = dist
@@ -574,58 +566,47 @@ func _bone_color(bone_name: String) -> Color:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func _facing_menu_label(profile: int) -> String: func _facing_menu_label(profile: int) -> String:
return ("[√] " if profile == _facing_profile else "") + FacingProfile.keys()[profile].capitalize() return ("[√] " if profile == _facing_profile else "") + StickmanRig.FacingProfile.keys()[profile].capitalize()
func _update_facing_menu_labels() -> void: func _update_facing_menu_labels() -> void:
if _facing_menu == null: if _facing_menu == null:
return return
for profile: int in [FacingProfile.LEFT, FacingProfile.RIGHT, FacingProfile.FORWARD]: for profile: int in [StickmanRig.FacingProfile.LEFT, StickmanRig.FacingProfile.RIGHT, StickmanRig.FacingProfile.FORWARD]:
var idx := _facing_menu.get_item_index(profile) var idx := _facing_menu.get_item_index(profile)
if idx >= 0: if idx >= 0:
_facing_menu.set_item_text(idx, _facing_menu_label(profile)) _facing_menu.set_item_text(idx, _facing_menu_label(profile))
func _on_facing_menu_id_pressed(id: int) -> void: func _on_facing_menu_id_pressed(id: int) -> void:
_apply_facing_profile(id) _facing_profile = id
if _rig_script != null and is_instance_valid(_rig_script):
_rig_script.set_facing_profile(id)
_update_facing_menu_labels() _update_facing_menu_labels()
func _apply_facing_profile(profile: int) -> void:
_facing_profile = profile
for joint: String in BEND_JOINTS:
var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK
if mod == null:
continue
mod.flip_bend_direction = bool(PROFILE_FLAGS[profile][joint])
_apply_body_z_order()
_debug_overlay.queue_redraw()
func _apply_body_z_order() -> void:
if _body_container == null or not is_instance_valid(_body_container):
return
var order: Array = Z_ORDER_BY_PROFILE.get(_facing_profile, Z_ORDER_BY_PROFILE[FacingProfile.FORWARD])
for part_name: String in order:
var part := _body_container.get_node_or_null(NodePath(part_name))
if part != null:
_body_container.move_child(part, _body_container.get_child_count() - 1)
func _context_menu_label(joint: String) -> String: func _context_menu_label(joint: String) -> String:
var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK if _rig_script == null or not is_instance_valid(_rig_script):
if mod == null:
return "Invert Bend" return "Invert Bend"
return "Normal Bend" if mod.flip_bend_direction else "Invert Bend" return "Normal Bend" if _rig_script.get_joint_bend_flipped(joint) else "Invert Bend"
func _on_context_menu_id_pressed(_id: int) -> void: func _on_context_menu_id_pressed(_id: int) -> void:
if _context_joint.is_empty(): if _context_joint.is_empty():
return return
var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(_context_joint) as SkeletonModification2DTwoBoneIK if _rig_script == null or not is_instance_valid(_rig_script):
if mod == null:
return return
mod.flip_bend_direction = not mod.flip_bend_direction _rig_script.set_joint_bend_flipped(_context_joint, not _rig_script.get_joint_bend_flipped(_context_joint))
_debug_overlay.queue_redraw()
func _on_facing_profile_changed(_profile: int) -> void:
_facing_profile = _rig_script.get_facing_profile() if _rig_script != null else _facing_profile
_update_facing_menu_labels()
_debug_overlay.queue_redraw()
func _on_bend_flag_changed(_joint: String, _flipped: bool) -> void:
_debug_overlay.queue_redraw() _debug_overlay.queue_redraw()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -653,14 +634,27 @@ func _load_and_spawn(path: String) -> void:
return return
_rig = rig _rig = rig
_rig_script = rig as StickmanRig
# Capture the user's last-selected profile before add_child: the rig's
# _ready() emits facing_profile_changed(FORWARD), which routes through
# _on_facing_profile_changed and would otherwise clobber the remembered
# _facing_profile mirror before it is re-applied below.
var remembered_profile := _facing_profile
if _rig_script == null:
push_warning("TestHarness: spawned rig is missing the StickmanRig script; facing/bend controls disabled.")
else:
_rig_script.facing_profile_changed.connect(_on_facing_profile_changed)
_rig_script.bend_flag_changed.connect(_on_bend_flag_changed)
_world.add_child(_rig) _world.add_child(_rig)
_world.move_child(_rig, 0) # keep the rig behind the debug overlay _world.move_child(_rig, 0) # keep the rig behind the debug overlay
_resolve_rig_nodes() _resolve_rig_nodes()
_ensure_modification_stack_enabled() if _rig_script != null and is_instance_valid(_rig_script):
_apply_facing_profile(_facing_profile) _rig_script.set_facing_profile(remembered_profile)
_status_label.text = path.get_file() _status_label.text = path.get_file()
_set_rig_controls_visible(true)
_recenter_camera() _recenter_camera()
_debug_overlay.queue_redraw() _debug_overlay.queue_redraw()
@@ -669,14 +663,18 @@ func _free_current_rig() -> void:
if _rig != null and is_instance_valid(_rig): if _rig != null and is_instance_valid(_rig):
_rig.queue_free() _rig.queue_free()
_rig = null _rig = null
_rig_script = null
_skeleton = null _skeleton = null
_body_container = null
_ik_handles.clear() _ik_handles.clear()
_bend_joint_bones.clear()
_bend_modifications.clear()
_coord_bones.clear() _coord_bones.clear()
_context_joint = "" _context_joint = ""
_dragging_handle = null _dragging_handle = null
_anim_player = null
_anim_dropdown.clear()
_selected_animation = ""
_playback_state = PlaybackState.STOPPED
_update_play_button()
_set_rig_controls_visible(false)
func _resolve_rig_nodes() -> void: func _resolve_rig_nodes() -> void:
@@ -684,10 +682,6 @@ func _resolve_rig_nodes() -> void:
if _skeleton == null: if _skeleton == null:
push_warning("TestHarness: missing '%s' node in rig." % SKELETON_PATH) push_warning("TestHarness: missing '%s' node in rig." % SKELETON_PATH)
_body_container = _rig.get_node_or_null(NodePath(BODY_CONTAINER_PATH)) as Node2D
if _body_container == null:
push_warning("TestHarness: missing '%s' node in rig." % BODY_CONTAINER_PATH)
_ik_handles.clear() _ik_handles.clear()
for handle_name: String in IK_HANDLE_PATHS: for handle_name: String in IK_HANDLE_PATHS:
var handle := _rig.get_node_or_null(NodePath(IK_HANDLE_PATHS[handle_name])) as Marker2D var handle := _rig.get_node_or_null(NodePath(IK_HANDLE_PATHS[handle_name])) as Marker2D
@@ -696,40 +690,8 @@ func _resolve_rig_nodes() -> void:
else: else:
push_warning("TestHarness: missing IK handle '%s'." % IK_HANDLE_PATHS[handle_name]) push_warning("TestHarness: missing IK handle '%s'." % IK_HANDLE_PATHS[handle_name])
_resolve_bend_joints()
_resolve_coord_bones() _resolve_coord_bones()
_resolve_anim_player()
func _resolve_bend_joints() -> void:
_bend_joint_bones.clear()
_bend_modifications.clear()
if _skeleton == null:
return
for joint: String in BEND_JOINTS:
var path: String = BEND_JOINT_BONE_PATHS[joint]
var bone := _skeleton.get_node_or_null(NodePath(path)) as Bone2D
if bone != null and is_instance_valid(bone):
_bend_joint_bones[joint] = bone
else:
push_warning("TestHarness: missing bend-joint bone '%s'." % path)
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
if stack == null:
return
for i: int in stack.modification_count:
var mod := stack.get_modification(i)
if not (mod is SkeletonModification2DTwoBoneIK):
continue
var ik := mod as SkeletonModification2DTwoBoneIK
for joint: String in BEND_JOINTS:
if ik.joint_two_bone2d_node == NodePath(BEND_JOINT_BONE_PATHS[joint]):
_bend_modifications[joint] = ik
break
for joint: String in BEND_JOINTS:
if not _bend_modifications.has(joint):
push_warning("TestHarness: missing TwoBoneIK modification for bend joint '%s'." % joint)
func _resolve_coord_bones() -> void: func _resolve_coord_bones() -> void:
@@ -744,14 +706,32 @@ func _resolve_coord_bones() -> void:
push_warning("TestHarness: missing coordinate bone '%s'." % path) push_warning("TestHarness: missing coordinate bone '%s'." % path)
func _ensure_modification_stack_enabled() -> void: func _resolve_anim_player() -> void:
if _skeleton == null: _anim_player = _rig.get_node_or_null(NodePath(ANIMATION_PLAYER_PATH)) as AnimationPlayer
_populate_animation_dropdown()
if _anim_player == null:
push_warning("TestHarness: missing '%s' node in rig." % ANIMATION_PLAYER_PATH)
return return
var stack: SkeletonModificationStack2D = _skeleton.modification_stack _anim_player.animation_finished.connect(_on_animation_finished)
if stack != null:
stack.enabled = true
else: func _populate_animation_dropdown() -> void:
push_warning("TestHarness: Skeleton2D has no modification_stack assigned.") _anim_dropdown.clear()
_selected_animation = ""
_playback_state = PlaybackState.STOPPED
_update_play_button()
if _anim_player == null:
return
var preferred_idx := 0
var anim_list: PackedStringArray = _anim_player.get_animation_list()
for i: int in anim_list.size():
var anim_name: String = anim_list[i]
_anim_dropdown.add_item(anim_name)
if anim_name == DEFAULT_ANIMATION:
preferred_idx = i
if _anim_dropdown.item_count > 0:
_anim_dropdown.select(preferred_idx)
_selected_animation = _anim_dropdown.get_item_text(preferred_idx)
func _recenter_camera() -> void: func _recenter_camera() -> void:
@@ -763,6 +743,79 @@ func _recenter_camera() -> void:
_camera.zoom = Vector2.ONE _camera.zoom = Vector2.ONE
_debug_overlay.queue_redraw() _debug_overlay.queue_redraw()
# ---------------------------------------------------------------------------
# Animation playback
# ---------------------------------------------------------------------------
func _on_anim_dropdown_selected(index: int) -> void:
_selected_animation = _anim_dropdown.get_item_text(index)
# Changing selection stops any in-progress playback (Play restarts it).
if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop()
_playback_state = PlaybackState.STOPPED
_update_play_button()
func _on_play_pressed() -> void:
if _anim_player == null or not is_instance_valid(_anim_player):
return
if _selected_animation.is_empty():
return
match _playback_state:
PlaybackState.STOPPED:
_apply_loop_mode()
_anim_player.play(_selected_animation) # restart from position 0
_playback_state = PlaybackState.PLAYING
PlaybackState.PLAYING:
_anim_player.pause()
_playback_state = PlaybackState.PAUSED
PlaybackState.PAUSED:
_anim_player.play() # resume the assigned (paused) animation
_playback_state = PlaybackState.PLAYING
_update_play_button()
func _on_stop_pressed() -> void:
if _anim_player == null or not is_instance_valid(_anim_player):
return
_anim_player.stop() # resets position to 0 and stops
_playback_state = PlaybackState.STOPPED
_update_play_button()
func _on_loop_toggled(pressed: bool) -> void:
_loop = pressed
_apply_loop_mode()
func _on_animation_finished(_anim_name: StringName) -> void:
if _loop:
return # looping: never treat a wrap as "finished"
_playback_state = PlaybackState.STOPPED
_update_play_button()
func _apply_loop_mode() -> void:
if _anim_player == null or not is_instance_valid(_anim_player):
return
if _selected_animation.is_empty():
return
var anim: Animation = _anim_player.get_animation(_selected_animation)
if anim != null:
anim.loop_mode = Animation.LOOP_LINEAR if _loop else Animation.LOOP_NONE
func _update_play_button() -> void:
if _play_button == null:
return
match _playback_state:
PlaybackState.STOPPED:
_play_button.text = "Play"
PlaybackState.PLAYING:
_play_button.text = "Pause"
PlaybackState.PAUSED:
_play_button.text = "Resume"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Checkbox handlers # Checkbox handlers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------