Implement rig animation controls in the test harness
- Added a new animation specification document for Phase 9 Task 5 detailing the requirements for rig animation controls. - Introduced a new `StickmanRig` script to manage the facing direction and joint bending for the rig. - Implemented UI elements in the test harness for selecting animations, controlling playback (play/pause/resume/stop), and toggling loop mode. - Enhanced the `test_harness.gd` script to handle animation playback state and UI interactions. - Updated documentation in `AGENTS.md`, `README.md`, and `RIGGING.md` to reflect the new animation features.
This commit is contained in:
@@ -213,14 +213,38 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
|||||||
`anchor`/`v` generically.
|
`anchor`/`v` generically.
|
||||||
Targets `master_rig.tscn` node paths; every node lookup is null-guarded (missing node →
|
Targets `master_rig.tscn` node paths; every node lookup is null-guarded (missing node →
|
||||||
`push_warning` + skip, never crash). Consumed by a future runtime pipeline.
|
`push_warning` + skip, never crash). Consumed by a future runtime pipeline.
|
||||||
|
- `scripts/stickman_rig.gd` — `class_name StickmanRig`, `extends Node2D`; the **runtime owner of
|
||||||
|
facing direction, per-joint bone bend, and `Body/*` z-order** for `master_rig.tscn` (Phase 9
|
||||||
|
Task 4). Attached to the `Master` root node of `master_rig.tscn`. **Non-`@tool`** — node
|
||||||
|
resolution, flag writes, and z-order reordering run only at runtime (`_ready` + setters on a
|
||||||
|
live instance). Enums `FacingProfile { LEFT, RIGHT, FORWARD }` (values are the harness facing-menu
|
||||||
|
ids) and `BendDirection { NORMAL, INVERTED }`. Constants (moved from the harness): `SKELETON_PATH`,
|
||||||
|
`BODY_CONTAINER_PATH`, `BEND_JOINTS` (`["LeftArm","RightArm","LeftLeg","RightLeg"]`),
|
||||||
|
`BEND_JOINT_BONE_PATHS` (each joint → its lower `Bone2D` NodePath relative to `Skeleton2D`),
|
||||||
|
`PROFILE_FLAGS` (per-profile `flip_bend_direction` sets), `Z_ORDER_BY_PROFILE` (per-profile
|
||||||
|
`Body/*` draw-order tables, back-to-front). Exports: `facing_profile: FacingProfile` (default
|
||||||
|
`FORWARD`, a preset whose setter writes the four per-joint vars + reorders `Body/*`) and an
|
||||||
|
`@export_group("Bend Direction")` of four `@export_enum("Normal","Inverted")` vars
|
||||||
|
`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend` (defaults
|
||||||
|
NORMAL/INVERTED/INVERTED/NORMAL = FORWARD). Signals `facing_profile_changed(profile)` /
|
||||||
|
`bend_flag_changed(joint, flipped)`. Public API: `set_facing_profile`/`get_facing_profile`,
|
||||||
|
`set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, and
|
||||||
|
`get_bend_joint_global_position(joint)` (unknown joint → `push_warning` + no-op/`false`/
|
||||||
|
`Vector2.ZERO`). `_ready()` resolves `Skeleton2D`/`Body`/4 lower `Bone2D`s/4 TwoBoneIK
|
||||||
|
modifications (matched by `joint_two_bone2d_node` NodePath, **never stack index**), enables the
|
||||||
|
modification stack (`stack.enabled = true`), applies the current profile once, then sets
|
||||||
|
`_nodes_ready`; a `_nodes_ready` guard makes pre-`_ready` setters store-only (robust against
|
||||||
|
setter timing during `PackedScene.instantiate()`). Null-guards + `push_warning` prefixed
|
||||||
|
`"StickmanRig: "` throughout; never crashes.
|
||||||
- `scripts/stickman_factory.gd` — `class_name StickmanFactory`, `extends RefCounted`; a **static
|
- `scripts/stickman_factory.gd` — `class_name StickmanFactory`, `extends RefCounted`; a **static
|
||||||
factory** and the **runtime entry point** (Phase 9, **not used by the editor**) that turns a
|
factory** and the **runtime entry point** (Phase 9, **not used by the editor**) that turns a
|
||||||
`.stk` file into a live, rigged `master_rig.tscn` instance:
|
`.stk` file into a live, rigged `master_rig.tscn` instance:
|
||||||
- `static func load_stk(path: String) -> Dictionary` — reads a `.stk` file (`FileAccess` +
|
- `static func load_stk(path: String) -> Dictionary` — reads a `.stk` file (`FileAccess` +
|
||||||
`JSON.parse_string`); returns `{}` + `push_warning` on failure.
|
`JSON.parse_string`); returns `{}` + `push_warning` on failure.
|
||||||
- `static func spawn_from_data(stk_data: Dictionary) -> Node2D` — instantiates
|
- `static func spawn_from_data(stk_data: Dictionary) -> StickmanRig` — instantiates
|
||||||
`res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, returns the rig root.
|
`res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, returns the rig root
|
||||||
- `static func spawn(path: String) -> Node2D` — `load_stk()` then `spawn_from_data()`;
|
(typed as `StickmanRig` since the rig now carries the `StickmanRig` root script).
|
||||||
|
- `static func spawn(path: String) -> StickmanRig` — `load_stk()` then `spawn_from_data()`;
|
||||||
returns `null` on empty data.
|
returns `null` on empty data.
|
||||||
- `scripts/test_harness.gd` — **standalone staging scene** (Phase 9, **not wired into the editor**;
|
- `scripts/test_harness.gd` — **standalone staging scene** (Phase 9, **not wired into the editor**;
|
||||||
run via **F6** on `res://scenes/test_harness.tscn`) for debugging bone scales, vector drawing
|
run via **F6** on `res://scenes/test_harness.tscn`) for debugging bone scales, vector drawing
|
||||||
@@ -241,8 +265,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
|||||||
line to the aim point; colored markers at
|
line to the aim point; colored markers at
|
||||||
`IK_Targets/{Left_Hand,Right_Hand,Left_Leg,Right_Leg}` when "Show IK Handles" is on. Interactive
|
`IK_Targets/{Left_Hand,Right_Hand,Left_Leg,Right_Leg}` when "Show IK Handles" is on. Interactive
|
||||||
IK: click-drag the `Marker2D` IK targets; the scene's `SkeletonModificationStack2D` TwoBoneIK
|
IK: click-drag the `Marker2D` IK targets; the scene's `SkeletonModificationStack2D` TwoBoneIK
|
||||||
flexes limbs live. The harness enables the modification stack (`enabled = true`) after each
|
flexes limbs live (the rig self-enables its modification stack in `_ready()`).
|
||||||
spawn.
|
|
||||||
- **Phase 9 Round 7 draggable Torso & Head handles:** `IK_HANDLE_PATHS` now has **6 entries**
|
- **Phase 9 Round 7 draggable Torso & Head handles:** `IK_HANDLE_PATHS` now has **6 entries**
|
||||||
— the 4 limb targets plus `"Head"` (`IK_Targets/Head`, the `SkeletonModification2DLookAt` aim
|
— the 4 limb targets plus `"Head"` (`IK_Targets/Head`, the `SkeletonModification2DLookAt` aim
|
||||||
point) and `"Torso"` (`IK_Targets/Torso`, whose child `RemoteTransform2D` moves the hip bone).
|
point) and `"Torso"` (`IK_Targets/Torso`, whose child `RemoteTransform2D` moves the hip bone).
|
||||||
@@ -258,46 +281,41 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
|||||||
for the LookAt test.
|
for the LookAt test.
|
||||||
- **Phase 9 Task 1 skeleton IK bone switches:** adds a "Facing" `MenuButton` (leftmost control
|
- **Phase 9 Task 1 skeleton IK bone switches:** adds a "Facing" `MenuButton` (leftmost control
|
||||||
in the top-bar `HBox`) and per-joint bend-direction toggles for the rig's TwoBoneIK "Flip
|
in the top-bar `HBox`) and per-joint bend-direction toggles for the rig's TwoBoneIK "Flip
|
||||||
Bend Direction" flags. `enum FacingProfile { LEFT, RIGHT, FORWARD }` (values used directly as
|
Bend Direction" flags. The facing profile, the per-joint bend flags, and the `Body/*` z-order
|
||||||
menu item ids); `BEND_JOINTS: Array[String] = ["LeftArm","RightArm","LeftLeg","RightLeg"]`
|
tables now **live in the `StickmanRig` script** (Phase 9 Task 4) — the harness drives the rig
|
||||||
with `BEND_JOINT_BONE_PATHS` (each joint → its lower `Bone2D` NodePath relative to
|
via `_rig_script: StickmanRig` (typed root from `StickmanFactory.spawn()`; signals connected
|
||||||
`Skeleton2D`, e.g. `Torso/LeftUpperArm/LeftLowerArm`); `PROFILE_FLAGS` maps each profile to a
|
**before** `add_child`) and keeps `_facing_profile` (default `FORWARD`) only as a **UI mirror**
|
||||||
per-joint `flip_bend_direction` set (LEFT: arms off / legs on; RIGHT: arms on / legs off;
|
for the `[√] ` menu prefix + respawn re-application; the rig's exported `facing_profile` is the
|
||||||
FORWARD: LeftArm off, RightArm on, LeftLeg on, RightLeg off — matching `master_rig.tscn`'s
|
authority. Menu item ids are `StickmanRig.FacingProfile.LEFT/RIGHT/FORWARD` (values used
|
||||||
authored defaults). State `_facing_profile` (default `FORWARD`, harness-level, persists across
|
directly as menu item ids). State `_context_joint`, `_facing_button`/`_facing_menu`,
|
||||||
spawns), `_context_joint`, `_bend_joint_bones`, `_bend_modifications`, `_facing_button`/
|
`_context_menu`. The Facing popup (Left/Right/Forward) uses dynamic text-only labels with the
|
||||||
`_facing_menu`, `_context_menu`. The Facing popup (Left/Right/Forward) uses dynamic text-only
|
current profile prefixed `[√] `, refreshed on `about_to_popup` and after selection (mirroring
|
||||||
labels with the current profile prefixed `[√] `, refreshed on `about_to_popup` and after
|
the editor's snap-menu pattern); selection calls `_rig_script.set_facing_profile(id)`. Hit-test
|
||||||
selection (mirroring the editor's snap-menu pattern). Runtime resolution:
|
for the right-click toggle iterates `_rig_script.get_bend_joints()` with positions from
|
||||||
`_resolve_bend_joints()` (called from `_resolve_rig_nodes()`) resolves the 4 lower-limb
|
`_rig_script.get_bend_joint_global_position(joint)`. Per-joint toggle: right-click inside the
|
||||||
`Bone2D`s via `get_node_or_null` and matches each `SkeletonModification2DTwoBoneIK` in the
|
viewport on an elbow/knee (the upper↔lower limb connector, within `JOINT_HIT_RADIUS_PX := 14.0`
|
||||||
modification stack by its `joint_two_bone2d_node` NodePath (no hardcoded stack index);
|
screen px converted to world by `_camera.zoom.x`, nearest joint wins) pops a one-item context
|
||||||
`push_warning` on missing nodes/mods. Per-joint toggle: right-click inside the viewport on an
|
menu labeled **"Normal Bend"** (when the rig's `get_joint_bend_flipped(joint)` is true) or
|
||||||
elbow/knee (the upper↔lower limb connector, within `JOINT_HIT_RADIUS_PX := 14.0` screen px
|
**"Invert Bend"** (when false); selecting calls
|
||||||
converted to world by `_camera.zoom.x`, nearest joint wins) pops a one-item context menu
|
`_rig_script.set_joint_bend_flipped(_context_joint, not _rig_script.get_joint_bend_flipped(_context_joint))`.
|
||||||
labeled **"Normal Bend"** (when `flip_bend_direction` is currently true) or **"Invert Bend"**
|
Only the 4 elbows/knees are right-click targets — shoulders/hips/wrists/ankles/head/torso are
|
||||||
(when false); selecting toggles that joint's `flip_bend_direction` on the live TwoBoneIK
|
not. Lifecycle: `_free_current_rig()` clears `_rig_script` (and `_context_joint`);
|
||||||
modification. Only the 4 elbows/knees are right-click targets — shoulders/hips/wrists/ankles/
|
`_load_and_spawn()` captures the remembered profile before `add_child` (so the rig's `_ready()`
|
||||||
head/torso are not. Lifecycle: `_free_current_rig()` clears `_bend_joint_bones`/
|
`facing_profile_changed(FORWARD)` doesn't clobber the mirror) then re-applies
|
||||||
`_bend_modifications`/`_context_joint`; `_load_and_spawn()` re-applies
|
`_rig_script.set_facing_profile(remembered_profile)` after `_resolve_rig_nodes()`. The rig
|
||||||
`_apply_facing_profile(_facing_profile)` right after `_ensure_modification_stack_enabled()` so
|
enables its own modification stack in `_ready()`. No persistence to disk.
|
||||||
every fresh spawn matches the current profile. No persistence to disk.
|
- **Phase 9 Task 2 body-part z-order:** the rig's `_apply_profile()` reorders the rig's `Body/*`
|
||||||
- **Phase 9 Task 2 body-part z-order:** `_apply_facing_profile()` now also sets the
|
visual part nodes (tree order = draw order) from the Facing profile; `Z_ORDER_BY_PROFILE` (now
|
||||||
`_facing_profile` state itself (previously only the menu handler did) and calls
|
owned by `StickmanRig`) maps each `FacingProfile` to the `Body/*` part node names in
|
||||||
`_apply_body_z_order()`, so bend flags + draw order stay in sync from one entry point.
|
**back-to-front draw order** (Godot 4 `Node2D` draws siblings in tree order; all parts keep
|
||||||
`const BODY_CONTAINER_PATH := "Body"` and `const Z_ORDER_BY_PROFILE: Dictionary` map each
|
`z_index = 0`). FORWARD: torso → left/right upper legs → left/right lower legs → left/right
|
||||||
`FacingProfile` to the rig `Body/*` visual part node names in **back-to-front draw order**
|
upper arms → left/right lower arms → head (all limbs in front of the torso); LEFT: left arm
|
||||||
(Godot 4 `Node2D` draws siblings in tree order; all parts keep `z_index = 0`). FORWARD:
|
pair then left leg pair **behind** the torso, right leg pair then right arm pair in front;
|
||||||
torso → left/right upper legs → left/right lower legs → left/right upper arms → left/right
|
RIGHT: mirrored. In every profile upper limbs stay behind lower limbs; far-side
|
||||||
lower arms → head (all limbs in front of the torso); LEFT: left arm pair then left leg
|
(behind-torso) arms draw behind the legs while near-side arms draw in front of the legs; the
|
||||||
pair **behind** the torso, right leg pair then right arm pair in front; RIGHT: mirrored.
|
**head is always frontmost**. The rig's `_apply_body_z_order()` walks the profile array
|
||||||
In every profile upper limbs stay behind lower limbs; far-side (behind-torso) arms draw
|
back-to-front and `move_child(part, count - 1)`s each existing child (missing parts skipped),
|
||||||
behind the legs while near-side arms draw in front of the legs; the **head is always
|
which yields the profile order; unknown extra children stay at the back. Safe with the adapter:
|
||||||
frontmost**. State `_body_container: Node2D` (resolved in
|
|
||||||
`_resolve_rig_nodes()` via `get_node_or_null`, null-guarded with `push_warning`, cleared
|
|
||||||
in `_free_current_rig()`). `_apply_body_z_order()` walks the profile array back-to-front
|
|
||||||
and `move_child(part, count - 1)`s each existing child (missing parts skipped), which
|
|
||||||
yields the profile order; unknown extra children stay at the back. Safe with the adapter:
|
|
||||||
shapes are children of the part nodes, so moving a part moves its whole shape group. No
|
shapes are children of the part nodes, so moving a part moves its whole shape group. No
|
||||||
persistence to disk.
|
persistence to disk.
|
||||||
- **Phase 9 Task 3 coordinates display:** adds a "Show Coords" `CheckBox` in the top-bar
|
- **Phase 9 Task 3 coordinates display:** adds a "Show Coords" `CheckBox` in the top-bar
|
||||||
@@ -328,6 +346,25 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
|||||||
frames. Values are
|
frames. Values are
|
||||||
world-space (`global_position`/`global_rotation`), rotation in degrees via
|
world-space (`global_position`/`global_rotation`), rotation in degrees via
|
||||||
`_fmt_deg(rad)` (1 decimal, `°`), `_fmt_vec2(v)` for positions. No persistence to disk.
|
`_fmt_deg(rad)` (1 decimal, `°`), `_fmt_vec2(v)` for positions. No persistence to disk.
|
||||||
|
- **Phase 9 Task 5 rig animation:** adds top-bar controls immediately after the "Facing" menu
|
||||||
|
— an `_anim_dropdown` `OptionButton` populated per spawn from
|
||||||
|
`AnimationPlayer.get_animation_list()` (preferring `walk_right` via `DEFAULT_ANIMATION`), a
|
||||||
|
`_play_button` whose label swaps "Play"/"Pause"/"Resume" by `_playback_state`, a `_stop_button`
|
||||||
|
(Stop), and a `_loop_check` `CheckBox` default ON (harness-level, persists across respawns like
|
||||||
|
`_show_coords`). The harness resolves the rig's `AnimationPlayer` directly by node path via the
|
||||||
|
`ANIMATION_PLAYER_PATH` const (`_resolve_anim_player()`, called at the end of
|
||||||
|
`_resolve_rig_nodes()`) and drives it directly; the `AnimationTree` node remains an untouched
|
||||||
|
unconfigured placeholder (out of scope, D1). Loop is implemented by writing
|
||||||
|
`Animation.loop_mode` (`LOOP_LINEAR`/`LOOP_NONE`) on the selected animation before each play
|
||||||
|
(`_apply_loop_mode()`); playback state is tracked by the enum
|
||||||
|
`PlaybackState {STOPPED, PLAYING, PAUSED}` via the button handlers + the `animation_finished`
|
||||||
|
signal (guarded by `_loop`) — no polling in `_process`. Changing the dropdown selection stops
|
||||||
|
playback; `_free_current_rig()` clears `_anim_player`, the dropdown, `_selected_animation`,
|
||||||
|
and state. Playing `walk_right` also sets the rig's `facing_profile` via its animation track →
|
||||||
|
export setter → the existing `_on_facing_profile_changed` handling (menu `[√] ` + redraw). No
|
||||||
|
persistence to disk. The "Facing" menu and all animation controls are **hidden until an .stk is
|
||||||
|
loaded** (`_set_rig_controls_visible(false)` at the end of `_build_ui()` and in
|
||||||
|
`_free_current_rig()`; shown on successful spawn in `_load_and_spawn()`).
|
||||||
- Scenes:
|
- Scenes:
|
||||||
- `scenes/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used
|
- `scenes/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used
|
||||||
for typed `@onready` access: `%MenuBar`, `%StickmanNameEdit`, `%LeftColumn`,
|
for typed `@onready` access: `%MenuBar`, `%StickmanNameEdit`, `%LeftColumn`,
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ Phase 9 adds the **runtime pipeline** that turns a saved `.stk` file into a live
|
|||||||
| `spawn_from_data` | `static func spawn_from_data(stk_data: Dictionary) -> Node2D` | Instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, returns the rig root. |
|
| `spawn_from_data` | `static func spawn_from_data(stk_data: Dictionary) -> Node2D` | Instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, returns the rig root. |
|
||||||
| `spawn` | `static func spawn(path: String) -> Node2D` | Chains `load_stk()` → `spawn_from_data()`. Returns `null` on empty data. |
|
| `spawn` | `static func spawn(path: String) -> Node2D` | Chains `load_stk()` → `spawn_from_data()`. Returns `null` on empty data. |
|
||||||
|
|
||||||
The factory is the intended runtime API: `StickmanFactory.spawn("res://stickmen/basic.stk")` yields a rigged `Node2D` ready to add to the scene tree. `StkRigAdapter` (Phase 8, extended by Phase 9) now also fits the head bone (`Head.position.y = -proportions.torso_length`) and mounts the head as **full geometry** like every other part (clearing its inline `@tool` circle script); the mounted head is dropped `HEAD_CHIN_DROP` (28 px) below the neck so its chin aligns with the pose-guide circle's bottom and overlaps the torso (Phase 9 Round 4).
|
The factory is the intended runtime API: `StickmanFactory.spawn("res://stickmen/basic.stk")` yields a rigged `StickmanRig` (the root of `master_rig.tscn`) ready to add to the scene tree. `StkRigAdapter` (Phase 8, extended by Phase 9) now also fits the head bone (`Head.position.y = -proportions.torso_length`) and mounts the head as **full geometry** like every other part (clearing its inline `@tool` circle script); the mounted head is dropped `HEAD_CHIN_DROP` (28 px) below the neck so its chin aligns with the pose-guide circle's bottom and overlaps the torso (Phase 9 Round 4).
|
||||||
|
|
||||||
**Test Harness (`res://scenes/test_harness.tscn`)** — run via **F6** on the scene (NOT via the main editor scene). Controls:
|
**Test Harness (`res://scenes/test_harness.tscn`)** — run via **F6** on the scene (NOT via the main editor scene). Controls:
|
||||||
|
|
||||||
@@ -259,10 +259,11 @@ The factory is the intended runtime API: `StickmanFactory.spawn("res://stickmen/
|
|||||||
- **Show Bones / Show IK Handles** — checkboxes toggling the debug overlay.
|
- **Show Bones / Show IK Handles** — checkboxes toggling the debug overlay.
|
||||||
- **Loaded filename** — status label showing the currently loaded file.
|
- **Loaded filename** — status label showing the currently loaded file.
|
||||||
- **Pan / zoom** — middle-mouse drag to pan, mouse-wheel to zoom the `Camera2D`; the camera recenters on each spawn.
|
- **Pan / zoom** — middle-mouse drag to pan, mouse-wheel to zoom the `Camera2D`; the camera recenters on each spawn.
|
||||||
- **IK drag** — click and drag any of the **6** `Marker2D` IK handles (`IK_Targets/Left_Hand`, `Right_Hand`, `Left_Leg`, `Right_Leg` flex the limb via TwoBoneIK; `IK_Targets/Torso` translates the whole rig rigidly via its `RemoteTransform2D`; `IK_Targets/Head` drives the head's `SkeletonModification2DLookAt` aim rotation) (Phase 9 Round 7). The harness enables the modification stack after each spawn.
|
- **IK drag** — click and drag any of the **6** `Marker2D` IK handles (`IK_Targets/Left_Hand`, `Right_Hand`, `Left_Leg`, `Right_Leg` flex the limb via TwoBoneIK; `IK_Targets/Torso` translates the whole rig rigidly via its `RemoteTransform2D`; `IK_Targets/Head` drives the head's `SkeletonModification2DLookAt` aim rotation) (Phase 9 Round 7). The rig self-enables its modification stack in `_ready()`.
|
||||||
- **Facing** — a `MenuButton` (leftmost in the top bar) applying a preset to the rig's TwoBoneIK **Flip Bend Direction** flags: **Left** (arms normal, legs inverted), **Right** (arms inverted, legs normal), **Forward** (left arm / right leg inverted — the authored defaults). The current profile is prefixed `[√] ` on the menu labels and persists across rig loads (Phase 9 Task 1).
|
- **Facing** — a `MenuButton` (leftmost in the top bar) applying a preset to the rig's **`StickmanRig`** exported `facing_profile`, which sets the rig's TwoBoneIK **Flip Bend Direction** flags: **Left** (arms normal, legs inverted), **Right** (arms inverted, legs normal), **Forward** (RightArm / LeftLeg inverted — the rig's default). The current profile is prefixed `[√] ` on the menu labels and persists across rig loads (Phase 9 Task 1, Task 4).
|
||||||
- **Bend-direction toggle** — right-click an elbow or knee joint in the viewport to pop a context menu that inverts that joint's TwoBoneIK bend direction ("Invert Bend" → "Normal Bend" and back). Only the 4 elbows/knees are targets (Phase 9 Task 1).
|
- **Bend-direction toggle** — right-click an elbow or knee joint in the viewport to pop a context menu that inverts that joint's TwoBoneIK bend direction ("Invert Bend" → "Normal Bend" and back). Only the 4 elbows/knees are targets (Phase 9 Task 1).
|
||||||
- **Body-part z-order** — the Facing profile also reorders the rig's `Body/*` visual part nodes (tree order = draw order): **Forward** draws all limbs in front of the torso, **Left** tucks the left arm/leg pairs behind the torso (right pairs in front), **Right** tucks the right pairs behind; upper limbs sit behind lower limbs, far-side (behind-torso) arms draw behind the legs while near-side arms draw in front of them, and the **head is always frontmost** (Phase 9 Task 2).
|
- **Body-part z-order** — the Facing profile also reorders the rig's `Body/*` visual part nodes (tree order = draw order), now owned by the rig's `StickmanRig._apply_body_z_order()`: **Forward** draws all limbs in front of the torso, **Left** tucks the left arm/leg pairs behind the torso (right pairs in front), **Right** tucks the right pairs behind; upper limbs sit behind lower limbs, far-side (behind-torso) arms draw behind the legs while near-side arms draw in front of them, and the **head is always frontmost** (Phase 9 Task 2, Task 4).
|
||||||
|
- **Animation** — a dropdown (populated per spawn from the rig's `AnimationPlayer.get_animation_list()`, `walk_right` pre-selected) plus **Play/Pause/Resume** (label swaps with playback state), **Stop**, and **Loop** (default ON, persists across loads) controls. The harness drives the rig's `AnimationPlayer` directly by node path (`ANIMATION_PLAYER_PATH`); loop writes `Animation.loop_mode` before play, and playback state is tracked via the button handlers + the `animation_finished` signal (no polling). The `AnimationTree` node remains an untouched placeholder. Playing `walk_right` also flips the rig's facing profile to Right via the animation's `facing_profile` track (Phase 9 Task 5).
|
||||||
|
|
||||||
Debug overlay (a world-space `Node2D` `_draw()`): true bone **segments** drawn between each `Bone2D` origin and its Bone2D children (color-coded left cyan / right orange / central white, with a joint dot per bone), with limb leaf bones drawn out to their IK targets so the forearm/shin segments and wrist/ankle joints are visible (Phase 9 Round 2) and the **Head** leaf drawn along the bone's own direction (~90 px, since its IK target is a LookAt aim point, not a joint) (Phase 9 Round 3), when **Show Bones** is on; colored markers at the six IK targets — hands green, feet blue, head **yellow**, torso **magenta** (Phase 9 Round 7) — plus a semi-transparent yellow aim line from the Head bone to the head marker, when **Show IK Handles** is on. Each load frees the previous rig and spawns a fresh one.
|
Debug overlay (a world-space `Node2D` `_draw()`): true bone **segments** drawn between each `Bone2D` origin and its Bone2D children (color-coded left cyan / right orange / central white, with a joint dot per bone), with limb leaf bones drawn out to their IK targets so the forearm/shin segments and wrist/ankle joints are visible (Phase 9 Round 2) and the **Head** leaf drawn along the bone's own direction (~90 px, since its IK target is a LookAt aim point, not a joint) (Phase 9 Round 3), when **Show Bones** is on; colored markers at the six IK targets — hands green, feet blue, head **yellow**, torso **magenta** (Phase 9 Round 7) — plus a semi-transparent yellow aim line from the Head bone to the head marker, when **Show IK Handles** is on. Each load frees the previous rig and spawns a fresh one.
|
||||||
|
|
||||||
@@ -403,8 +404,9 @@ Behavior:
|
|||||||
| `res://scenes/body_part_panel.tscn` | Reusable single body-part editor panel (title, drawing area, context menu, `ColorPickerPopup`); expands vertically in its column. |
|
| `res://scenes/body_part_panel.tscn` | Reusable single body-part editor panel (title, drawing area, context menu, `ColorPickerPopup`); expands vertically in its column. |
|
||||||
| `res://scripts/stickman_editor.gd` | Editor controller — File/Edit/View menu actions, save/load/clear, JSON v1.5 serialization with multi-shape/rotation/scale, `part_order`, Phase 8 `proportions`/`pivot`/`length`, and Phase 9 Round 5 per-part `guide_offset` export, `settings.json` load/save, editor-wide shape clipboard (Copy/Paste across panels), broadcast of grid/snap settings to panels, Reset Views, populates panels, coordinates selection across panels. |
|
| `res://scripts/stickman_editor.gd` | Editor controller — File/Edit/View menu actions, save/load/clear, JSON v1.5 serialization with multi-shape/rotation/scale, `part_order`, Phase 8 `proportions`/`pivot`/`length`, and Phase 9 Round 5 per-part `guide_offset` export, `settings.json` load/save, editor-wide shape clipboard (Copy/Paste across panels), broadcast of grid/snap settings to panels, Reset Views, populates panels, coordinates selection across panels. |
|
||||||
| `res://scripts/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9 (Rounds 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/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/stickman_factory.gd` | **Phase 9.** Runtime entry point (`class_name StickmanFactory`, `extends RefCounted`); a static factory that turns a `.stk` file into a live, rigged `master_rig.tscn` instance. `load_stk(path)` reads + parses the file (`{}` + `push_warning` on failure); `spawn_from_data(stk_data)` instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, and returns the rig root **typed as `StickmanRig`** (the rig now carries the `StickmanRig` root script); `spawn(path)` chains them (`null` on empty data). **Not used by the editor.** |
|
||||||
| `res://scripts/test_harness.gd` | **Phase 9.** Standalone staging scene (run via **F6** on `res://scenes/test_harness.tscn`, not wired into the editor) for debugging bone scales, vector-drawing offsets, and IK limits in isolation. Top UI bar: "Open .stk…" / quick-select buttons (`stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`), "Show Bones" / "Show IK Handles" toggles, loaded-filename label. `SubViewport` world + enabled `Camera2D` (middle-mouse pan, wheel zoom, recenter on spawn); each load frees the previous rig and spawns a fresh one via `StickmanFactory.spawn()`. A world-space debug overlay draws true bone segments (joint dots + parent→child lines, with limb leaf bones drawn out to their IK targets so wrist/ankle joints are visible; the **Head** leaf is the exception — its target is a LookAt aim point, not a joint, so it draws a ~90 px segment along the bone's own direction instead) and colored IK-target markers (hands green, feet blue, head yellow, torso magenta) plus a semi-transparent yellow head-aim line; the **6** `Marker2D` IK targets are click-draggable — the 4 limb targets flex limbs live via `SkeletonModificationStack2D` TwoBoneIK (enabled after each spawn), the Torso target translates the whole rig via its `RemoteTransform2D`, and the Head target drives the head's LookAt aim rotation (Phase 9 Round 7). |
|
| `res://scripts/stickman_rig.gd` | **Phase 9 Task 4.** `class_name StickmanRig`, `extends Node2D`; the runtime owner of facing direction, per-joint bone bend, and `Body/*` z-order, attached to the `master_rig.tscn` root `Master`. Exports a `facing_profile` preset (`FacingProfile` LEFT/RIGHT/FORWARD, default FORWARD) and four `@export_enum("Normal","Inverted")` per-joint bend vars (`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend`). Non-`@tool`: resolves `Skeleton2D`/`Body`/bend joints at runtime, enables its own modification stack, and applies the profile (flag writes + `Body/*` reorder) in `_ready()` and setters. Signals `facing_profile_changed` / `bend_flag_changed`; public API `set_facing_profile`/`get_facing_profile`, `set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, `get_bend_joint_global_position()`. Null-guarded (`push_warning` + skip). **Not used by the editor.** |
|
||||||
|
| `res://scripts/test_harness.gd` | **Phase 9.** Standalone staging scene (run via **F6** on `res://scenes/test_harness.tscn`, not wired into the editor) for debugging bone scales, vector-drawing offsets, and IK limits in isolation. Top UI bar: "Open .stk…" / quick-select buttons (`stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`), "Show Bones" / "Show IK Handles" toggles, loaded-filename label. `SubViewport` world + enabled `Camera2D` (middle-mouse pan, wheel zoom, recenter on spawn); each load frees the previous rig and spawns a fresh one via `StickmanFactory.spawn()`. A world-space debug overlay draws true bone segments (joint dots + parent→child lines, with limb leaf bones drawn out to their IK targets so wrist/ankle joints are visible; the **Head** leaf is the exception — its target is a LookAt aim point, not a joint, so it draws a ~90 px segment along the bone's own direction instead) and colored IK-target markers (hands green, feet blue, head yellow, torso magenta) plus a semi-transparent yellow head-aim line; the **6** `Marker2D` IK targets are click-draggable — the 4 limb targets flex limbs live via `SkeletonModificationStack2D` TwoBoneIK (the rig self-enables its stack), the Torso target translates the whole rig via its `RemoteTransform2D`, and the Head target drives the head's LookAt aim rotation (Phase 9 Round 7). |
|
||||||
| `res://scenes/test_harness.tscn` | **Phase 9.** Standalone staging scene backing `scripts/test_harness.gd` (run via **F6**; not wired into the editor). |
|
| `res://scenes/test_harness.tscn` | **Phase 9.** Standalone staging scene backing `scripts/test_harness.gd` (run via **F6**; not wired into the editor). |
|
||||||
| `res://scripts/body_part_panel.gd` | Multi-shape creation, vertex editing, shape dragging, per-panel zoom & pan, grid drawing & snap-to-grid, ColorPicker, shape/vertex delete, Z-ordering (Send Back / Bring Forward), shape Copy/Paste, shape Mirror X/Y, drawing (fill + outline for closed shapes). |
|
| `res://scripts/body_part_panel.gd` | Multi-shape creation, vertex editing, shape dragging, per-panel zoom & pan, grid drawing & snap-to-grid, ColorPicker, shape/vertex delete, Z-ordering (Send Back / Bring Forward), shape Copy/Paste, shape Mirror X/Y, drawing (fill + outline for closed shapes). |
|
||||||
| `res://scripts/whole_stickman_preview.gd` | Assembly preview, drag-to-reposition, part selection with white bounding box, rotation gizmo (circle below box) with Ctrl 15° snap, scale gizmo (corner crosses) with Ctrl aspect lock, part Z-ordering (Send Back / Bring Forward) via `part_order`, part Mirror X/Y (scale negation), zoom & pan, grid drawing & snap-to-grid, pose silhouette guide (Phase 7), part hit-bounds, labels, and (Phase 9 Round 5) `get_guide_joint_preview()` — the preview-space position of a guide joint, used by the editor to export per-part `guide_offset`. |
|
| `res://scripts/whole_stickman_preview.gd` | Assembly preview, drag-to-reposition, part selection with white bounding box, rotation gizmo (circle below box) with Ctrl 15° snap, scale gizmo (corner crosses) with Ctrl aspect lock, part Z-ordering (Send Back / Bring Forward) via `part_order`, part Mirror X/Y (scale negation), zoom & pan, grid drawing & snap-to-grid, pose silhouette guide (Phase 7), part hit-bounds, labels, and (Phase 9 Round 5) `get_guide_joint_preview()` — the preview-space position of a guide joint, used by the editor to export per-part `guide_offset`. |
|
||||||
|
|||||||
+16
@@ -98,5 +98,21 @@ The display should be easy to read to the right side of the screen. There should
|
|||||||
|
|
||||||
## Task 4: Refactor facing direction / bone bend
|
## Task 4: Refactor facing direction / bone bend
|
||||||
|
|
||||||
|
**Implemented (Phase 9 Task 4)** — see `docs/phase9_task4_refactor_spec.md`; a new
|
||||||
|
`scripts/stickman_rig.gd` (`class_name StickmanRig`, attached to the `master_rig.tscn` root)
|
||||||
|
now owns the facing preset, the four per-joint TwoBoneIK bend flags, and the `Body/*` z-order
|
||||||
|
as exported controls; the test harness became a thin driver that reads/writes the rig script.
|
||||||
|
|
||||||
We need to have facing direction and bone bend to be a part of the master_rig.tscn. This rig will be used for instances of stickmen which will be able to face different directions / have different bone bending at a time.
|
We need to have facing direction and bone bend to be a part of the master_rig.tscn. This rig will be used for instances of stickmen which will be able to face different directions / have different bone bending at a time.
|
||||||
So rather than keeping that data in the harness, we need it in the master rig. These should probably be exported controls too so that they can easily be accessed.
|
So rather than keeping that data in the harness, we need it in the master rig. These should probably be exported controls too so that they can easily be accessed.
|
||||||
|
|
||||||
|
## Task 5: Rig animation
|
||||||
|
|
||||||
|
**Implemented (Phase 9 Task 5)** — see `docs/phase9_task5_animation_spec.md`; the harness adds a dropdown, play/pause/resume, stop, and loop controls that drive the rig's `AnimationPlayer` directly.
|
||||||
|
|
||||||
|
Both AnimationPlayer and AnimationTree has been added to the master_rig.tscn. Animations should be selectable and playable in the harness.
|
||||||
|
|
||||||
|
- Default animations are inside the AnimationPlayer: currently just 'walk_right'
|
||||||
|
- There should be a dropdown in the harness where the user can select an animation.
|
||||||
|
- There should also be a play/pause/stop button that will start the animation, pause/resume, or stop (animation will restart on play)
|
||||||
|
- there should be a button that toggles if the animation will loop or play just once.
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
+166
-39
@@ -1,5 +1,7 @@
|
|||||||
[gd_scene format=3 uid="uid://dr5ef3l2var2s"]
|
[gd_scene format=3 uid="uid://dr5ef3l2var2s"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" uid="uid://b4rsae8suaxj7" path="res://scripts/stickman_rig.gd" id="1_rig_script"]
|
||||||
|
|
||||||
[sub_resource type="GDScript" id="GDScript_f0s26"]
|
[sub_resource type="GDScript" id="GDScript_f0s26"]
|
||||||
script/source = "@tool
|
script/source = "@tool
|
||||||
extends Node2D
|
extends Node2D
|
||||||
@@ -28,7 +30,6 @@ joint_two_bone2d_node = NodePath("Torso/RightUpperArm/RightLowerArm")
|
|||||||
|
|
||||||
[sub_resource type="SkeletonModification2DTwoBoneIK" id="SkeletonModification2DTwoBoneIK_f0s26"]
|
[sub_resource type="SkeletonModification2DTwoBoneIK" id="SkeletonModification2DTwoBoneIK_f0s26"]
|
||||||
target_nodepath = NodePath("../IK_Targets/Left_Hand")
|
target_nodepath = NodePath("../IK_Targets/Left_Hand")
|
||||||
flip_bend_direction = true
|
|
||||||
joint_one_bone_idx = 2
|
joint_one_bone_idx = 2
|
||||||
joint_one_bone2d_node = NodePath("Torso/LeftUpperArm")
|
joint_one_bone2d_node = NodePath("Torso/LeftUpperArm")
|
||||||
joint_two_bone_idx = 3
|
joint_two_bone_idx = 3
|
||||||
@@ -43,6 +44,7 @@ joint_two_bone2d_node = NodePath("Torso/RightUpperLeg/RightLowerLeg")
|
|||||||
|
|
||||||
[sub_resource type="SkeletonModification2DTwoBoneIK" id="SkeletonModification2DTwoBoneIK_2leu7"]
|
[sub_resource type="SkeletonModification2DTwoBoneIK" id="SkeletonModification2DTwoBoneIK_2leu7"]
|
||||||
target_nodepath = NodePath("../IK_Targets/Left_Leg")
|
target_nodepath = NodePath("../IK_Targets/Left_Leg")
|
||||||
|
flip_bend_direction = true
|
||||||
joint_one_bone_idx = 6
|
joint_one_bone_idx = 6
|
||||||
joint_one_bone2d_node = NodePath("Torso/LeftUpperLeg")
|
joint_one_bone2d_node = NodePath("Torso/LeftUpperLeg")
|
||||||
joint_two_bone_idx = 7
|
joint_two_bone_idx = 7
|
||||||
@@ -59,6 +61,7 @@ constraint_angle_invert = true
|
|||||||
constraint_in_localspace = true
|
constraint_in_localspace = true
|
||||||
|
|
||||||
[sub_resource type="SkeletonModificationStack2D" id="SkeletonModificationStack2D_j4hao"]
|
[sub_resource type="SkeletonModificationStack2D" id="SkeletonModificationStack2D_j4hao"]
|
||||||
|
enabled = true
|
||||||
modification_count = 5
|
modification_count = 5
|
||||||
modifications/0 = SubResource("SkeletonModification2DTwoBoneIK_yvxej")
|
modifications/0 = SubResource("SkeletonModification2DTwoBoneIK_yvxej")
|
||||||
modifications/1 = SubResource("SkeletonModification2DTwoBoneIK_f0s26")
|
modifications/1 = SubResource("SkeletonModification2DTwoBoneIK_f0s26")
|
||||||
@@ -68,6 +71,18 @@ modifications/4 = SubResource("SkeletonModification2DLookAt_j4hao")
|
|||||||
|
|
||||||
[sub_resource type="Animation" id="Animation_2leu7"]
|
[sub_resource type="Animation" id="Animation_2leu7"]
|
||||||
length = 0.001
|
length = 0.001
|
||||||
|
tracks/0/type = "value"
|
||||||
|
tracks/0/imported = false
|
||||||
|
tracks/0/enabled = true
|
||||||
|
tracks/0/path = NodePath(".:facing_profile")
|
||||||
|
tracks/0/interp = 1
|
||||||
|
tracks/0/loop_wrap = true
|
||||||
|
tracks/0/keys = {
|
||||||
|
"times": PackedFloat32Array(0),
|
||||||
|
"transitions": PackedFloat32Array(1),
|
||||||
|
"update": 1,
|
||||||
|
"values": [2]
|
||||||
|
}
|
||||||
|
|
||||||
[sub_resource type="Animation" id="Animation_ylko5"]
|
[sub_resource type="Animation" id="Animation_ylko5"]
|
||||||
length = 0.8
|
length = 0.8
|
||||||
@@ -75,67 +90,67 @@ loop_mode = 1
|
|||||||
tracks/0/type = "value"
|
tracks/0/type = "value"
|
||||||
tracks/0/imported = false
|
tracks/0/imported = false
|
||||||
tracks/0/enabled = true
|
tracks/0/enabled = true
|
||||||
tracks/0/path = NodePath("IK_Targets/Torso:position")
|
tracks/0/path = NodePath(".:facing_profile")
|
||||||
tracks/0/interp = 2
|
tracks/0/interp = 1
|
||||||
tracks/0/loop_wrap = true
|
tracks/0/loop_wrap = true
|
||||||
tracks/0/keys = {
|
tracks/0/keys = {
|
||||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
"times": PackedFloat32Array(0),
|
||||||
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
"transitions": PackedFloat32Array(1),
|
||||||
"update": 0,
|
"update": 1,
|
||||||
"values": [Vector2(0, 10), Vector2(0, -15), Vector2(0, 10), Vector2(0, -15), Vector2(0, 10)]
|
"values": [0]
|
||||||
}
|
}
|
||||||
tracks/1/type = "value"
|
tracks/1/type = "value"
|
||||||
tracks/1/imported = false
|
tracks/1/imported = false
|
||||||
tracks/1/enabled = true
|
tracks/1/enabled = true
|
||||||
tracks/1/path = NodePath("IK_Targets/Head:position")
|
tracks/1/path = NodePath("IK_Targets/Torso:position")
|
||||||
tracks/1/interp = 2
|
tracks/1/interp = 2
|
||||||
tracks/1/loop_wrap = true
|
tracks/1/loop_wrap = true
|
||||||
tracks/1/keys = {
|
tracks/1/keys = {
|
||||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
||||||
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||||
"update": 0,
|
"update": 0,
|
||||||
"values": [Vector2(100, -614), Vector2(100, -639), Vector2(100, -614), Vector2(100, -639), Vector2(100, -614)]
|
"values": [Vector2(0, 10), Vector2(0, -15), Vector2(0, 10), Vector2(0, -15), Vector2(0, 10)]
|
||||||
}
|
}
|
||||||
tracks/2/type = "value"
|
tracks/2/type = "value"
|
||||||
tracks/2/imported = false
|
tracks/2/imported = false
|
||||||
tracks/2/enabled = true
|
tracks/2/enabled = true
|
||||||
tracks/2/path = NodePath("IK_Targets/Right_Leg:position")
|
tracks/2/path = NodePath("IK_Targets/Head:position")
|
||||||
tracks/2/interp = 2
|
tracks/2/interp = 2
|
||||||
tracks/2/loop_wrap = true
|
tracks/2/loop_wrap = true
|
||||||
tracks/2/keys = {
|
tracks/2/keys = {
|
||||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
||||||
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||||
"update": 0,
|
"update": 0,
|
||||||
"values": [Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390)]
|
"values": [Vector2(-100, -614), Vector2(-100, -639), Vector2(-100, -614), Vector2(-100, -639), Vector2(-100, -614)]
|
||||||
}
|
}
|
||||||
tracks/3/type = "value"
|
tracks/3/type = "value"
|
||||||
tracks/3/imported = false
|
tracks/3/imported = false
|
||||||
tracks/3/enabled = true
|
tracks/3/enabled = true
|
||||||
tracks/3/path = NodePath("IK_Targets/Left_Leg:position")
|
tracks/3/path = NodePath("IK_Targets/Right_Leg:position")
|
||||||
tracks/3/interp = 2
|
tracks/3/interp = 2
|
||||||
tracks/3/loop_wrap = true
|
tracks/3/loop_wrap = true
|
||||||
tracks/3/keys = {
|
tracks/3/keys = {
|
||||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
||||||
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||||
"update": 0,
|
"update": 0,
|
||||||
"values": [Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380)]
|
"values": [Vector2(-110, 390), Vector2(0, 397), Vector2(110, 380), Vector2(20, 320), Vector2(-110, 390)]
|
||||||
}
|
}
|
||||||
tracks/4/type = "value"
|
tracks/4/type = "value"
|
||||||
tracks/4/imported = false
|
tracks/4/imported = false
|
||||||
tracks/4/enabled = true
|
tracks/4/enabled = true
|
||||||
tracks/4/path = NodePath("IK_Targets/Right_Hand:position")
|
tracks/4/path = NodePath("IK_Targets/Left_Leg:position")
|
||||||
tracks/4/interp = 2
|
tracks/4/interp = 2
|
||||||
tracks/4/loop_wrap = true
|
tracks/4/loop_wrap = true
|
||||||
tracks/4/keys = {
|
tracks/4/keys = {
|
||||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
||||||
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||||
"update": 0,
|
"update": 0,
|
||||||
"values": [Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110)]
|
"values": [Vector2(110, 380), Vector2(20, 320), Vector2(-110, 390), Vector2(0, 397), Vector2(110, 380)]
|
||||||
}
|
}
|
||||||
tracks/5/type = "value"
|
tracks/5/type = "value"
|
||||||
tracks/5/imported = false
|
tracks/5/imported = false
|
||||||
tracks/5/enabled = true
|
tracks/5/enabled = true
|
||||||
tracks/5/path = NodePath("IK_Targets/Left_Hand:position")
|
tracks/5/path = NodePath("IK_Targets/Right_Hand:position")
|
||||||
tracks/5/interp = 2
|
tracks/5/interp = 2
|
||||||
tracks/5/loop_wrap = true
|
tracks/5/loop_wrap = true
|
||||||
tracks/5/keys = {
|
tracks/5/keys = {
|
||||||
@@ -144,25 +159,121 @@ tracks/5/keys = {
|
|||||||
"update": 0,
|
"update": 0,
|
||||||
"values": [Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110)]
|
"values": [Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110)]
|
||||||
}
|
}
|
||||||
|
tracks/6/type = "value"
|
||||||
|
tracks/6/imported = false
|
||||||
|
tracks/6/enabled = true
|
||||||
|
tracks/6/path = NodePath("IK_Targets/Left_Hand:position")
|
||||||
|
tracks/6/interp = 2
|
||||||
|
tracks/6/loop_wrap = true
|
||||||
|
tracks/6/keys = {
|
||||||
|
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
||||||
|
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||||
|
"update": 0,
|
||||||
|
"values": [Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110)]
|
||||||
|
}
|
||||||
|
|
||||||
|
[sub_resource type="Animation" id="Animation_t75yq"]
|
||||||
|
length = 0.8
|
||||||
|
loop_mode = 1
|
||||||
|
tracks/0/type = "value"
|
||||||
|
tracks/0/imported = false
|
||||||
|
tracks/0/enabled = true
|
||||||
|
tracks/0/path = NodePath(".:facing_profile")
|
||||||
|
tracks/0/interp = 1
|
||||||
|
tracks/0/loop_wrap = true
|
||||||
|
tracks/0/keys = {
|
||||||
|
"times": PackedFloat32Array(0),
|
||||||
|
"transitions": PackedFloat32Array(1),
|
||||||
|
"update": 1,
|
||||||
|
"values": [1]
|
||||||
|
}
|
||||||
|
tracks/1/type = "value"
|
||||||
|
tracks/1/imported = false
|
||||||
|
tracks/1/enabled = true
|
||||||
|
tracks/1/path = NodePath("IK_Targets/Torso:position")
|
||||||
|
tracks/1/interp = 2
|
||||||
|
tracks/1/loop_wrap = true
|
||||||
|
tracks/1/keys = {
|
||||||
|
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
||||||
|
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||||
|
"update": 0,
|
||||||
|
"values": [Vector2(0, 10), Vector2(0, -15), Vector2(0, 10), Vector2(0, -15), Vector2(0, 10)]
|
||||||
|
}
|
||||||
|
tracks/2/type = "value"
|
||||||
|
tracks/2/imported = false
|
||||||
|
tracks/2/enabled = true
|
||||||
|
tracks/2/path = NodePath("IK_Targets/Head:position")
|
||||||
|
tracks/2/interp = 2
|
||||||
|
tracks/2/loop_wrap = true
|
||||||
|
tracks/2/keys = {
|
||||||
|
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
||||||
|
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||||
|
"update": 0,
|
||||||
|
"values": [Vector2(100, -614), Vector2(100, -639), Vector2(100, -614), Vector2(100, -639), Vector2(100, -614)]
|
||||||
|
}
|
||||||
|
tracks/3/type = "value"
|
||||||
|
tracks/3/imported = false
|
||||||
|
tracks/3/enabled = true
|
||||||
|
tracks/3/path = NodePath("IK_Targets/Right_Leg:position")
|
||||||
|
tracks/3/interp = 2
|
||||||
|
tracks/3/loop_wrap = true
|
||||||
|
tracks/3/keys = {
|
||||||
|
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
||||||
|
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||||
|
"update": 0,
|
||||||
|
"values": [Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390)]
|
||||||
|
}
|
||||||
|
tracks/4/type = "value"
|
||||||
|
tracks/4/imported = false
|
||||||
|
tracks/4/enabled = true
|
||||||
|
tracks/4/path = NodePath("IK_Targets/Left_Leg:position")
|
||||||
|
tracks/4/interp = 2
|
||||||
|
tracks/4/loop_wrap = true
|
||||||
|
tracks/4/keys = {
|
||||||
|
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
||||||
|
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||||
|
"update": 0,
|
||||||
|
"values": [Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380)]
|
||||||
|
}
|
||||||
|
tracks/5/type = "value"
|
||||||
|
tracks/5/imported = false
|
||||||
|
tracks/5/enabled = true
|
||||||
|
tracks/5/path = NodePath("IK_Targets/Right_Hand:position")
|
||||||
|
tracks/5/interp = 2
|
||||||
|
tracks/5/loop_wrap = true
|
||||||
|
tracks/5/keys = {
|
||||||
|
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
||||||
|
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||||
|
"update": 0,
|
||||||
|
"values": [Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110)]
|
||||||
|
}
|
||||||
|
tracks/6/type = "value"
|
||||||
|
tracks/6/imported = false
|
||||||
|
tracks/6/enabled = true
|
||||||
|
tracks/6/path = NodePath("IK_Targets/Left_Hand:position")
|
||||||
|
tracks/6/interp = 2
|
||||||
|
tracks/6/loop_wrap = true
|
||||||
|
tracks/6/keys = {
|
||||||
|
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6, 0.8),
|
||||||
|
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||||
|
"update": 0,
|
||||||
|
"values": [Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110)]
|
||||||
|
}
|
||||||
|
|
||||||
[sub_resource type="AnimationLibrary" id="AnimationLibrary_t75yq"]
|
[sub_resource type="AnimationLibrary" id="AnimationLibrary_t75yq"]
|
||||||
_data = {
|
_data = {
|
||||||
&"RESET": SubResource("Animation_2leu7"),
|
&"RESET": SubResource("Animation_2leu7"),
|
||||||
&"walk_right": SubResource("Animation_ylko5")
|
&"walk_left": SubResource("Animation_ylko5"),
|
||||||
|
&"walk_right": SubResource("Animation_t75yq")
|
||||||
}
|
}
|
||||||
|
|
||||||
[sub_resource type="AnimationNodeStateMachine" id="AnimationNodeStateMachine_6rw38"]
|
[sub_resource type="AnimationNodeStateMachine" id="AnimationNodeStateMachine_6rw38"]
|
||||||
|
|
||||||
[node name="Master" type="Node2D" unique_id=319103476]
|
[node name="Master" type="Node2D" unique_id=319103476]
|
||||||
|
script = ExtResource("1_rig_script")
|
||||||
|
|
||||||
[node name="Body" type="Node2D" parent="." unique_id=327811712]
|
[node name="Body" type="Node2D" parent="." unique_id=327811712]
|
||||||
|
|
||||||
[node name="Head" type="Node2D" parent="Body" unique_id=864822355]
|
|
||||||
position = Vector2(-0.03859164, -453.50787)
|
|
||||||
rotation = 0.00055408623
|
|
||||||
scale = Vector2(0.9999996, 0.9999996)
|
|
||||||
script = SubResource("GDScript_f0s26")
|
|
||||||
|
|
||||||
[node name="Body" type="Line2D" parent="Body" unique_id=1290443463]
|
[node name="Body" type="Line2D" parent="Body" unique_id=1290443463]
|
||||||
position = Vector2(9.313226e-10, 10.000001)
|
position = Vector2(9.313226e-10, 10.000001)
|
||||||
rotation = -3.1415925
|
rotation = -3.1415925
|
||||||
@@ -171,64 +282,71 @@ width = 16.0
|
|||||||
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
||||||
|
|
||||||
[node name="LeftUpperLeg" type="Line2D" parent="Body" unique_id=199373156]
|
[node name="LeftUpperLeg" type="Line2D" parent="Body" unique_id=199373156]
|
||||||
position = Vector2(1.6098846e-05, 10.000002)
|
position = Vector2(1.6037084e-05, 10.000003)
|
||||||
rotation = 0.4947604
|
rotation = 0.55427897
|
||||||
points = PackedVector2Array(0, 0, 0, 200)
|
points = PackedVector2Array(0, 0, 0, 200)
|
||||||
width = 16.0
|
width = 16.0
|
||||||
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
||||||
|
|
||||||
[node name="RightUpperLeg" type="Line2D" parent="Body" unique_id=1396556308]
|
[node name="RightUpperLeg" type="Line2D" parent="Body" unique_id=1396556308]
|
||||||
position = Vector2(-2.3252098e-05, 10.000022)
|
position = Vector2(-2.4553774e-05, 10.000021)
|
||||||
rotation = -0.49392816
|
rotation = -0.43021643
|
||||||
scale = Vector2(0.99999994, 0.99999994)
|
scale = Vector2(0.99999994, 0.99999994)
|
||||||
points = PackedVector2Array(0, 0, 0, 200)
|
points = PackedVector2Array(0, 0, 0, 200)
|
||||||
width = 16.0
|
width = 16.0
|
||||||
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
||||||
|
|
||||||
[node name="LeftLowerLeg" type="Line2D" parent="Body" unique_id=1590485904]
|
[node name="LeftLowerLeg" type="Line2D" parent="Body" unique_id=1590485904]
|
||||||
position = Vector2(-94.96416, 186.0165)
|
position = Vector2(-105.26607, 180.05603)
|
||||||
rotation = 0.0051915743
|
rotation = 0.023672067
|
||||||
scale = Vector2(0.99999994, 0.99999994)
|
scale = Vector2(0.99999994, 0.99999994)
|
||||||
points = PackedVector2Array(0, 0, 0, 200)
|
points = PackedVector2Array(0, 0, 0, 200)
|
||||||
width = 16.0
|
width = 16.0
|
||||||
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
||||||
|
|
||||||
[node name="RightLowerLeg" type="Line2D" parent="Body" unique_id=29203948]
|
[node name="RightLowerLeg" type="Line2D" parent="Body" unique_id=29203948]
|
||||||
position = Vector2(94.817635, 186.09546)
|
position = Vector2(83.4135, 191.77509)
|
||||||
rotation = -0.0034907677
|
rotation = -0.13332568
|
||||||
scale = Vector2(0.9999998, 0.9999998)
|
scale = Vector2(0.9999997, 0.9999997)
|
||||||
points = PackedVector2Array(0, 0, 0, 200)
|
points = PackedVector2Array(0, 0, 0, 200)
|
||||||
width = 16.0
|
width = 16.0
|
||||||
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
||||||
|
|
||||||
[node name="LeftUpperArm" type="Line2D" parent="Body" unique_id=744524157]
|
[node name="LeftUpperArm" type="Line2D" parent="Body" unique_id=744524157]
|
||||||
position = Vector2(9.313226e-10, -238)
|
position = Vector2(9.313226e-10, -238)
|
||||||
rotation = 1.6184965
|
rotation = -0.48905078
|
||||||
points = PackedVector2Array(0, 0, 0, 175)
|
points = PackedVector2Array(0, 0, 0, 175)
|
||||||
width = 16.0
|
width = 16.0
|
||||||
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
||||||
|
|
||||||
[node name="RightUpperArm" type="Line2D" parent="Body" unique_id=1593646765]
|
[node name="RightUpperArm" type="Line2D" parent="Body" unique_id=1593646765]
|
||||||
position = Vector2(9.313226e-10, -238)
|
position = Vector2(9.313226e-10, -238)
|
||||||
rotation = -1.6184964
|
rotation = 0.48905125
|
||||||
|
scale = Vector2(0.99999994, 0.99999994)
|
||||||
points = PackedVector2Array(0, 0, 0, 175)
|
points = PackedVector2Array(0, 0, 0, 175)
|
||||||
width = 16.0
|
width = 16.0
|
||||||
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
||||||
|
|
||||||
[node name="LeftLowerArm" type="Line2D" parent="Body" unique_id=2142117097]
|
[node name="LeftLowerArm" type="Line2D" parent="Body" unique_id=2142117097]
|
||||||
position = Vector2(-167.80888, -246.01059)
|
position = Vector2(78.92438, -89.6931)
|
||||||
rotation = 3.1406152
|
rotation = -0.055406693
|
||||||
points = PackedVector2Array(0, 0, 0, 200)
|
points = PackedVector2Array(0, 0, 0, 200)
|
||||||
width = 16.0
|
width = 16.0
|
||||||
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
||||||
|
|
||||||
[node name="RightLowerArm" type="Line2D" parent="Body" unique_id=1200980480]
|
[node name="RightLowerArm" type="Line2D" parent="Body" unique_id=1200980480]
|
||||||
position = Vector2(167.80891, -246.01057)
|
position = Vector2(-78.924484, -89.69313)
|
||||||
rotation = -3.1406155
|
rotation = 0.055405635
|
||||||
points = PackedVector2Array(0, 0, 0, 200)
|
points = PackedVector2Array(0, 0, 0, 200)
|
||||||
width = 16.0
|
width = 16.0
|
||||||
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
default_color = Color(0.445488, 0.445488, 0.445488, 1)
|
||||||
|
|
||||||
|
[node name="Head" type="Node2D" parent="Body" unique_id=864822355]
|
||||||
|
position = Vector2(28.397142, -447.61594)
|
||||||
|
rotation = 0.40666866
|
||||||
|
scale = Vector2(0.9999996, 0.9999996)
|
||||||
|
script = SubResource("GDScript_f0s26")
|
||||||
|
|
||||||
[node name="Skeleton2D" type="Skeleton2D" parent="." unique_id=1854735445]
|
[node name="Skeleton2D" type="Skeleton2D" parent="." unique_id=1854735445]
|
||||||
rotation = -0.0006991282
|
rotation = -0.0006991282
|
||||||
modification_stack = SubResource("SkeletonModificationStack2D_j4hao")
|
modification_stack = SubResource("SkeletonModificationStack2D_j4hao")
|
||||||
@@ -240,16 +358,17 @@ rest = Transform2D(0.99999976, 0.00069912814, -0.00069912814, 0.99999976, 0, 0)
|
|||||||
|
|
||||||
[node name="Head" type="Bone2D" parent="Skeleton2D/Torso" unique_id=1487357366]
|
[node name="Head" type="Bone2D" parent="Skeleton2D/Torso" unique_id=1487357366]
|
||||||
position = Vector2(-0.12882307, -391.5079)
|
position = Vector2(-0.12882307, -391.5079)
|
||||||
rotation = 0.00055408623
|
|
||||||
scale = Vector2(0.9999996, 0.9999996)
|
scale = Vector2(0.9999996, 0.9999996)
|
||||||
rest = Transform2D(0.9998273, 0.0005539903, -0.0005539903, 0.9998273, -0.12882307, -391.5079)
|
rest = Transform2D(0.9998273, 0.0005539903, -0.0005539903, 0.9998273, -0.12882307, -391.5079)
|
||||||
auto_calculate_length_and_angle = false
|
auto_calculate_length_and_angle = false
|
||||||
length = 90.0
|
length = 90.0
|
||||||
bone_angle = -90.0
|
bone_angle = -90.0
|
||||||
|
metadata/_local_pose_override_enabled_ = true
|
||||||
|
|
||||||
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/Head" unique_id=396112729]
|
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/Head" unique_id=396112729]
|
||||||
position = Vector2(0.050337255, -72.00002)
|
position = Vector2(0.050337255, -72.00002)
|
||||||
remote_path = NodePath("../../../../Body/Head")
|
remote_path = NodePath("../../../../Body/Head")
|
||||||
|
update_scale = false
|
||||||
|
|
||||||
[node name="RayCast_Aim" type="RayCast2D" parent="Skeleton2D/Torso/Head" unique_id=2000000004]
|
[node name="RayCast_Aim" type="RayCast2D" parent="Skeleton2D/Torso/Head" unique_id=2000000004]
|
||||||
position = Vector2(0, -90)
|
position = Vector2(0, -90)
|
||||||
@@ -262,6 +381,7 @@ rest = Transform2D(0.9988364, 0.04768082, -0.04768082, 0.9988364, 0, -248)
|
|||||||
auto_calculate_length_and_angle = false
|
auto_calculate_length_and_angle = false
|
||||||
length = 168.0
|
length = 168.0
|
||||||
bone_angle = -180.0
|
bone_angle = -180.0
|
||||||
|
metadata/_local_pose_override_enabled_ = true
|
||||||
|
|
||||||
[node name="LeftLowerArm" type="Bone2D" parent="Skeleton2D/Torso/LeftUpperArm" unique_id=721380545]
|
[node name="LeftLowerArm" type="Bone2D" parent="Skeleton2D/Torso/LeftUpperArm" unique_id=721380545]
|
||||||
position = Vector2(-168, 0)
|
position = Vector2(-168, 0)
|
||||||
@@ -270,6 +390,7 @@ rest = Transform2D(0.9987903, -0.04865717, 0.04865717, 0.9987903, -168, 0)
|
|||||||
auto_calculate_length_and_angle = false
|
auto_calculate_length_and_angle = false
|
||||||
length = 200.0
|
length = 200.0
|
||||||
bone_angle = -90.0
|
bone_angle = -90.0
|
||||||
|
metadata/_local_pose_override_enabled_ = true
|
||||||
|
|
||||||
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/LeftUpperArm/LeftLowerArm" unique_id=1189277122]
|
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/LeftUpperArm/LeftLowerArm" unique_id=1189277122]
|
||||||
position = Vector2(3.0517578e-05, 1.9073486e-06)
|
position = Vector2(3.0517578e-05, 1.9073486e-06)
|
||||||
@@ -287,6 +408,7 @@ rest = Transform2D(0.9988364, -0.047680702, 0.047680702, 0.9988364, 0, -248)
|
|||||||
auto_calculate_length_and_angle = false
|
auto_calculate_length_and_angle = false
|
||||||
length = 168.00006
|
length = 168.00006
|
||||||
bone_angle = 0.0
|
bone_angle = 0.0
|
||||||
|
metadata/_local_pose_override_enabled_ = true
|
||||||
|
|
||||||
[node name="RightLowerArm" type="Bone2D" parent="Skeleton2D/Torso/RightUpperArm" unique_id=1018820563]
|
[node name="RightLowerArm" type="Bone2D" parent="Skeleton2D/Torso/RightUpperArm" unique_id=1018820563]
|
||||||
position = Vector2(168, 0)
|
position = Vector2(168, 0)
|
||||||
@@ -295,6 +417,7 @@ rest = Transform2D(0.048656836, -0.9987903, 0.9987903, 0.048656836, 168, 0)
|
|||||||
auto_calculate_length_and_angle = false
|
auto_calculate_length_and_angle = false
|
||||||
length = 200.0
|
length = 200.0
|
||||||
bone_angle = 0.0
|
bone_angle = 0.0
|
||||||
|
metadata/_local_pose_override_enabled_ = true
|
||||||
|
|
||||||
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/RightUpperArm/RightLowerArm" unique_id=1282597655]
|
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/RightUpperArm/RightLowerArm" unique_id=1282597655]
|
||||||
rotation = -1.5707964
|
rotation = -1.5707964
|
||||||
@@ -311,6 +434,7 @@ rest = Transform2D(0.88005984, 0.47480857, -0.47480857, 0.88005984, -9.536743e-0
|
|||||||
auto_calculate_length_and_angle = false
|
auto_calculate_length_and_angle = false
|
||||||
length = 200.0
|
length = 200.0
|
||||||
bone_angle = 90.0
|
bone_angle = 90.0
|
||||||
|
metadata/_local_pose_override_enabled_ = true
|
||||||
|
|
||||||
[node name="LeftLowerLeg" type="Bone2D" parent="Skeleton2D/Torso/LeftUpperLeg" unique_id=81288294]
|
[node name="LeftLowerLeg" type="Bone2D" parent="Skeleton2D/Torso/LeftUpperLeg" unique_id=81288294]
|
||||||
position = Vector2(0, 200)
|
position = Vector2(0, 200)
|
||||||
@@ -319,6 +443,7 @@ rest = Transform2D(0.4702357, 0.8825176, -0.8825176, 0.4702357, 0, 200)
|
|||||||
auto_calculate_length_and_angle = false
|
auto_calculate_length_and_angle = false
|
||||||
length = 200.0
|
length = 200.0
|
||||||
bone_angle = 0.0
|
bone_angle = 0.0
|
||||||
|
metadata/_local_pose_override_enabled_ = true
|
||||||
|
|
||||||
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg" unique_id=1673135542]
|
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg" unique_id=1673135542]
|
||||||
rotation = -1.5707964
|
rotation = -1.5707964
|
||||||
@@ -335,6 +460,7 @@ rest = Transform2D(0.8800552, -0.47482592, 0.47482592, 0.8800552, 0, 0)
|
|||||||
auto_calculate_length_and_angle = false
|
auto_calculate_length_and_angle = false
|
||||||
length = 200.0
|
length = 200.0
|
||||||
bone_angle = 90.0
|
bone_angle = 90.0
|
||||||
|
metadata/_local_pose_override_enabled_ = true
|
||||||
|
|
||||||
[node name="RightLowerLeg" type="Bone2D" parent="Skeleton2D/Torso/RightUpperLeg" unique_id=1819999778]
|
[node name="RightLowerLeg" type="Bone2D" parent="Skeleton2D/Torso/RightUpperLeg" unique_id=1819999778]
|
||||||
position = Vector2(0, 200)
|
position = Vector2(0, 200)
|
||||||
@@ -344,6 +470,7 @@ rest = Transform2D(-0.47026652, 0.8825017, -0.8825017, -0.47026652, 0, 200)
|
|||||||
auto_calculate_length_and_angle = false
|
auto_calculate_length_and_angle = false
|
||||||
length = 200.0
|
length = 200.0
|
||||||
bone_angle = 0.0
|
bone_angle = 0.0
|
||||||
|
metadata/_local_pose_override_enabled_ = true
|
||||||
|
|
||||||
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/RightUpperLeg/RightLowerLeg" unique_id=278100224]
|
[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/RightUpperLeg/RightLowerLeg" unique_id=278100224]
|
||||||
rotation = -1.5707964
|
rotation = -1.5707964
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
[gd_scene load_steps=2 format=3]
|
[gd_scene format=3 uid="uid://bleeb15l02i3d"]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://scripts/test_harness.gd" id="1_harness"]
|
[ext_resource type="Script" uid="uid://b7wkt57rkpfo4" path="res://scripts/test_harness.gd" id="1_harness"]
|
||||||
|
|
||||||
[node name="TestHarness" type="Control"]
|
[node name="TestHarness" type="Control" unique_id=724700228]
|
||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
anchors_preset = 15
|
anchors_preset = 15
|
||||||
anchor_right = 1.0
|
anchor_right = 1.0
|
||||||
|
|||||||
+42
-39
@@ -1,60 +1,63 @@
|
|||||||
@tool
|
@tool
|
||||||
extends Node2D
|
extends EditorScript
|
||||||
|
|
||||||
@export var build_walk_animation: bool = false:
|
func _run() -> void:
|
||||||
set(value):
|
var root = EditorInterface.get_edited_scene_root()
|
||||||
if value:
|
if not root:
|
||||||
_create_walk_right_animation()
|
push_error("EditorScript: No active scene open.")
|
||||||
|
|
||||||
func _create_walk_right_animation() -> void:
|
|
||||||
var anim_player: AnimationPlayer = get_node_or_null("AnimationPlayer")
|
|
||||||
if not anim_player:
|
|
||||||
push_error("AnimationPlayer node not found as direct child of Master!")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
var anim_player = root.get_node_or_null("AnimationPlayer") as AnimationPlayer
|
||||||
|
if not anim_player:
|
||||||
|
push_error("EditorScript: AnimationPlayer node not found under root.")
|
||||||
|
return
|
||||||
|
|
||||||
|
_generate_walk_animation(anim_player, "walk_right", 1, false) # FacingProfile.RIGHT (1)
|
||||||
|
_generate_walk_animation(anim_player, "walk_left", 0, true) # FacingProfile.LEFT (0)
|
||||||
|
|
||||||
|
|
||||||
|
func _generate_walk_animation(anim_player: AnimationPlayer, anim_name: String, profile_enum: int, flip_x: bool) -> void:
|
||||||
var anim = Animation.new()
|
var anim = Animation.new()
|
||||||
anim.length = 0.8
|
anim.length = 0.8
|
||||||
anim.loop_mode = Animation.LOOP_LINEAR
|
anim.loop_mode = Animation.LOOP_LINEAR
|
||||||
|
var dir_mult: float = -1.0 if flip_x else 1.0
|
||||||
|
|
||||||
var times = [0.0, 0.2, 0.4, 0.6, 0.8]
|
# 1. Profile Track (0 = LEFT, 1 = RIGHT)
|
||||||
|
var profile_track = anim.add_track(Animation.TYPE_VALUE)
|
||||||
# Keyframe tracks mapped relative to root Master node
|
anim.track_set_path(profile_track, ".:facing_profile")
|
||||||
var tracks = {
|
anim.value_track_set_update_mode(profile_track, Animation.UPDATE_DISCRETE)
|
||||||
"IK_Targets/Torso:position": [
|
anim.track_insert_key(profile_track, 0.0, profile_enum)
|
||||||
Vector2(0, 10), Vector2(0, -15), Vector2(0, 10), Vector2(0, -15), Vector2(0, 10)
|
|
||||||
],
|
# 2. Keyframe positions
|
||||||
"IK_Targets/Head:position": [
|
var raw_tracks = {
|
||||||
Vector2(100, -614), Vector2(100, -639), Vector2(100, -614), Vector2(100, -639), Vector2(100, -614)
|
"IK_Targets/Torso:position": [Vector2(0, 10), Vector2(0, -15), Vector2(0, 10), Vector2(0, -15), Vector2(0, 10)],
|
||||||
],
|
"IK_Targets/Head:position": [Vector2(100, -614), Vector2(100, -639), Vector2(100, -614), Vector2(100, -639), Vector2(100, -614)],
|
||||||
"IK_Targets/Right_Leg:position": [
|
"IK_Targets/Right_Leg:position": [Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390)],
|
||||||
Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390)
|
"IK_Targets/Left_Leg:position": [Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380)],
|
||||||
],
|
"IK_Targets/Right_Hand:position": [Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110)],
|
||||||
"IK_Targets/Left_Leg:position": [
|
"IK_Targets/Left_Hand:position": [Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110)]
|
||||||
Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380)
|
|
||||||
],
|
|
||||||
"IK_Targets/Right_Hand:position": [
|
|
||||||
Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110)
|
|
||||||
],
|
|
||||||
"IK_Targets/Left_Hand:position": [
|
|
||||||
Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110), Vector2(0, 115), Vector2(-90, 110)
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for path in tracks:
|
var times = [0.0, 0.2, 0.4, 0.6, 0.8]
|
||||||
|
|
||||||
|
for path in raw_tracks:
|
||||||
var track_idx = anim.add_track(Animation.TYPE_VALUE)
|
var track_idx = anim.add_track(Animation.TYPE_VALUE)
|
||||||
anim.track_set_path(track_idx, path)
|
anim.track_set_path(track_idx, path)
|
||||||
anim.track_set_interpolation_type(track_idx, Animation.INTERPOLATION_CUBIC)
|
anim.track_set_interpolation_type(track_idx, Animation.INTERPOLATION_CUBIC)
|
||||||
|
|
||||||
for i in range(times.size()):
|
for i in range(times.size()):
|
||||||
anim.track_insert_key(track_idx, times[i], tracks[path][i])
|
var orig_pos: Vector2 = raw_tracks[path][i]
|
||||||
|
var mirrored_pos = Vector2(orig_pos.x * dir_mult, orig_pos.y)
|
||||||
|
anim.track_insert_key(track_idx, times[i], mirrored_pos)
|
||||||
|
|
||||||
# Add or retrieve default library
|
# 3. Attach animation to AnimationPlayer library
|
||||||
var lib = anim_player.get_animation_library("")
|
var lib = anim_player.get_animation_library("")
|
||||||
if not lib:
|
if not lib:
|
||||||
lib = AnimationLibrary.new()
|
lib = AnimationLibrary.new()
|
||||||
anim_player.add_animation_library("", lib)
|
anim_player.add_animation_library("", lib)
|
||||||
|
|
||||||
if lib.has_animation("walk_right"):
|
if lib.has_animation(anim_name):
|
||||||
lib.remove_animation("walk_right")
|
lib.remove_animation(anim_name)
|
||||||
|
|
||||||
lib.add_animation("walk_right", anim)
|
lib.add_animation(anim_name, anim)
|
||||||
print("✓ Successfully generated 'walk_right' animation in AnimationPlayer!")
|
print("Successfully generated '%s' animation!" % anim_name)
|
||||||
|
|||||||
@@ -34,13 +34,13 @@ static func load_stk(path: String) -> Dictionary:
|
|||||||
return parsed as Dictionary
|
return parsed as Dictionary
|
||||||
|
|
||||||
|
|
||||||
static func spawn_from_data(stk_data: Dictionary) -> Node2D:
|
static func spawn_from_data(stk_data: Dictionary) -> StickmanRig:
|
||||||
var rig := RIG_SCENE.instantiate() as Node2D
|
var rig := RIG_SCENE.instantiate() as StickmanRig
|
||||||
StkRigAdapter.apply(stk_data, rig)
|
StkRigAdapter.apply(stk_data, rig)
|
||||||
return rig
|
return rig
|
||||||
|
|
||||||
|
|
||||||
static func spawn(path: String) -> Node2D:
|
static func spawn(path: String) -> StickmanRig:
|
||||||
var data := load_stk(path)
|
var data := load_stk(path)
|
||||||
if data.is_empty():
|
if data.is_empty():
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b4rsae8suaxj7
|
||||||
+201
-148
@@ -28,7 +28,12 @@ const HANDLE_COLOR_HEAD := Color(1.00, 1.00, 0.00) # yellow
|
|||||||
const HANDLE_COLOR_TORSO := Color(1.00, 0.00, 1.00) # magenta
|
const HANDLE_COLOR_TORSO := Color(1.00, 0.00, 1.00) # magenta
|
||||||
|
|
||||||
const SKELETON_PATH := "Skeleton2D"
|
const SKELETON_PATH := "Skeleton2D"
|
||||||
const BODY_CONTAINER_PATH := "Body"
|
|
||||||
|
## AnimationPlayer node path (relative to rig root).
|
||||||
|
const ANIMATION_PLAYER_PATH := "AnimationPlayer"
|
||||||
|
|
||||||
|
## Initially-selected animation in the dropdown (RIGGING.md default).
|
||||||
|
const DEFAULT_ANIMATION := "walk_right"
|
||||||
|
|
||||||
## Width of the coordinates readout panel (Task 3).
|
## Width of the coordinates readout panel (Task 3).
|
||||||
const COORDS_PANEL_WIDTH: float = 320.0
|
const COORDS_PANEL_WIDTH: float = 320.0
|
||||||
@@ -80,63 +85,12 @@ const LEAF_BONE_IK_PATHS: Dictionary = {
|
|||||||
"RightLowerLeg": "IK_Targets/Right_Leg",
|
"RightLowerLeg": "IK_Targets/Right_Leg",
|
||||||
}
|
}
|
||||||
|
|
||||||
## Facing profiles for the rig's TwoBoneIK "Flip Bend Direction" flags (Phase
|
|
||||||
## 9 / Task 1). LEFT = facing left, RIGHT = facing right, FORWARD = facing the
|
|
||||||
## user. The enum values are used directly as the facing-menu item ids.
|
|
||||||
enum FacingProfile { LEFT, RIGHT, FORWARD }
|
|
||||||
|
|
||||||
const JOINT_HIT_RADIUS_PX: float = 14.0 # screen-space grab radius for bend joints
|
const JOINT_HIT_RADIUS_PX: float = 14.0 # screen-space grab radius for bend joints
|
||||||
|
|
||||||
## Bend joints (upper↔lower limb connectors) whose TwoBoneIK "Flip Bend
|
## Harness-tracked playback state (the harness is the sole driver of the
|
||||||
## Direction" flag is user-controllable. Keyed by joint name; values are the
|
## AnimationPlayer, so it tracks state authoritatively via button handlers and
|
||||||
## lower bone's NodePath relative to the Skeleton2D.
|
## the animation_finished signal rather than polling is_playing()).
|
||||||
const BEND_JOINTS: Array[String] = ["LeftArm", "RightArm", "LeftLeg", "RightLeg"]
|
enum PlaybackState { STOPPED, PLAYING, PAUSED }
|
||||||
|
|
||||||
const BEND_JOINT_BONE_PATHS: Dictionary = {
|
|
||||||
"LeftArm": "Torso/LeftUpperArm/LeftLowerArm",
|
|
||||||
"RightArm": "Torso/RightUpperArm/RightLowerArm",
|
|
||||||
"LeftLeg": "Torso/LeftUpperLeg/LeftLowerLeg",
|
|
||||||
"RightLeg": "Torso/RightUpperLeg/RightLowerLeg",
|
|
||||||
}
|
|
||||||
|
|
||||||
## flip_bend_direction value per facing profile, keyed by bend-joint name.
|
|
||||||
const PROFILE_FLAGS: Dictionary = {
|
|
||||||
FacingProfile.LEFT: { "LeftArm": false, "RightArm": false, "LeftLeg": true, "RightLeg": true },
|
|
||||||
FacingProfile.RIGHT: { "LeftArm": true, "RightArm": true, "LeftLeg": false, "RightLeg": false },
|
|
||||||
FacingProfile.FORWARD: { "LeftArm": false, "RightArm": true, "LeftLeg": true, "RightLeg": false },
|
|
||||||
}
|
|
||||||
|
|
||||||
## Body/* visual part node names in draw order (back-to-front) per facing
|
|
||||||
## profile (Phase 9 / Task 2). First entry draws backmost, last frontmost.
|
|
||||||
## Upper limbs stay behind lower limbs; on the far (behind-torso) side the arm
|
|
||||||
## pair draws behind the leg pair, on the near (in-front) side the arm pair
|
|
||||||
## draws in front of the leg pair; the head is always frontmost.
|
|
||||||
const Z_ORDER_BY_PROFILE: Dictionary = {
|
|
||||||
FacingProfile.FORWARD: [
|
|
||||||
"Body",
|
|
||||||
"LeftUpperLeg", "RightUpperLeg",
|
|
||||||
"LeftLowerLeg", "RightLowerLeg",
|
|
||||||
"LeftUpperArm", "RightUpperArm",
|
|
||||||
"LeftLowerArm", "RightLowerArm",
|
|
||||||
"Head",
|
|
||||||
],
|
|
||||||
FacingProfile.LEFT: [
|
|
||||||
"LeftUpperArm", "LeftLowerArm",
|
|
||||||
"LeftUpperLeg", "LeftLowerLeg",
|
|
||||||
"Body",
|
|
||||||
"RightUpperLeg", "RightLowerLeg",
|
|
||||||
"RightUpperArm", "RightLowerArm",
|
|
||||||
"Head",
|
|
||||||
],
|
|
||||||
FacingProfile.RIGHT: [
|
|
||||||
"RightUpperArm", "RightLowerArm",
|
|
||||||
"RightUpperLeg", "RightLowerLeg",
|
|
||||||
"Body",
|
|
||||||
"LeftUpperLeg", "LeftLowerLeg",
|
|
||||||
"LeftUpperArm", "LeftLowerArm",
|
|
||||||
"Head",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Runtime-built node references
|
# Runtime-built node references
|
||||||
@@ -151,14 +105,18 @@ var _status_label: Label
|
|||||||
var _file_dialog: FileDialog
|
var _file_dialog: FileDialog
|
||||||
var _coords_panel: PanelContainer
|
var _coords_panel: PanelContainer
|
||||||
var _coords_label: RichTextLabel
|
var _coords_label: RichTextLabel
|
||||||
|
var _anim_dropdown: OptionButton
|
||||||
|
var _play_button: Button
|
||||||
|
var _stop_button: Button
|
||||||
|
var _loop_check: CheckBox
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# State
|
# State
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
var _rig: Node2D = null
|
var _rig: Node2D = null
|
||||||
|
var _rig_script: StickmanRig = null
|
||||||
var _skeleton: Skeleton2D = null
|
var _skeleton: Skeleton2D = null
|
||||||
var _body_container: Node2D = null
|
|
||||||
var _ik_handles: Dictionary = {} # { String : Marker2D }
|
var _ik_handles: Dictionary = {} # { String : Marker2D }
|
||||||
var _coord_bones: Dictionary = {} # { String : Bone2D }
|
var _coord_bones: Dictionary = {} # { String : Bone2D }
|
||||||
|
|
||||||
@@ -170,15 +128,20 @@ var _is_panning: bool = false
|
|||||||
var _pan_last: Vector2 = Vector2.ZERO
|
var _pan_last: Vector2 = Vector2.ZERO
|
||||||
var _dragging_handle: Marker2D = null
|
var _dragging_handle: Marker2D = null
|
||||||
|
|
||||||
# Task 1: IK bend-direction state (survives respawn; matches authored defaults).
|
# UI mirror of the last-selected facing profile (persists across respawns; the
|
||||||
var _facing_profile: int = FacingProfile.FORWARD
|
# rig's StickmanRig script owns the actual flags/z-order). Used for the [√] menu
|
||||||
|
# prefix and re-application after each spawn.
|
||||||
|
var _facing_profile: int = StickmanRig.FacingProfile.FORWARD
|
||||||
var _context_joint: String = ""
|
var _context_joint: String = ""
|
||||||
var _bend_joint_bones: Dictionary = {} # { String : Bone2D }
|
|
||||||
var _bend_modifications: Dictionary = {} # { String : SkeletonModification2DTwoBoneIK }
|
|
||||||
var _facing_button: MenuButton
|
var _facing_button: MenuButton
|
||||||
var _facing_menu: PopupMenu
|
var _facing_menu: PopupMenu
|
||||||
var _context_menu: PopupMenu
|
var _context_menu: PopupMenu
|
||||||
|
|
||||||
|
var _anim_player: AnimationPlayer = null
|
||||||
|
var _selected_animation: String = ""
|
||||||
|
var _playback_state: int = PlaybackState.STOPPED
|
||||||
|
var _loop: bool = true # harness-level, persists across respawns (like _show_coords)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Lifecycle
|
# Lifecycle
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -222,13 +185,33 @@ func _build_ui() -> void:
|
|||||||
_facing_button = MenuButton.new()
|
_facing_button = MenuButton.new()
|
||||||
_facing_button.text = "Facing"
|
_facing_button.text = "Facing"
|
||||||
_facing_menu = _facing_button.get_popup()
|
_facing_menu = _facing_button.get_popup()
|
||||||
_facing_menu.add_item(_facing_menu_label(FacingProfile.LEFT), FacingProfile.LEFT)
|
_facing_menu.add_item(_facing_menu_label(StickmanRig.FacingProfile.LEFT), StickmanRig.FacingProfile.LEFT)
|
||||||
_facing_menu.add_item(_facing_menu_label(FacingProfile.RIGHT), FacingProfile.RIGHT)
|
_facing_menu.add_item(_facing_menu_label(StickmanRig.FacingProfile.RIGHT), StickmanRig.FacingProfile.RIGHT)
|
||||||
_facing_menu.add_item(_facing_menu_label(FacingProfile.FORWARD), FacingProfile.FORWARD)
|
_facing_menu.add_item(_facing_menu_label(StickmanRig.FacingProfile.FORWARD), StickmanRig.FacingProfile.FORWARD)
|
||||||
_facing_menu.id_pressed.connect(_on_facing_menu_id_pressed)
|
_facing_menu.id_pressed.connect(_on_facing_menu_id_pressed)
|
||||||
_facing_menu.about_to_popup.connect(_update_facing_menu_labels)
|
_facing_menu.about_to_popup.connect(_update_facing_menu_labels)
|
||||||
hbox.add_child(_facing_button)
|
hbox.add_child(_facing_button)
|
||||||
|
|
||||||
|
_anim_dropdown = OptionButton.new()
|
||||||
|
_anim_dropdown.item_selected.connect(_on_anim_dropdown_selected)
|
||||||
|
hbox.add_child(_anim_dropdown)
|
||||||
|
|
||||||
|
_play_button = Button.new()
|
||||||
|
_play_button.text = "Play"
|
||||||
|
_play_button.pressed.connect(_on_play_pressed)
|
||||||
|
hbox.add_child(_play_button)
|
||||||
|
|
||||||
|
_stop_button = Button.new()
|
||||||
|
_stop_button.text = "Stop"
|
||||||
|
_stop_button.pressed.connect(_on_stop_pressed)
|
||||||
|
hbox.add_child(_stop_button)
|
||||||
|
|
||||||
|
_loop_check = CheckBox.new()
|
||||||
|
_loop_check.text = "Loop"
|
||||||
|
_loop_check.button_pressed = true
|
||||||
|
_loop_check.toggled.connect(_on_loop_toggled)
|
||||||
|
hbox.add_child(_loop_check)
|
||||||
|
|
||||||
var open_btn := Button.new()
|
var open_btn := Button.new()
|
||||||
open_btn.text = "Open .stk…"
|
open_btn.text = "Open .stk…"
|
||||||
open_btn.pressed.connect(_on_open_pressed)
|
open_btn.pressed.connect(_on_open_pressed)
|
||||||
@@ -266,6 +249,10 @@ func _build_ui() -> void:
|
|||||||
_status_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
_status_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||||
hbox.add_child(_status_label)
|
hbox.add_child(_status_label)
|
||||||
|
|
||||||
|
# Rig-behavior controls (Facing + animation) are hidden until an .stk is
|
||||||
|
# loaded — they are meaningless without a spawned rig.
|
||||||
|
_set_rig_controls_visible(false)
|
||||||
|
|
||||||
# Viewport area (fills everything below the top bar).
|
# Viewport area (fills everything below the top bar).
|
||||||
_viewport_container = SubViewportContainer.new()
|
_viewport_container = SubViewportContainer.new()
|
||||||
_viewport_container.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
_viewport_container.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
@@ -360,6 +347,12 @@ func _on_viewport_container_resized() -> void:
|
|||||||
return
|
return
|
||||||
_viewport.size = Vector2i(size)
|
_viewport.size = Vector2i(size)
|
||||||
|
|
||||||
|
|
||||||
|
func _set_rig_controls_visible(visible: bool) -> void:
|
||||||
|
for control: Control in [_facing_button, _anim_dropdown, _play_button, _stop_button, _loop_check]:
|
||||||
|
if control != null:
|
||||||
|
control.visible = visible
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Input handling
|
# Input handling
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -408,14 +401,13 @@ func _handle_right_click(screen_pos: Vector2) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _hit_test_bend_joint(world_pos: Vector2) -> String:
|
func _hit_test_bend_joint(world_pos: Vector2) -> String:
|
||||||
|
if _rig_script == null or not is_instance_valid(_rig_script):
|
||||||
|
return ""
|
||||||
var hit_radius := JOINT_HIT_RADIUS_PX / _camera.zoom.x
|
var hit_radius := JOINT_HIT_RADIUS_PX / _camera.zoom.x
|
||||||
var best_joint := ""
|
var best_joint := ""
|
||||||
var best_dist := hit_radius
|
var best_dist := hit_radius
|
||||||
for joint: String in BEND_JOINTS:
|
for joint: String in _rig_script.get_bend_joints():
|
||||||
var bone: Bone2D = _bend_joint_bones.get(joint) as Bone2D
|
var dist := world_pos.distance_to(_rig_script.get_bend_joint_global_position(joint))
|
||||||
if bone == null or not is_instance_valid(bone):
|
|
||||||
continue
|
|
||||||
var dist := world_pos.distance_to(bone.global_position)
|
|
||||||
if dist <= best_dist:
|
if dist <= best_dist:
|
||||||
best_joint = joint
|
best_joint = joint
|
||||||
best_dist = dist
|
best_dist = dist
|
||||||
@@ -574,58 +566,47 @@ func _bone_color(bone_name: String) -> Color:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func _facing_menu_label(profile: int) -> String:
|
func _facing_menu_label(profile: int) -> String:
|
||||||
return ("[√] " if profile == _facing_profile else "") + FacingProfile.keys()[profile].capitalize()
|
return ("[√] " if profile == _facing_profile else "") + StickmanRig.FacingProfile.keys()[profile].capitalize()
|
||||||
|
|
||||||
|
|
||||||
func _update_facing_menu_labels() -> void:
|
func _update_facing_menu_labels() -> void:
|
||||||
if _facing_menu == null:
|
if _facing_menu == null:
|
||||||
return
|
return
|
||||||
for profile: int in [FacingProfile.LEFT, FacingProfile.RIGHT, FacingProfile.FORWARD]:
|
for profile: int in [StickmanRig.FacingProfile.LEFT, StickmanRig.FacingProfile.RIGHT, StickmanRig.FacingProfile.FORWARD]:
|
||||||
var idx := _facing_menu.get_item_index(profile)
|
var idx := _facing_menu.get_item_index(profile)
|
||||||
if idx >= 0:
|
if idx >= 0:
|
||||||
_facing_menu.set_item_text(idx, _facing_menu_label(profile))
|
_facing_menu.set_item_text(idx, _facing_menu_label(profile))
|
||||||
|
|
||||||
|
|
||||||
func _on_facing_menu_id_pressed(id: int) -> void:
|
func _on_facing_menu_id_pressed(id: int) -> void:
|
||||||
_apply_facing_profile(id)
|
_facing_profile = id
|
||||||
|
if _rig_script != null and is_instance_valid(_rig_script):
|
||||||
|
_rig_script.set_facing_profile(id)
|
||||||
_update_facing_menu_labels()
|
_update_facing_menu_labels()
|
||||||
|
|
||||||
|
|
||||||
func _apply_facing_profile(profile: int) -> void:
|
|
||||||
_facing_profile = profile
|
|
||||||
for joint: String in BEND_JOINTS:
|
|
||||||
var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK
|
|
||||||
if mod == null:
|
|
||||||
continue
|
|
||||||
mod.flip_bend_direction = bool(PROFILE_FLAGS[profile][joint])
|
|
||||||
_apply_body_z_order()
|
|
||||||
_debug_overlay.queue_redraw()
|
|
||||||
|
|
||||||
|
|
||||||
func _apply_body_z_order() -> void:
|
|
||||||
if _body_container == null or not is_instance_valid(_body_container):
|
|
||||||
return
|
|
||||||
var order: Array = Z_ORDER_BY_PROFILE.get(_facing_profile, Z_ORDER_BY_PROFILE[FacingProfile.FORWARD])
|
|
||||||
for part_name: String in order:
|
|
||||||
var part := _body_container.get_node_or_null(NodePath(part_name))
|
|
||||||
if part != null:
|
|
||||||
_body_container.move_child(part, _body_container.get_child_count() - 1)
|
|
||||||
|
|
||||||
|
|
||||||
func _context_menu_label(joint: String) -> String:
|
func _context_menu_label(joint: String) -> String:
|
||||||
var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK
|
if _rig_script == null or not is_instance_valid(_rig_script):
|
||||||
if mod == null:
|
|
||||||
return "Invert Bend"
|
return "Invert Bend"
|
||||||
return "Normal Bend" if mod.flip_bend_direction else "Invert Bend"
|
return "Normal Bend" if _rig_script.get_joint_bend_flipped(joint) else "Invert Bend"
|
||||||
|
|
||||||
|
|
||||||
func _on_context_menu_id_pressed(_id: int) -> void:
|
func _on_context_menu_id_pressed(_id: int) -> void:
|
||||||
if _context_joint.is_empty():
|
if _context_joint.is_empty():
|
||||||
return
|
return
|
||||||
var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(_context_joint) as SkeletonModification2DTwoBoneIK
|
if _rig_script == null or not is_instance_valid(_rig_script):
|
||||||
if mod == null:
|
|
||||||
return
|
return
|
||||||
mod.flip_bend_direction = not mod.flip_bend_direction
|
_rig_script.set_joint_bend_flipped(_context_joint, not _rig_script.get_joint_bend_flipped(_context_joint))
|
||||||
|
_debug_overlay.queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_facing_profile_changed(_profile: int) -> void:
|
||||||
|
_facing_profile = _rig_script.get_facing_profile() if _rig_script != null else _facing_profile
|
||||||
|
_update_facing_menu_labels()
|
||||||
|
_debug_overlay.queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_bend_flag_changed(_joint: String, _flipped: bool) -> void:
|
||||||
_debug_overlay.queue_redraw()
|
_debug_overlay.queue_redraw()
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -653,14 +634,27 @@ func _load_and_spawn(path: String) -> void:
|
|||||||
return
|
return
|
||||||
|
|
||||||
_rig = rig
|
_rig = rig
|
||||||
|
_rig_script = rig as StickmanRig
|
||||||
|
# Capture the user's last-selected profile before add_child: the rig's
|
||||||
|
# _ready() emits facing_profile_changed(FORWARD), which routes through
|
||||||
|
# _on_facing_profile_changed and would otherwise clobber the remembered
|
||||||
|
# _facing_profile mirror before it is re-applied below.
|
||||||
|
var remembered_profile := _facing_profile
|
||||||
|
if _rig_script == null:
|
||||||
|
push_warning("TestHarness: spawned rig is missing the StickmanRig script; facing/bend controls disabled.")
|
||||||
|
else:
|
||||||
|
_rig_script.facing_profile_changed.connect(_on_facing_profile_changed)
|
||||||
|
_rig_script.bend_flag_changed.connect(_on_bend_flag_changed)
|
||||||
|
|
||||||
_world.add_child(_rig)
|
_world.add_child(_rig)
|
||||||
_world.move_child(_rig, 0) # keep the rig behind the debug overlay
|
_world.move_child(_rig, 0) # keep the rig behind the debug overlay
|
||||||
|
|
||||||
_resolve_rig_nodes()
|
_resolve_rig_nodes()
|
||||||
_ensure_modification_stack_enabled()
|
if _rig_script != null and is_instance_valid(_rig_script):
|
||||||
_apply_facing_profile(_facing_profile)
|
_rig_script.set_facing_profile(remembered_profile)
|
||||||
|
|
||||||
_status_label.text = path.get_file()
|
_status_label.text = path.get_file()
|
||||||
|
_set_rig_controls_visible(true)
|
||||||
_recenter_camera()
|
_recenter_camera()
|
||||||
_debug_overlay.queue_redraw()
|
_debug_overlay.queue_redraw()
|
||||||
|
|
||||||
@@ -669,14 +663,18 @@ func _free_current_rig() -> void:
|
|||||||
if _rig != null and is_instance_valid(_rig):
|
if _rig != null and is_instance_valid(_rig):
|
||||||
_rig.queue_free()
|
_rig.queue_free()
|
||||||
_rig = null
|
_rig = null
|
||||||
|
_rig_script = null
|
||||||
_skeleton = null
|
_skeleton = null
|
||||||
_body_container = null
|
|
||||||
_ik_handles.clear()
|
_ik_handles.clear()
|
||||||
_bend_joint_bones.clear()
|
|
||||||
_bend_modifications.clear()
|
|
||||||
_coord_bones.clear()
|
_coord_bones.clear()
|
||||||
_context_joint = ""
|
_context_joint = ""
|
||||||
_dragging_handle = null
|
_dragging_handle = null
|
||||||
|
_anim_player = null
|
||||||
|
_anim_dropdown.clear()
|
||||||
|
_selected_animation = ""
|
||||||
|
_playback_state = PlaybackState.STOPPED
|
||||||
|
_update_play_button()
|
||||||
|
_set_rig_controls_visible(false)
|
||||||
|
|
||||||
|
|
||||||
func _resolve_rig_nodes() -> void:
|
func _resolve_rig_nodes() -> void:
|
||||||
@@ -684,10 +682,6 @@ func _resolve_rig_nodes() -> void:
|
|||||||
if _skeleton == null:
|
if _skeleton == null:
|
||||||
push_warning("TestHarness: missing '%s' node in rig." % SKELETON_PATH)
|
push_warning("TestHarness: missing '%s' node in rig." % SKELETON_PATH)
|
||||||
|
|
||||||
_body_container = _rig.get_node_or_null(NodePath(BODY_CONTAINER_PATH)) as Node2D
|
|
||||||
if _body_container == null:
|
|
||||||
push_warning("TestHarness: missing '%s' node in rig." % BODY_CONTAINER_PATH)
|
|
||||||
|
|
||||||
_ik_handles.clear()
|
_ik_handles.clear()
|
||||||
for handle_name: String in IK_HANDLE_PATHS:
|
for handle_name: String in IK_HANDLE_PATHS:
|
||||||
var handle := _rig.get_node_or_null(NodePath(IK_HANDLE_PATHS[handle_name])) as Marker2D
|
var handle := _rig.get_node_or_null(NodePath(IK_HANDLE_PATHS[handle_name])) as Marker2D
|
||||||
@@ -696,40 +690,8 @@ func _resolve_rig_nodes() -> void:
|
|||||||
else:
|
else:
|
||||||
push_warning("TestHarness: missing IK handle '%s'." % IK_HANDLE_PATHS[handle_name])
|
push_warning("TestHarness: missing IK handle '%s'." % IK_HANDLE_PATHS[handle_name])
|
||||||
|
|
||||||
_resolve_bend_joints()
|
|
||||||
_resolve_coord_bones()
|
_resolve_coord_bones()
|
||||||
|
_resolve_anim_player()
|
||||||
|
|
||||||
func _resolve_bend_joints() -> void:
|
|
||||||
_bend_joint_bones.clear()
|
|
||||||
_bend_modifications.clear()
|
|
||||||
if _skeleton == null:
|
|
||||||
return
|
|
||||||
|
|
||||||
for joint: String in BEND_JOINTS:
|
|
||||||
var path: String = BEND_JOINT_BONE_PATHS[joint]
|
|
||||||
var bone := _skeleton.get_node_or_null(NodePath(path)) as Bone2D
|
|
||||||
if bone != null and is_instance_valid(bone):
|
|
||||||
_bend_joint_bones[joint] = bone
|
|
||||||
else:
|
|
||||||
push_warning("TestHarness: missing bend-joint bone '%s'." % path)
|
|
||||||
|
|
||||||
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
|
|
||||||
if stack == null:
|
|
||||||
return
|
|
||||||
for i: int in stack.modification_count:
|
|
||||||
var mod := stack.get_modification(i)
|
|
||||||
if not (mod is SkeletonModification2DTwoBoneIK):
|
|
||||||
continue
|
|
||||||
var ik := mod as SkeletonModification2DTwoBoneIK
|
|
||||||
for joint: String in BEND_JOINTS:
|
|
||||||
if ik.joint_two_bone2d_node == NodePath(BEND_JOINT_BONE_PATHS[joint]):
|
|
||||||
_bend_modifications[joint] = ik
|
|
||||||
break
|
|
||||||
|
|
||||||
for joint: String in BEND_JOINTS:
|
|
||||||
if not _bend_modifications.has(joint):
|
|
||||||
push_warning("TestHarness: missing TwoBoneIK modification for bend joint '%s'." % joint)
|
|
||||||
|
|
||||||
|
|
||||||
func _resolve_coord_bones() -> void:
|
func _resolve_coord_bones() -> void:
|
||||||
@@ -744,14 +706,32 @@ func _resolve_coord_bones() -> void:
|
|||||||
push_warning("TestHarness: missing coordinate bone '%s'." % path)
|
push_warning("TestHarness: missing coordinate bone '%s'." % path)
|
||||||
|
|
||||||
|
|
||||||
func _ensure_modification_stack_enabled() -> void:
|
func _resolve_anim_player() -> void:
|
||||||
if _skeleton == null:
|
_anim_player = _rig.get_node_or_null(NodePath(ANIMATION_PLAYER_PATH)) as AnimationPlayer
|
||||||
|
_populate_animation_dropdown()
|
||||||
|
if _anim_player == null:
|
||||||
|
push_warning("TestHarness: missing '%s' node in rig." % ANIMATION_PLAYER_PATH)
|
||||||
return
|
return
|
||||||
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
|
_anim_player.animation_finished.connect(_on_animation_finished)
|
||||||
if stack != null:
|
|
||||||
stack.enabled = true
|
|
||||||
else:
|
func _populate_animation_dropdown() -> void:
|
||||||
push_warning("TestHarness: Skeleton2D has no modification_stack assigned.")
|
_anim_dropdown.clear()
|
||||||
|
_selected_animation = ""
|
||||||
|
_playback_state = PlaybackState.STOPPED
|
||||||
|
_update_play_button()
|
||||||
|
if _anim_player == null:
|
||||||
|
return
|
||||||
|
var preferred_idx := 0
|
||||||
|
var anim_list: PackedStringArray = _anim_player.get_animation_list()
|
||||||
|
for i: int in anim_list.size():
|
||||||
|
var anim_name: String = anim_list[i]
|
||||||
|
_anim_dropdown.add_item(anim_name)
|
||||||
|
if anim_name == DEFAULT_ANIMATION:
|
||||||
|
preferred_idx = i
|
||||||
|
if _anim_dropdown.item_count > 0:
|
||||||
|
_anim_dropdown.select(preferred_idx)
|
||||||
|
_selected_animation = _anim_dropdown.get_item_text(preferred_idx)
|
||||||
|
|
||||||
|
|
||||||
func _recenter_camera() -> void:
|
func _recenter_camera() -> void:
|
||||||
@@ -763,6 +743,79 @@ func _recenter_camera() -> void:
|
|||||||
_camera.zoom = Vector2.ONE
|
_camera.zoom = Vector2.ONE
|
||||||
_debug_overlay.queue_redraw()
|
_debug_overlay.queue_redraw()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Animation playback
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _on_anim_dropdown_selected(index: int) -> void:
|
||||||
|
_selected_animation = _anim_dropdown.get_item_text(index)
|
||||||
|
# Changing selection stops any in-progress playback (Play restarts it).
|
||||||
|
if _anim_player != null and is_instance_valid(_anim_player):
|
||||||
|
_anim_player.stop()
|
||||||
|
_playback_state = PlaybackState.STOPPED
|
||||||
|
_update_play_button()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_play_pressed() -> void:
|
||||||
|
if _anim_player == null or not is_instance_valid(_anim_player):
|
||||||
|
return
|
||||||
|
if _selected_animation.is_empty():
|
||||||
|
return
|
||||||
|
match _playback_state:
|
||||||
|
PlaybackState.STOPPED:
|
||||||
|
_apply_loop_mode()
|
||||||
|
_anim_player.play(_selected_animation) # restart from position 0
|
||||||
|
_playback_state = PlaybackState.PLAYING
|
||||||
|
PlaybackState.PLAYING:
|
||||||
|
_anim_player.pause()
|
||||||
|
_playback_state = PlaybackState.PAUSED
|
||||||
|
PlaybackState.PAUSED:
|
||||||
|
_anim_player.play() # resume the assigned (paused) animation
|
||||||
|
_playback_state = PlaybackState.PLAYING
|
||||||
|
_update_play_button()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_stop_pressed() -> void:
|
||||||
|
if _anim_player == null or not is_instance_valid(_anim_player):
|
||||||
|
return
|
||||||
|
_anim_player.stop() # resets position to 0 and stops
|
||||||
|
_playback_state = PlaybackState.STOPPED
|
||||||
|
_update_play_button()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_loop_toggled(pressed: bool) -> void:
|
||||||
|
_loop = pressed
|
||||||
|
_apply_loop_mode()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_animation_finished(_anim_name: StringName) -> void:
|
||||||
|
if _loop:
|
||||||
|
return # looping: never treat a wrap as "finished"
|
||||||
|
_playback_state = PlaybackState.STOPPED
|
||||||
|
_update_play_button()
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_loop_mode() -> void:
|
||||||
|
if _anim_player == null or not is_instance_valid(_anim_player):
|
||||||
|
return
|
||||||
|
if _selected_animation.is_empty():
|
||||||
|
return
|
||||||
|
var anim: Animation = _anim_player.get_animation(_selected_animation)
|
||||||
|
if anim != null:
|
||||||
|
anim.loop_mode = Animation.LOOP_LINEAR if _loop else Animation.LOOP_NONE
|
||||||
|
|
||||||
|
|
||||||
|
func _update_play_button() -> void:
|
||||||
|
if _play_button == null:
|
||||||
|
return
|
||||||
|
match _playback_state:
|
||||||
|
PlaybackState.STOPPED:
|
||||||
|
_play_button.text = "Play"
|
||||||
|
PlaybackState.PLAYING:
|
||||||
|
_play_button.text = "Pause"
|
||||||
|
PlaybackState.PAUSED:
|
||||||
|
_play_button.text = "Resume"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Checkbox handlers
|
# Checkbox handlers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user