feat: Implement facing profiles and bend direction toggles in test harness

- Added functionality for skeleton IK bone switches, allowing users to dynamically change the bend direction of joints via a context menu.
- Introduced facing profiles (LEFT, RIGHT, FORWARD) that modify the bend direction of limbs based on the selected profile.
- Implemented body part z-ordering based on the facing profile, ensuring correct rendering order of limbs relative to the torso.
- Added a coordinates display panel that shows the position and rotation of the Skeleton2D and its bones, as well as IK target positions.
- Created a new script to generate a walk animation for the stickman rig, including keyframe tracks for various IK targets.
- Updated documentation to reflect the new features and their specifications.
This commit is contained in:
2026-08-23 22:21:23 -04:00
parent ab5c79ab6a
commit 273090993e
9 changed files with 1013 additions and 26 deletions
+137
View File
@@ -0,0 +1,137 @@
# Phase 9 Task 2 — Feature: Body-Part Z-Order by Facing Profile
## Overview
RIGGING.md Task 2: when the stickman faces different directions, body parts must be drawn in
the right order relative to the torso:
1. **Facing left** → left upper/lower arm **and** left upper/lower leg draw **behind** the
torso; the right-side limbs draw in front. On the far (behind-torso) side the arm draws
**behind** the leg; on the near (in-front) side the arm draws **in front of** the leg.
2. **Facing right** → right upper/lower arm **and** right upper/lower leg draw **behind** the
torso; the left-side limbs draw in front, with the same far-arm-behind-leg / near-arm-in-front-of-leg rule.
3. **Facing forward** → all four limb pairs draw **in front of** the torso.
4. In **all** cases the **head draws in front of the torso**.
Task 1 already built the harness-level `FacingProfile { LEFT, RIGHT, FORWARD }` state
(`_facing_profile`, the "Facing" `MenuButton`, `_apply_facing_profile()`). Task 2 reuses that
profile state to also reorder the rig's `Body/*` visual part nodes.
## 1. Mechanism
`master_rig.tscn`'s `Body` node is a plain `Node2D` container whose children are the per-part
visual nodes: `Head`, `Body` (the torso visual), `LeftUpperLeg`, `RightUpperLeg`,
`LeftLowerLeg`, `RightLowerLeg`, `LeftUpperArm`, `RightUpperArm`, `LeftLowerArm`,
`RightLowerArm`. All have default `z_index = 0`, and Godot 4 `Node2D` draws siblings in tree
order (first child = back, last child = front). The `RemoteTransform2D` drivers only push
global transforms — they never touch tree order. Therefore **draw order = child order of the
`Body` container**, and reordering is a pure `move_child()` operation. This is safe to do at
any time: the adapter mounts shapes as children of each part node (`_mount_shape` adds
`Line2D`/`Polygon2D` under the part node), so moving a part node moves its whole shape group
and never disturbs intra-part shape order.
Per-part authored node names are fixed, so z-order tables are **static string arrays** — no
runtime lookup by part key needed (the harness already owns the rig-facing logic for Task 1).
## 2. Z-order tables
Back-to-front (first entry = backmost, last = frontmost). Within each limb pair the **upper
limb stays behind the lower limb** (the elbow/knee overlaps the lower limb). 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 (per the user's per-direction spec). The
head is always last.
`const Z_ORDER_BY_PROFILE := { FacingProfile.FORWARD: [torso, left upper leg, right upper leg,
left lower leg, right lower leg, left upper arm, right upper arm, left lower arm, right lower
arm, head], FacingProfile.LEFT: [left upper arm, left lower arm, left upper leg, left lower
leg, torso, right upper leg, right lower leg, right upper arm, right lower arm, head],
FacingProfile.RIGHT: [right upper arm, right lower arm, right upper leg, right lower leg,
torso, left upper leg, left lower leg, left upper arm, left lower arm, head] }`
with node names from `master_rig.tscn` (`"Head"`, `"Body"`, `"LeftUpperArm"`, …).
- **FORWARD** matches the requirement "legs and arms drawn in front of the torso"; all limbs
are on the near side, so arms draw in front of legs.
- **LEFT** places both left limb pairs behind the torso (left arm behind left leg) and the
right pairs in front (right arm in front of right leg) (requirement 1); **RIGHT** mirrors
it (requirement 2).
- The head is frontmost in all three (requirement 4).
## 3. Implementation — `scripts/test_harness.gd`
### 3a. Constants
- `const BODY_CONTAINER_PATH := "Body"` — rig-relative path of the visual container.
- `const Z_ORDER_BY_PROFILE: Dictionary = { FacingProfile.FORWARD: [...], ... }` per §2.
### 3b. State
- `var _body_container: Node2D = null` — resolved at spawn, cleared in `_free_current_rig()`.
### 3c. Resolution
- `_resolve_rig_nodes()` additionally resolves `_body_container` via
`_rig.get_node_or_null(NodePath(BODY_CONTAINER_PATH)) as Node2D` (null → `push_warning`).
### 3d. Reorder
New `_apply_body_z_order()`: when `_body_container` is valid, walk the profile's ordered part
names **back-to-front** and call `_body_container.move_child(part, _body_container.get_child_count() - 1)`
for each existing child (skip missing parts — a `.stk` may omit a part). Moving to the end in
back-to-front sequence yields the profile order and leaves unknown/extra children untouched at
the back.
### 3e. Hooks
- `_apply_facing_profile(profile)` calls `_apply_body_z_order()` after setting the
`flip_bend_direction` flags (single entry point: menu selection and fresh spawns both flow
through it).
- No other changes: `_load_and_spawn()` already calls `_apply_facing_profile(_facing_profile)`
after spawn, so every loaded rig gets the current profile's z-order.
## 4. Files modified
| File | Changes |
|---|---|
| `scripts/test_harness.gd` | `BODY_CONTAINER_PATH`, `Z_ORDER_BY_PROFILE`, `_body_container`, resolve + clear + `_apply_body_z_order()`, hook in `_apply_facing_profile()`. |
| `docs/phase9_task2_zorder_spec.md` | This file. |
| `AGENTS.md` | Test-harness section: "Phase 9 Task 2 body-part z-order" bullet. |
| `README.md` | Test-harness bullet: facing profiles now also reorder `Body/*` draw order. |
| `RIGGING.md` | Mark Task 2 implemented (Task 1 was documented in the same way). |
## 5. Edge cases
- **Missing `Body` container** (foreign rig): null-guarded; z-order silently skipped.
- **Missing part nodes** (partial `.stk`): skipped by `get_node_or_null`; no warnings spam.
- **Re-spawn**: `_body_container` is re-resolved per spawn; `_free_current_rig()` nulls it.
- **`z_index`**: all authored `Body/*` nodes use default `z_index = 0`; nothing in the
adapter or harness sets it, so tree order remains the sole depth source.
## 6. Design decisions
| # | Decision | Justification |
|---|---|---|
| D1 | Z-order lives in the **harness**, reusing `FacingProfile` state | The rig stays generic; Task 1 already made the harness the owner of facing state; the rig itself has no z-order concept. |
| D2 | Upper limb behind lower limb in every profile | Elbow/knee joints overlap the lower limb; matching the authored scene order avoids joint seams. |
| D3 | Far-side arms draw behind the legs; near-side arms draw in front of the legs (user-specified per direction) | Matches the user's Left/Right spec: e.g. facing left → left arm behind left leg, right arm in front of right leg. |
| D4 | Head always frontmost (last in array) | Explicit requirement 4. |
## 7. Test plan
1. Parse check: `..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit`
(user-requested; no errors).
2. Temporary headless SceneTree script (pattern from the Round 7 spec; deleted afterwards):
- Instantiate the harness scene, `add_child` it, call `_load_and_spawn("res://stickmen/basic.stk")`.
- Assert `Body` children order equals `Z_ORDER_BY_PROFILE[FacingProfile.FORWARD]`.
- Call `_apply_facing_profile(FacingProfile.LEFT)` → assert left limb pairs precede the
torso node (left arm pair before left leg pair) and right pairs follow it (right leg
pair before right arm pair); repeat for `RIGHT` (mirrored).
- Assert `Body/Head` is the last child in all three profiles.
3. Manual F6 check: load a `.stk`, switch Facing — far-side limbs visibly tuck behind the
torso; head stays on top.
## 8. Implementation order
1. `scripts/test_harness.gd` — constants, state, resolution, reorder + hook.
2. Parse check + headless verification script (temp, then removed).
3. Docs: `AGENTS.md`, `README.md`, `RIGGING.md`.
+146
View File
@@ -0,0 +1,146 @@
# Phase 9 Task 3 — Feature: Coordinates Display
## Overview
RIGGING.md Task 3: the test harness needs a show/hide readout on the right side of the screen
displaying the current skeleton pose so bone/IK work can be inspected numerically:
1. **Skeleton2D** position and rotation.
2. **Bone** positions and rotations — Torso, Head, Left/RightUpperArm, Left/RightLowerArm,
Left/RightUpperLeg, Left/RightLowerLeg.
3. **IK target** positions — Head, Torso, Right_Hand, Left_Hand, Right_Leg, Left_Leg.
The display is easy to read on the right side of the screen and is toggled by a checkbox like
the existing "Show Bones" / "Show IK Handles" toggles. It is a **non-persistent** debug aid:
the toggle defaults ON per launch, and the panel is rebuilt from scratch each run.
## 1. Mechanism
The readout is a `PanelContainer` + `RichTextLabel` pair built entirely in code
(`_build_coords_panel()`), **not** authored in `test_harness.tscn`, and added as a child of the
harness root **after** the viewport container so it renders in front of the `SubViewport`. It
anchors to the top-right of the window below the top UI bar (`offset_top = 40.0`) with a fixed
width and auto-height, and the panel is `MOUSE_FILTER_IGNORE` so it never intercepts viewport
pan/zoom/drag input — the label itself stays interactive: text is **selectable** and copyable
(mouse drag + Ctrl+C, plus the built-in right-click copy menu).
Values are read live each frame in `_process(delta)`, so the panel always mirrors the current
pose. All values are **world-space** (`global_position` / `global_rotation`). Rotation is
reported in degrees (`rad_to_deg`); positions are `Vector2` formatted to 1 decimal place.
## 2. Implementation — `scripts/test_harness.gd`
### 2a. Constants
- `const COORDS_PANEL_WIDTH: float = 320.0` — fixed panel width (also the `offset_left` extent).
- `const COORD_BONE_PATHS: Array[String]` — 10 `Skeleton2D`-relative bone paths, in display
order; the display name is the last path segment (`path.get_file()`):
`Torso`, `Torso/Head`, `Torso/LeftUpperArm`, `Torso/LeftUpperArm/LeftLowerArm`,
`Torso/RightUpperArm`, `Torso/RightUpperArm/RightLowerArm`, `Torso/LeftUpperLeg`,
`Torso/LeftUpperLeg/LeftLowerLeg`, `Torso/RightUpperLeg`, `Torso/RightUpperLeg/RightLowerLeg`.
### 2b. State
- `var _coords_panel: PanelContainer = null` — the readout container.
- `var _coords_label: RichTextLabel = null` — the monospace, selectable text readout.
- `var _coord_bones: Dictionary = {}` — bone display name → `Bone2D`, resolved per spawn.
- `var _show_coords: bool = true` — toggle state (default ON, harness-level, persists across
spawns but not to disk).
### 2c. UI construction — `_build_coords_panel()`
Called from `_build_ui()` right after the viewport container. Builds:
- `_coords_panel` (`PanelContainer`):
- `PRESET_TOP_RIGHT` anchors; `offset_top = 40.0`, `offset_right = -8.0`,
`offset_left = -COORDS_PANEL_WIDTH`.
- `grow_vertical = GROW_DIRECTION_END` + `grow_horizontal = GROW_DIRECTION_BEGIN`
(auto-height/width from label content; grows leftward so text never runs off the right
edge of the screen).
- `mouse_filter = MOUSE_FILTER_IGNORE` on the panel (input passes through to the viewport;
the label itself keeps mouse handling for text selection).
- `StyleBoxFlat` override "panel": bg `Color(0,0,0,0.55)`, border `Color(1,1,1,0.12)` width 1,
corner radius 4, content margin 8.
- `_coords_label` (`RichTextLabel`): `selection_enabled = true`, `context_menu_enabled = true`,
`fit_content = true`, `autowrap_mode = OFF`, `scroll_active = false`,
`focus_mode = FOCUS_CLICK`; monospace via a `SystemFont` override on the `normal_font` theme
item (`Consolas`, `Menlo`, `DejaVu Sans Mono`, `Courier New` fallbacks) at
`normal_font_size = 18`.
- `_coords_panel` added to the harness root (`add_child(_coords_panel)`).
### 2d. Toggle — `_on_show_coords_toggled(pressed)`
`CheckBox` "Show Coords" (top bar `HBox`, after "Show IK Handles", before the status label),
`button_pressed = true` at build. Handler sets `_show_coords = pressed` and flips
`_coords_panel.visible`. No persistence to disk.
### 2e. Update loop — `_process(_delta)` → `_update_coords_display()`
`_process(delta)` early-outs when `_show_coords` is false or `_coords_label` is null; otherwise
calls `_update_coords_display()` each frame. `_update_coords_display()`:
- If `_rig` is null/invalid → label text `"No rig loaded"`, return.
- `"Skeleton2D"` section: position + rotation (degrees) via `_skeleton.global_position` /
`_skeleton.global_rotation` (guarded).
- `"Bones"` section: iterates `COORD_BONE_PATHS`, looking each bone up in `_coord_bones` by
display name; position + rotation (degrees). Missing/invalid bones skipped.
- `"IK Targets"` section: iterates `IK_HANDLE_PATHS` and reads each handle from `_ik_handles`
(the same 6 handle set used by the drag handles); **position only**. Missing/invalid handles
skipped.
- Lines joined with `\n` into `_coords_label.text`; the text is only reassigned when the built
string differs from the current one, so an active text selection survives idle frames (it
refreshes while bones are actively moving). Formatting helpers: `_fmt_vec2(v)`
`(%8.1f, %8.1f)`, `_fmt_deg(rad)` `%7.1f°` (1 decimal place).
### 2f. Resolution & lifecycle
- `_resolve_rig_nodes()` additionally calls `_resolve_coord_bones()`.
- `_resolve_coord_bones()` clears `_coord_bones`, then for each `COORD_BONE_PATHS` entry
resolves via `_skeleton.get_node_or_null(NodePath(path)) as Bone2D`; valid bones are keyed by
`path.get_file()`, missing ones emit `push_warning`.
- `_free_current_rig()` clears `_coord_bones`. The `_show_coords` toggle itself persists across
respawns (it lives at harness level, not per-rig).
## 3. Files modified
| File | Changes |
|---|---|
| `scripts/test_harness.gd` | `COORDS_PANEL_WIDTH`, `COORD_BONE_PATHS`, `_coords_panel`, `_coords_label`, `_coord_bones`, `_show_coords`, `_build_coords_panel()`, `_on_show_coords_toggled()`, `_process()`, `_update_coords_display()`, `_resolve_coord_bones()`, `_fmt_vec2()`, `_fmt_deg()`; "Show Coords" checkbox. |
| `docs/phase9_task3_coords_spec.md` | This file. |
| `AGENTS.md` | Test-harness section: "Phase 9 Task 3 coordinates display" bullet; `test_harness.tscn` scene description now lists the "Show Coords" toggle. |
| `RIGGING.md` | Mark Task 3 implemented. |
## 4. Edge cases
- **No rig loaded**: `_update_coords_display()` shows `"No rig loaded"`.
- **Missing bone nodes** (foreign/partial rig): `_resolve_coord_bones()` warns once, and the
per-frame loop skips missing/invalid bones via `is_instance_valid`.
- **Toggle off**: `_process()` early-outs; panel hidden.
- **Re-spawn**: `_coord_bones` re-resolved per spawn; `_free_current_rig()` clears it. The
`_show_coords` toggle stays at its user-set value across spawns (harness-level state).
- **Input blocking**: `MOUSE_FILTER_IGNORE` keeps the panel from intercepting viewport pan/zoom/
drag.
- **World-space values**: `global_position`/`global_rotation` reflect the live pose regardless
of `Camera2D` pan/zoom, so the readout tracks the actual rig transforms.
## 5. Design decisions
| # | Decision | Justification |
|---|---|---|
| D1 | Panel is code-built (`_build_coords_panel()`), not a scene node | Mirrors the code-built harness top bar; keeps `test_harness.tscn` unchanged and the panel trivial to rebuild. |
| D2 | `MOUSE_FILTER_IGNORE` on the panel | The readout must never block viewport interaction (pan/zoom/IK dragging). |
| D3 | World-space values + degrees | Matches what the debug overlay and drag handles use; degrees are the convention in the harness and the editor. |
| D4 | IK targets report position only | IK targets carry no meaningful rotation for this readout (they are aim/IK markers). |
| D5 | Toggle non-persistent, default ON | Debug aid only; no settings.json involvement, consistent with the other harness toggles. |
## 6. Test plan
1. Parse check: `..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit`
(user-requested; no errors).
2. Manual F6 check:
- Launch harness; readout visible on the top-right with `"No rig loaded"`.
- Load `stickmen/basic.stk`; Skeleton2D, 10 Bones, and 6 IK Target rows populate with
numeric values.
- Drag an IK handle → position/rotation values update live each frame.
- Uncheck "Show Coords" → panel hides; recheck → reappears with current values.
- Confirm the panel does not block middle-mouse pan / wheel zoom / IK drags beneath it.