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.
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.
- `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
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:
- `static func load_stk(path: String) -> Dictionary` — reads a `.stk` file (`FileAccess` +
`JSON.parse_string`); returns `{}` + `push_warning` on failure.
- `static func spawn_from_data(stk_data: Dictionary) -> Node2D` — instantiates
`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()`;
- `static func spawn_from_data(stk_data: Dictionary) -> StickmanRig` — instantiates
`res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, returns the rig root
(typed as `StickmanRig` since the rig now carries the `StickmanRig` root script).
- `static func spawn(path: String) -> StickmanRig` — `load_stk()` then `spawn_from_data()`;
returns `null` on empty data.
- `scripts/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
@@ -241,8 +265,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
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: click-drag the `Marker2D` IK targets; the scene's `SkeletonModificationStack2D` TwoBoneIK
flexes limbs live. The harness enables the modification stack (`enabled = true`) after each
spawn.
flexes limbs live (the rig self-enables its modification stack in `_ready()`).
- **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
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.
- **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
Bend Direction" flags. `enum FacingProfile { LEFT, RIGHT, FORWARD }` (values used directly as
menu item ids); `BEND_JOINTS: Array[String] = ["LeftArm","RightArm","LeftLeg","RightLeg"]`
with `BEND_JOINT_BONE_PATHS` (each joint → its lower `Bone2D` NodePath relative to
`Skeleton2D`, e.g. `Torso/LeftUpperArm/LeftLowerArm`); `PROFILE_FLAGS` maps each profile to a
per-joint `flip_bend_direction` set (LEFT: arms off / legs on; RIGHT: arms on / legs off;
FORWARD: LeftArm off, RightArm on, LeftLeg on, RightLeg off — matching `master_rig.tscn`'s
authored defaults). State `_facing_profile` (default `FORWARD`, harness-level, persists across
spawns), `_context_joint`, `_bend_joint_bones`, `_bend_modifications`, `_facing_button`/
`_facing_menu`, `_context_menu`. The Facing popup (Left/Right/Forward) uses dynamic text-only
labels with the current profile prefixed `[√] `, refreshed on `about_to_popup` and after
selection (mirroring the editor's snap-menu pattern). Runtime resolution:
`_resolve_bend_joints()` (called from `_resolve_rig_nodes()`) resolves the 4 lower-limb
`Bone2D`s via `get_node_or_null` and matches each `SkeletonModification2DTwoBoneIK` in the
modification stack by its `joint_two_bone2d_node` NodePath (no hardcoded stack index);
`push_warning` on missing nodes/mods. Per-joint toggle: right-click inside the viewport on an
elbow/knee (the upper↔lower limb connector, within `JOINT_HIT_RADIUS_PX := 14.0` screen px
converted to world by `_camera.zoom.x`, nearest joint wins) pops a one-item context menu
labeled **"Normal Bend"** (when `flip_bend_direction` is currently true) or **"Invert Bend"**
(when false); selecting toggles that joint's `flip_bend_direction` on the live TwoBoneIK
modification. Only the 4 elbows/knees are right-click targets — shoulders/hips/wrists/ankles/
head/torso are not. Lifecycle: `_free_current_rig()` clears `_bend_joint_bones`/
`_bend_modifications`/`_context_joint`; `_load_and_spawn()` re-applies
`_apply_facing_profile(_facing_profile)` right after `_ensure_modification_stack_enabled()` so
every fresh spawn matches the current profile. No persistence to disk.
- **Phase 9 Task 2 body-part z-order:** `_apply_facing_profile()` now also sets the
`_facing_profile` state itself (previously only the menu handler did) and calls
`_apply_body_z_order()`, so bend flags + draw order stay in sync from one entry point.
`const BODY_CONTAINER_PATH := "Body"` and `const Z_ORDER_BY_PROFILE: Dictionary` map each
`FacingProfile` to the rig `Body/*` visual part node names in **back-to-front draw order**
(Godot 4 `Node2D` draws siblings in tree order; all parts keep `z_index = 0`). FORWARD:
torso → left/right upper legs → left/right lower legs → left/right upper arms → left/right
lower arms → head (all limbs in front of the torso); LEFT: left arm pair then left leg
pair **behind** the torso, right leg pair then right arm pair in front; RIGHT: mirrored.
In every profile upper limbs stay behind lower limbs; far-side (behind-torso) arms draw
behind the legs while near-side arms draw in front of the legs; the **head is always
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:
Bend Direction" flags. The facing profile, the per-joint bend flags, and the `Body/*` z-order
tables now **live in the `StickmanRig` script** (Phase 9 Task 4) — the harness drives the rig
via `_rig_script: StickmanRig` (typed root from `StickmanFactory.spawn()`; signals connected
**before** `add_child`) and keeps `_facing_profile` (default `FORWARD`) only as a **UI mirror**
for the `[√] ` menu prefix + respawn re-application; the rig's exported `facing_profile` is the
authority. Menu item ids are `StickmanRig.FacingProfile.LEFT/RIGHT/FORWARD` (values used
directly as menu item ids). State `_context_joint`, `_facing_button`/`_facing_menu`,
`_context_menu`. The Facing popup (Left/Right/Forward) uses dynamic text-only labels with the
current profile prefixed `[√] `, refreshed on `about_to_popup` and after selection (mirroring
the editor's snap-menu pattern); selection calls `_rig_script.set_facing_profile(id)`. Hit-test
for the right-click toggle iterates `_rig_script.get_bend_joints()` with positions from
`_rig_script.get_bend_joint_global_position(joint)`. Per-joint toggle: right-click inside the
viewport on an elbow/knee (the upper↔lower limb connector, within `JOINT_HIT_RADIUS_PX := 14.0`
screen px converted to world by `_camera.zoom.x`, nearest joint wins) pops a one-item context
menu labeled **"Normal Bend"** (when the rig's `get_joint_bend_flipped(joint)` is true) or
**"Invert Bend"** (when false); selecting calls
`_rig_script.set_joint_bend_flipped(_context_joint, not _rig_script.get_joint_bend_flipped(_context_joint))`.
Only the 4 elbows/knees are right-click targets — shoulders/hips/wrists/ankles/head/torso are
not. Lifecycle: `_free_current_rig()` clears `_rig_script` (and `_context_joint`);
`_load_and_spawn()` captures the remembered profile before `add_child` (so the rig's `_ready()`
`facing_profile_changed(FORWARD)` doesn't clobber the mirror) then re-applies
`_rig_script.set_facing_profile(remembered_profile)` after `_resolve_rig_nodes()`. The rig
enables its own modification stack in `_ready()`. No persistence to disk.
- **Phase 9 Task 2 body-part z-order:** the rig's `_apply_profile()` reorders the rig's `Body/*`
visual part nodes (tree order = draw order) from the Facing profile; `Z_ORDER_BY_PROFILE` (now
owned by `StickmanRig`) maps each `FacingProfile` to the `Body/*` part node names in
**back-to-front draw order** (Godot 4 `Node2D` draws siblings in tree order; all parts keep
`z_index = 0`). FORWARD: torso → left/right upper legs → left/right lower legs → left/right
upper arms → left/right lower arms → head (all limbs in front of the torso); LEFT: left arm
pair then left leg pair **behind** the torso, right leg pair then right arm pair in front;
RIGHT: mirrored. In every profile upper limbs stay behind lower limbs; far-side
(behind-torso) arms draw behind the legs while near-side arms draw in front of the legs; the
**head is always frontmost**. The rig's `_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
persistence to disk.
- **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
world-space (`global_position`/`global_rotation`), rotation in degrees via
`_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/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used
for typed `@onready` access: `%MenuBar`, `%StickmanNameEdit`, `%LeftColumn`,