From 07bef667030e4dcf6e3c3df48980c8ca54702670 Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Mon, 24 Aug 2026 23:41:23 -0400 Subject: [PATCH] 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. --- AGENTS.md | 127 +++++--- README.md | 14 +- RIGGING.md | 16 + docs/phase9_task4_refactor_spec.md | 471 ++++++++++++++++++++++++++++ docs/phase9_task5_animation_spec.md | 398 +++++++++++++++++++++++ master_rig.tscn | 205 +++++++++--- scenes/test_harness.tscn | 6 +- scripts/create_walk.gd | 81 ++--- scripts/stickman_factory.gd | 6 +- scripts/stickman_rig.gd | 304 ++++++++++++++++++ scripts/stickman_rig.gd.uid | 1 + scripts/test_harness.gd | 349 ++++++++++++--------- 12 files changed, 1695 insertions(+), 283 deletions(-) create mode 100644 docs/phase9_task4_refactor_spec.md create mode 100644 docs/phase9_task5_animation_spec.md create mode 100644 scripts/stickman_rig.gd create mode 100644 scripts/stickman_rig.gd.uid diff --git a/AGENTS.md b/AGENTS.md index 176ab38..693de53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`, diff --git a/README.md b/README.md index 78af411..710efa6 100644 --- a/README.md +++ b/README.md @@ -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` | `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: @@ -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. - **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. -- **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. -- **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). +- **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 **`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). -- **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. @@ -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://scripts/stickman_editor.gd` | Editor controller — File/Edit/View menu actions, save/load/clear, JSON v1.5 serialization with multi-shape/rotation/scale, `part_order`, Phase 8 `proportions`/`pivot`/`length`, and Phase 9 Round 5 per-part `guide_offset` export, `settings.json` load/save, editor-wide shape clipboard (Copy/Paste across panels), broadcast of grid/snap settings to panels, Reset Views, populates panels, coordinates selection across panels. | | `res://scripts/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9 (Rounds 4–6 bugfix).** Standalone runtime adapter (`class_name StkRigAdapter`, `static func apply(stk_data, rig)`): fits an instantiated `master_rig.tscn` to a loaded `.stk` by re-fitting the 8 limb bones (`Skeleton2D/Torso/...` `Bone2D` lengths + lower-bone origins), recalibrating the IK targets (`IK_Targets/Left|Right_Hand`, `Left|Right_Leg`), and mounting the `.stk` shapes onto the `Body/*` visual nodes (**one node per shape**: closed → single `Polygon2D` fill, open → single `Line2D` width 2). Shape mounting recomputes each part's bounding box at mount time (file `pivot`/`length` are no longer trusted) and derives a mount transform in the rig's **hanging convention** (joint anchor at the local origin, far end along local `+Y`) via `_compute_mount_transform()`: the part's preview transform `E(P) = C + R(rot)·S·(P − C)` (rotation + scale about the bbox center — the editor's exact Whole-Stickman-preview transform) is composed **first**, then the anchor/alignment θ/bone-fit scale are computed on the **transformed geometry**; rotations near ±180° (`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint). Anchors (raw family rules): head/torso bottom-center `(cx, max_y)`, left horizontal limbs `(max_x, cy)`, right horizontal limbs `(min_x, cy)`, vertically drawn limbs top-center `(cx, min_y)`; alignment rotation θ maps the far end onto `+Y`; scaling is **anisotropic** — only the **auto-detected drawn long axis** (`width >= height`) scales to the bone length (`bone_length/extent`, guard `extent <= 0.0001` → `1.0`), cross-axis thickness stays 1:1. The `RemoteTransform2D` drivers keep `update_rotation = true`, so mounted shapes follow their bones under IK flexing. (Phase 9 Round 5) when a part dict carries `guide_offset`, the mounted geometry is translated by `t = (guide_offset + (A − C)).rotated(−c_node)`; (Phase 9 Round 6) when `guide_offset` is present, the joint anchor is whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is nearest the part's guide joint (`center − guide_offset`), replacing the per-side family choice + 180° flip heuristic for that case (fixing the lower-left-leg and lower-right-arm, which were mounted 180° off their bones) — old files without the key keep the family rules + flip heuristic as the fallback in the driver's bone frame (A = mount anchor incl. the 180° flip rule, C = raw bbox center, `c_node` = driver `RemoteTransform2D.global_rotation`), so the harness reproduces the editor's guide-relative placement 1:1; old files without the key keep the offset-0 behavior (head falls back to `HEAD_CHIN_DROP`). Each `Body/*` container's scale is reset to `(1,1)` / rotation `0` (position untouched). (Phase 9) also fits the head bone (`Head.position.y = -proportions.torso_length`) while mounting the head as **full geometry** — it clears the head's inline `@tool` circle script and mounts `.stk` head shapes as `Line2D`/`Polygon2D`, and zeroes the Head driver's local position so the chin sits on the neck joint; the head mounts upright (`θ = 0`, `s = 1`) but still applies the part scale via `E` (face ≈160 px). **Not used by the editor** — consumed by the runtime pipeline. | -| `res://scripts/stickman_factory.gd` | **Phase 9.** Runtime entry point (`class_name StickmanFactory`, `extends RefCounted`); a static factory that turns a `.stk` file into a live, rigged `master_rig.tscn` instance. `load_stk(path)` reads + parses the file (`{}` + `push_warning` on failure); `spawn_from_data(stk_data)` instantiates `res://master_rig.tscn` and calls `StkRigAdapter.apply(stk_data, rig)`; `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_factory.gd` | **Phase 9.** Runtime entry point (`class_name StickmanFactory`, `extends RefCounted`); a static factory that turns a `.stk` file into a live, rigged `master_rig.tscn` instance. `load_stk(path)` reads + parses the file (`{}` + `push_warning` on failure); `spawn_from_data(stk_data)` instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, and returns the rig root **typed as `StickmanRig`** (the rig now carries the `StickmanRig` root script); `spawn(path)` chains them (`null` on empty data). **Not used by the editor.** | +| `res://scripts/stickman_rig.gd` | **Phase 9 Task 4.** `class_name StickmanRig`, `extends Node2D`; the runtime owner of facing direction, per-joint bone bend, 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://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`. | diff --git a/RIGGING.md b/RIGGING.md index ec8fbc4..be0376c 100644 --- a/RIGGING.md +++ b/RIGGING.md @@ -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 +**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. 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. diff --git a/docs/phase9_task4_refactor_spec.md b/docs/phase9_task4_refactor_spec.md new file mode 100644 index 0000000..688790c --- /dev/null +++ b/docs/phase9_task4_refactor_spec.md @@ -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 (§3c–3f). | +| `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`. diff --git a/docs/phase9_task5_animation_spec.md b/docs/phase9_task5_animation_spec.md new file mode 100644 index 0000000..a0f7982 --- /dev/null +++ b/docs/phase9_task5_animation_spec.md @@ -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 1–2) + manual F6 (§8 item 3). +3. Docs: `AGENTS.md`, `README.md`, `RIGGING.md`, this spec. diff --git a/master_rig.tscn b/master_rig.tscn index 3681727..8c0417b 100644 --- a/master_rig.tscn +++ b/master_rig.tscn @@ -1,5 +1,7 @@ [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"] script/source = "@tool extends Node2D @@ -28,7 +30,6 @@ joint_two_bone2d_node = NodePath("Torso/RightUpperArm/RightLowerArm") [sub_resource type="SkeletonModification2DTwoBoneIK" id="SkeletonModification2DTwoBoneIK_f0s26"] target_nodepath = NodePath("../IK_Targets/Left_Hand") -flip_bend_direction = true joint_one_bone_idx = 2 joint_one_bone2d_node = NodePath("Torso/LeftUpperArm") joint_two_bone_idx = 3 @@ -43,6 +44,7 @@ joint_two_bone2d_node = NodePath("Torso/RightUpperLeg/RightLowerLeg") [sub_resource type="SkeletonModification2DTwoBoneIK" id="SkeletonModification2DTwoBoneIK_2leu7"] target_nodepath = NodePath("../IK_Targets/Left_Leg") +flip_bend_direction = true joint_one_bone_idx = 6 joint_one_bone2d_node = NodePath("Torso/LeftUpperLeg") joint_two_bone_idx = 7 @@ -59,6 +61,7 @@ constraint_angle_invert = true constraint_in_localspace = true [sub_resource type="SkeletonModificationStack2D" id="SkeletonModificationStack2D_j4hao"] +enabled = true modification_count = 5 modifications/0 = SubResource("SkeletonModification2DTwoBoneIK_yvxej") modifications/1 = SubResource("SkeletonModification2DTwoBoneIK_f0s26") @@ -68,6 +71,18 @@ modifications/4 = SubResource("SkeletonModification2DLookAt_j4hao") [sub_resource type="Animation" id="Animation_2leu7"] 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"] length = 0.8 @@ -75,67 +90,67 @@ loop_mode = 1 tracks/0/type = "value" tracks/0/imported = false tracks/0/enabled = true -tracks/0/path = NodePath("IK_Targets/Torso:position") -tracks/0/interp = 2 +tracks/0/path = NodePath(".:facing_profile") +tracks/0/interp = 1 tracks/0/loop_wrap = true tracks/0/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)] +"times": PackedFloat32Array(0), +"transitions": PackedFloat32Array(1), +"update": 1, +"values": [0] } tracks/1/type = "value" tracks/1/imported = false 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/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(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/imported = false 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/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(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/imported = false 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/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, 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/imported = false 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/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(-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/imported = false 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/loop_wrap = true tracks/5/keys = { @@ -144,25 +159,121 @@ tracks/5/keys = { "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="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"] _data = { &"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"] [node name="Master" type="Node2D" unique_id=319103476] +script = ExtResource("1_rig_script") [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] position = Vector2(9.313226e-10, 10.000001) rotation = -3.1415925 @@ -171,64 +282,71 @@ width = 16.0 default_color = Color(0.445488, 0.445488, 0.445488, 1) [node name="LeftUpperLeg" type="Line2D" parent="Body" unique_id=199373156] -position = Vector2(1.6098846e-05, 10.000002) -rotation = 0.4947604 +position = Vector2(1.6037084e-05, 10.000003) +rotation = 0.55427897 points = PackedVector2Array(0, 0, 0, 200) width = 16.0 default_color = Color(0.445488, 0.445488, 0.445488, 1) [node name="RightUpperLeg" type="Line2D" parent="Body" unique_id=1396556308] -position = Vector2(-2.3252098e-05, 10.000022) -rotation = -0.49392816 +position = Vector2(-2.4553774e-05, 10.000021) +rotation = -0.43021643 scale = Vector2(0.99999994, 0.99999994) points = PackedVector2Array(0, 0, 0, 200) width = 16.0 default_color = Color(0.445488, 0.445488, 0.445488, 1) [node name="LeftLowerLeg" type="Line2D" parent="Body" unique_id=1590485904] -position = Vector2(-94.96416, 186.0165) -rotation = 0.0051915743 +position = Vector2(-105.26607, 180.05603) +rotation = 0.023672067 scale = Vector2(0.99999994, 0.99999994) points = PackedVector2Array(0, 0, 0, 200) width = 16.0 default_color = Color(0.445488, 0.445488, 0.445488, 1) [node name="RightLowerLeg" type="Line2D" parent="Body" unique_id=29203948] -position = Vector2(94.817635, 186.09546) -rotation = -0.0034907677 -scale = Vector2(0.9999998, 0.9999998) +position = Vector2(83.4135, 191.77509) +rotation = -0.13332568 +scale = Vector2(0.9999997, 0.9999997) points = PackedVector2Array(0, 0, 0, 200) width = 16.0 default_color = Color(0.445488, 0.445488, 0.445488, 1) [node name="LeftUpperArm" type="Line2D" parent="Body" unique_id=744524157] position = Vector2(9.313226e-10, -238) -rotation = 1.6184965 +rotation = -0.48905078 points = PackedVector2Array(0, 0, 0, 175) width = 16.0 default_color = Color(0.445488, 0.445488, 0.445488, 1) [node name="RightUpperArm" type="Line2D" parent="Body" unique_id=1593646765] position = Vector2(9.313226e-10, -238) -rotation = -1.6184964 +rotation = 0.48905125 +scale = Vector2(0.99999994, 0.99999994) points = PackedVector2Array(0, 0, 0, 175) width = 16.0 default_color = Color(0.445488, 0.445488, 0.445488, 1) [node name="LeftLowerArm" type="Line2D" parent="Body" unique_id=2142117097] -position = Vector2(-167.80888, -246.01059) -rotation = 3.1406152 +position = Vector2(78.92438, -89.6931) +rotation = -0.055406693 points = PackedVector2Array(0, 0, 0, 200) width = 16.0 default_color = Color(0.445488, 0.445488, 0.445488, 1) [node name="RightLowerArm" type="Line2D" parent="Body" unique_id=1200980480] -position = Vector2(167.80891, -246.01057) -rotation = -3.1406155 +position = Vector2(-78.924484, -89.69313) +rotation = 0.055405635 points = PackedVector2Array(0, 0, 0, 200) width = 16.0 default_color = Color(0.445488, 0.445488, 0.445488, 1) +[node name="Head" type="Node2D" parent="Body" unique_id=864822355] +position = Vector2(28.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] rotation = -0.0006991282 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] position = Vector2(-0.12882307, -391.5079) -rotation = 0.00055408623 scale = Vector2(0.9999996, 0.9999996) rest = Transform2D(0.9998273, 0.0005539903, -0.0005539903, 0.9998273, -0.12882307, -391.5079) auto_calculate_length_and_angle = false length = 90.0 bone_angle = -90.0 +metadata/_local_pose_override_enabled_ = true [node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/Head" unique_id=396112729] position = Vector2(0.050337255, -72.00002) remote_path = NodePath("../../../../Body/Head") +update_scale = false [node name="RayCast_Aim" type="RayCast2D" parent="Skeleton2D/Torso/Head" unique_id=2000000004] 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 length = 168.0 bone_angle = -180.0 +metadata/_local_pose_override_enabled_ = true [node name="LeftLowerArm" type="Bone2D" parent="Skeleton2D/Torso/LeftUpperArm" unique_id=721380545] position = Vector2(-168, 0) @@ -270,6 +390,7 @@ rest = Transform2D(0.9987903, -0.04865717, 0.04865717, 0.9987903, -168, 0) auto_calculate_length_and_angle = false length = 200.0 bone_angle = -90.0 +metadata/_local_pose_override_enabled_ = true [node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/LeftUpperArm/LeftLowerArm" unique_id=1189277122] position = Vector2(3.0517578e-05, 1.9073486e-06) @@ -287,6 +408,7 @@ rest = Transform2D(0.9988364, -0.047680702, 0.047680702, 0.9988364, 0, -248) auto_calculate_length_and_angle = false length = 168.00006 bone_angle = 0.0 +metadata/_local_pose_override_enabled_ = true [node name="RightLowerArm" type="Bone2D" parent="Skeleton2D/Torso/RightUpperArm" unique_id=1018820563] position = Vector2(168, 0) @@ -295,6 +417,7 @@ rest = Transform2D(0.048656836, -0.9987903, 0.9987903, 0.048656836, 168, 0) auto_calculate_length_and_angle = false length = 200.0 bone_angle = 0.0 +metadata/_local_pose_override_enabled_ = true [node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/RightUpperArm/RightLowerArm" unique_id=1282597655] rotation = -1.5707964 @@ -311,6 +434,7 @@ rest = Transform2D(0.88005984, 0.47480857, -0.47480857, 0.88005984, -9.536743e-0 auto_calculate_length_and_angle = false length = 200.0 bone_angle = 90.0 +metadata/_local_pose_override_enabled_ = true [node name="LeftLowerLeg" type="Bone2D" parent="Skeleton2D/Torso/LeftUpperLeg" unique_id=81288294] position = Vector2(0, 200) @@ -319,6 +443,7 @@ rest = Transform2D(0.4702357, 0.8825176, -0.8825176, 0.4702357, 0, 200) auto_calculate_length_and_angle = false length = 200.0 bone_angle = 0.0 +metadata/_local_pose_override_enabled_ = true [node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg" unique_id=1673135542] rotation = -1.5707964 @@ -335,6 +460,7 @@ rest = Transform2D(0.8800552, -0.47482592, 0.47482592, 0.8800552, 0, 0) auto_calculate_length_and_angle = false length = 200.0 bone_angle = 90.0 +metadata/_local_pose_override_enabled_ = true [node name="RightLowerLeg" type="Bone2D" parent="Skeleton2D/Torso/RightUpperLeg" unique_id=1819999778] position = Vector2(0, 200) @@ -344,6 +470,7 @@ rest = Transform2D(-0.47026652, 0.8825017, -0.8825017, -0.47026652, 0, 200) auto_calculate_length_and_angle = false length = 200.0 bone_angle = 0.0 +metadata/_local_pose_override_enabled_ = true [node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/RightUpperLeg/RightLowerLeg" unique_id=278100224] rotation = -1.5707964 diff --git a/scenes/test_harness.tscn b/scenes/test_harness.tscn index 890b72d..b06769f 100644 --- a/scenes/test_harness.tscn +++ b/scenes/test_harness.tscn @@ -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 anchors_preset = 15 anchor_right = 1.0 diff --git a/scripts/create_walk.gd b/scripts/create_walk.gd index 435c898..db2443f 100644 --- a/scripts/create_walk.gd +++ b/scripts/create_walk.gd @@ -1,60 +1,63 @@ @tool -extends Node2D +extends EditorScript -@export var build_walk_animation: bool = false: - set(value): - if value: - _create_walk_right_animation() - -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!") +func _run() -> void: + var root = EditorInterface.get_edited_scene_root() + if not root: + push_error("EditorScript: No active scene open.") return + var anim_player = root.get_node_or_null("AnimationPlayer") as AnimationPlayer + if not anim_player: + push_error("EditorScript: AnimationPlayer node not found under root.") + return + + _generate_walk_animation(anim_player, "walk_right", 1, false) # FacingProfile.RIGHT (1) + _generate_walk_animation(anim_player, "walk_left", 0, true) # FacingProfile.LEFT (0) + + +func _generate_walk_animation(anim_player: AnimationPlayer, anim_name: String, profile_enum: int, flip_x: bool) -> void: var anim = Animation.new() anim.length = 0.8 anim.loop_mode = Animation.LOOP_LINEAR + var dir_mult: float = -1.0 if flip_x else 1.0 - var times = [0.0, 0.2, 0.4, 0.6, 0.8] - - # Keyframe tracks mapped relative to root Master node - var tracks = { - "IK_Targets/Torso:position": [ - Vector2(0, 10), Vector2(0, -15), Vector2(0, 10), Vector2(0, -15), Vector2(0, 10) - ], - "IK_Targets/Head:position": [ - Vector2(100, -614), Vector2(100, -639), Vector2(100, -614), Vector2(100, -639), Vector2(100, -614) - ], - "IK_Targets/Right_Leg:position": [ - Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390) - ], - "IK_Targets/Left_Leg:position": [ - Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380) - ], - "IK_Targets/Right_Hand:position": [ - Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110) - ], - "IK_Targets/Left_Hand:position": [ - Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110), Vector2(0, 115), Vector2(-90, 110) - ] + # 1. Profile Track (0 = LEFT, 1 = RIGHT) + var profile_track = anim.add_track(Animation.TYPE_VALUE) + anim.track_set_path(profile_track, ".:facing_profile") + anim.value_track_set_update_mode(profile_track, Animation.UPDATE_DISCRETE) + anim.track_insert_key(profile_track, 0.0, profile_enum) + + # 2. Keyframe positions + var raw_tracks = { + "IK_Targets/Torso:position": [Vector2(0, 10), Vector2(0, -15), Vector2(0, 10), Vector2(0, -15), Vector2(0, 10)], + "IK_Targets/Head:position": [Vector2(100, -614), Vector2(100, -639), Vector2(100, -614), Vector2(100, -639), Vector2(100, -614)], + "IK_Targets/Right_Leg:position": [Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390)], + "IK_Targets/Left_Leg:position": [Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380)], + "IK_Targets/Right_Hand:position": [Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110)], + "IK_Targets/Left_Hand:position": [Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110)] } - 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) anim.track_set_path(track_idx, path) anim.track_set_interpolation_type(track_idx, Animation.INTERPOLATION_CUBIC) + 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("") if not lib: lib = AnimationLibrary.new() anim_player.add_animation_library("", lib) - if lib.has_animation("walk_right"): - lib.remove_animation("walk_right") + if lib.has_animation(anim_name): + lib.remove_animation(anim_name) - lib.add_animation("walk_right", anim) - print("✓ Successfully generated 'walk_right' animation in AnimationPlayer!") + lib.add_animation(anim_name, anim) + print("Successfully generated '%s' animation!" % anim_name) diff --git a/scripts/stickman_factory.gd b/scripts/stickman_factory.gd index f57dba8..4476edc 100644 --- a/scripts/stickman_factory.gd +++ b/scripts/stickman_factory.gd @@ -34,13 +34,13 @@ static func load_stk(path: String) -> Dictionary: return parsed as Dictionary -static func spawn_from_data(stk_data: Dictionary) -> Node2D: - var rig := RIG_SCENE.instantiate() as Node2D +static func spawn_from_data(stk_data: Dictionary) -> StickmanRig: + var rig := RIG_SCENE.instantiate() as StickmanRig StkRigAdapter.apply(stk_data, rig) return rig -static func spawn(path: String) -> Node2D: +static func spawn(path: String) -> StickmanRig: var data := load_stk(path) if data.is_empty(): return null diff --git a/scripts/stickman_rig.gd b/scripts/stickman_rig.gd new file mode 100644 index 0000000..b6ad47c --- /dev/null +++ b/scripts/stickman_rig.gd @@ -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) diff --git a/scripts/stickman_rig.gd.uid b/scripts/stickman_rig.gd.uid new file mode 100644 index 0000000..aafa667 --- /dev/null +++ b/scripts/stickman_rig.gd.uid @@ -0,0 +1 @@ +uid://b4rsae8suaxj7 diff --git a/scripts/test_harness.gd b/scripts/test_harness.gd index ca878ef..dac8292 100644 --- a/scripts/test_harness.gd +++ b/scripts/test_harness.gd @@ -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 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). const COORDS_PANEL_WIDTH: float = 320.0 @@ -80,63 +85,12 @@ const LEAF_BONE_IK_PATHS: Dictionary = { "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 -## Bend joints (upper↔lower limb connectors) whose TwoBoneIK "Flip Bend -## Direction" flag is user-controllable. Keyed by joint name; values are the -## lower bone's NodePath relative to the Skeleton2D. -const BEND_JOINTS: Array[String] = ["LeftArm", "RightArm", "LeftLeg", "RightLeg"] - -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", - ], -} +## 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 } # --------------------------------------------------------------------------- # Runtime-built node references @@ -151,14 +105,18 @@ var _status_label: Label var _file_dialog: FileDialog var _coords_panel: PanelContainer var _coords_label: RichTextLabel +var _anim_dropdown: OptionButton +var _play_button: Button +var _stop_button: Button +var _loop_check: CheckBox # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- var _rig: Node2D = null +var _rig_script: StickmanRig = null var _skeleton: Skeleton2D = null -var _body_container: Node2D = null var _ik_handles: Dictionary = {} # { String : Marker2D } var _coord_bones: Dictionary = {} # { String : Bone2D } @@ -170,15 +128,20 @@ var _is_panning: bool = false var _pan_last: Vector2 = Vector2.ZERO var _dragging_handle: Marker2D = null -# Task 1: IK bend-direction state (survives respawn; matches authored defaults). -var _facing_profile: int = FacingProfile.FORWARD +# UI mirror of the last-selected facing profile (persists across respawns; the +# 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 _bend_joint_bones: Dictionary = {} # { String : Bone2D } -var _bend_modifications: Dictionary = {} # { String : SkeletonModification2DTwoBoneIK } var _facing_button: MenuButton var _facing_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 # --------------------------------------------------------------------------- @@ -222,13 +185,33 @@ func _build_ui() -> void: _facing_button = MenuButton.new() _facing_button.text = "Facing" _facing_menu = _facing_button.get_popup() - _facing_menu.add_item(_facing_menu_label(FacingProfile.LEFT), FacingProfile.LEFT) - _facing_menu.add_item(_facing_menu_label(FacingProfile.RIGHT), FacingProfile.RIGHT) - _facing_menu.add_item(_facing_menu_label(FacingProfile.FORWARD), FacingProfile.FORWARD) + _facing_menu.add_item(_facing_menu_label(StickmanRig.FacingProfile.LEFT), StickmanRig.FacingProfile.LEFT) + _facing_menu.add_item(_facing_menu_label(StickmanRig.FacingProfile.RIGHT), StickmanRig.FacingProfile.RIGHT) + _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.about_to_popup.connect(_update_facing_menu_labels) 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() open_btn.text = "Open .stk…" open_btn.pressed.connect(_on_open_pressed) @@ -266,6 +249,10 @@ func _build_ui() -> void: _status_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS 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_container = SubViewportContainer.new() _viewport_container.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) @@ -360,6 +347,12 @@ func _on_viewport_container_resized() -> void: return _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 # --------------------------------------------------------------------------- @@ -408,14 +401,13 @@ func _handle_right_click(screen_pos: Vector2) -> void: 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 best_joint := "" var best_dist := hit_radius - for joint: String in BEND_JOINTS: - var bone: Bone2D = _bend_joint_bones.get(joint) as Bone2D - if bone == null or not is_instance_valid(bone): - continue - var dist := world_pos.distance_to(bone.global_position) + for joint: String in _rig_script.get_bend_joints(): + var dist := world_pos.distance_to(_rig_script.get_bend_joint_global_position(joint)) if dist <= best_dist: best_joint = joint best_dist = dist @@ -574,58 +566,47 @@ func _bone_color(bone_name: String) -> Color: # --------------------------------------------------------------------------- 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: if _facing_menu == null: 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) if idx >= 0: _facing_menu.set_item_text(idx, _facing_menu_label(profile)) 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() -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: - var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK - if mod == null: + if _rig_script == null or not is_instance_valid(_rig_script): 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: if _context_joint.is_empty(): return - var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(_context_joint) as SkeletonModification2DTwoBoneIK - if mod == null: + if _rig_script == null or not is_instance_valid(_rig_script): 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() # --------------------------------------------------------------------------- @@ -653,14 +634,27 @@ func _load_and_spawn(path: String) -> void: return _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.move_child(_rig, 0) # keep the rig behind the debug overlay _resolve_rig_nodes() - _ensure_modification_stack_enabled() - _apply_facing_profile(_facing_profile) + if _rig_script != null and is_instance_valid(_rig_script): + _rig_script.set_facing_profile(remembered_profile) _status_label.text = path.get_file() + _set_rig_controls_visible(true) _recenter_camera() _debug_overlay.queue_redraw() @@ -669,14 +663,18 @@ func _free_current_rig() -> void: if _rig != null and is_instance_valid(_rig): _rig.queue_free() _rig = null + _rig_script = null _skeleton = null - _body_container = null _ik_handles.clear() - _bend_joint_bones.clear() - _bend_modifications.clear() _coord_bones.clear() _context_joint = "" _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: @@ -684,10 +682,6 @@ func _resolve_rig_nodes() -> void: if _skeleton == null: 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() for handle_name: String in IK_HANDLE_PATHS: 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: push_warning("TestHarness: missing IK handle '%s'." % IK_HANDLE_PATHS[handle_name]) - _resolve_bend_joints() _resolve_coord_bones() - - -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) + _resolve_anim_player() func _resolve_coord_bones() -> void: @@ -744,14 +706,32 @@ func _resolve_coord_bones() -> void: push_warning("TestHarness: missing coordinate bone '%s'." % path) -func _ensure_modification_stack_enabled() -> void: - if _skeleton == null: +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 - var stack: SkeletonModificationStack2D = _skeleton.modification_stack - if stack != null: - stack.enabled = true - else: - push_warning("TestHarness: Skeleton2D has no modification_stack assigned.") + _anim_player.animation_finished.connect(_on_animation_finished) + + +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) func _recenter_camera() -> void: @@ -763,6 +743,79 @@ func _recenter_camera() -> void: _camera.zoom = Vector2.ONE _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 # ---------------------------------------------------------------------------