Files
stickman/docs/phase9_task4_refactor_spec.md
T

503 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 9 Task 4 — Refactor: Facing Direction & Bone Bend into the Rig
## Overview
RIGGING.md Task 4: facing direction and per-joint bone bend must become **part of
`master_rig.tscn`**, not the test harness. A rigged stickman instance should carry its own facing
profile and bend flags so different instances can face different directions / bend differently at
the same time. These must be **exported controls** on the rig so they are easy to access from the
inspector and from runtime code.
Today the harness owns all of this:
- `enum FacingProfile { LEFT, RIGHT, FORWARD }` (Task 1),
- `PROFILE_FLAGS` — per-profile `flip_bend_direction` sets for the 4 TwoBoneIK joints,
- `Z_ORDER_BY_PROFILE` — per-profile `Body/*` draw-order tables (Task 2),
- `BEND_JOINTS` / `BEND_JOINT_BONE_PATHS` — the 4 upper↔lower limb connectors,
- `_facing_profile`, `_bend_joint_bones`, `_bend_modifications`, `_body_container` state,
- `_apply_facing_profile()`, `_apply_body_z_order()`, `_resolve_bend_joints()`, and
`_ensure_modification_stack_enabled()`.
Task 4 moves that state + logic into a new script attached to the `master_rig.tscn` root, and the
harness becomes a thin driver that reads/writes the rig's exported properties.
## 1. New rig script — `scripts/stickman_rig.gd`
A new `class_name StickmanRig` script, `extends Node2D`, attached to the `Master` root node of
`master_rig.tscn` (the root currently has **no** script). It is the single owner of facing profile
+ per-joint bend flags + `Body/*` z-order.
### 1a. Class declaration & enums
```gdscript
class_name StickmanRig
extends Node2D
## Facing profiles. Values are used directly as facing-menu item ids in the
## harness (0/1/2), so they must stay stable.
enum FacingProfile { LEFT, RIGHT, FORWARD }
## Per-joint bend direction. INVERTED == flip_bend_direction = true.
enum BendDirection { NORMAL, INVERTED }
```
The enums live **in the rig script** (not a shared script). The harness references them as
`StickmanRig.FacingProfile.LEFT` / `StickmanRig.BendDirection.INVERTED` (idiomatic `class_name`
enum access — no autoload or singleton needed).
### 1b. Constants (moved verbatim from `test_harness.gd`)
```gdscript
const SKELETON_PATH := "Skeleton2D"
const BODY_CONTAINER_PATH := "Body"
## Bend joints (upper↔lower limb connectors) whose TwoBoneIK "Flip Bend
## Direction" flag is user-controllable.
const BEND_JOINTS: Array[String] = ["LeftArm", "RightArm", "LeftLeg", "RightLeg"]
## Lower-bone NodePath (relative to Skeleton2D) per bend joint. Used both to
## resolve the TwoBoneIK modification (via joint_two_bone2d_node) and to report
## each joint's world position for hit-testing.
const BEND_JOINT_BONE_PATHS: Dictionary = {
"LeftArm": "Torso/LeftUpperArm/LeftLowerArm",
"RightArm": "Torso/RightUpperArm/RightLowerArm",
"LeftLeg": "Torso/LeftUpperLeg/LeftLowerLeg",
"RightLeg": "Torso/RightUpperLeg/RightLowerLeg",
}
## flip_bend_direction value per facing profile, keyed by bend-joint name.
const PROFILE_FLAGS: Dictionary = {
FacingProfile.LEFT: { "LeftArm": false, "RightArm": false, "LeftLeg": true, "RightLeg": true },
FacingProfile.RIGHT: { "LeftArm": true, "RightArm": true, "LeftLeg": false, "RightLeg": false },
FacingProfile.FORWARD: { "LeftArm": false, "RightArm": true, "LeftLeg": true, "RightLeg": false },
}
## Body/* visual part node names in draw order (back-to-front) per profile.
## First entry backmost, last frontmost. Upper limbs behind lower limbs; on the
## far (behind-torso) side the arm pair draws behind the leg pair, on the near
## side the arm pair draws in front; head always frontmost.
const Z_ORDER_BY_PROFILE: Dictionary = {
FacingProfile.FORWARD: [
"Body",
"LeftUpperLeg", "RightUpperLeg",
"LeftLowerLeg", "RightLowerLeg",
"LeftUpperArm", "RightUpperArm",
"LeftLowerArm", "RightLowerArm",
"Head",
],
FacingProfile.LEFT: [
"LeftUpperArm", "LeftLowerArm",
"LeftUpperLeg", "LeftLowerLeg",
"Body",
"RightUpperLeg", "RightLowerLeg",
"RightUpperArm", "RightLowerArm",
"Head",
],
FacingProfile.RIGHT: [
"RightUpperArm", "RightLowerArm",
"RightUpperLeg", "RightLowerLeg",
"Body",
"LeftUpperLeg", "LeftLowerLeg",
"LeftUpperArm", "LeftLowerArm",
"Head",
],
}
```
### 1c. Exported properties (the "exported controls")
```gdscript
## Facing preset. Setting it overwrites the four per-joint bend values from
## PROFILE_FLAGS and reorders Body/* children. Default FORWARD.
@export var facing_profile: FacingProfile = FacingProfile.FORWARD:
set(value):
if facing_profile == value:
return
facing_profile = value
_apply_profile()
## Per-joint bend direction (the actual flip_bend_direction source of truth).
## Defaults match the FORWARD profile. These are individually overridable after
## a profile is applied (drifting away from the preset, exactly like the current
## harness right-click behavior).
@export_group("Bend Direction")
@export_enum("Normal", "Inverted") var left_arm_bend: int = BendDirection.NORMAL:
set(value):
_set_joint_bend_inverted("LeftArm", value == BendDirection.INVERTED)
@export_enum("Normal", "Inverted") var right_arm_bend: int = BendDirection.INVERTED:
set(value):
_set_joint_bend_inverted("RightArm", value == BendDirection.INVERTED)
@export_enum("Normal", "Inverted") var left_leg_bend: int = BendDirection.INVERTED:
set(value):
_set_joint_bend_inverted("LeftLeg", value == BendDirection.INVERTED)
@export_enum("Normal", "Inverted") var right_leg_bend: int = BendDirection.NORMAL:
set(value):
_set_joint_bend_inverted("RightLeg", value == BendDirection.INVERTED)
```
Semantics:
- `facing_profile` is a **preset**. Its setter writes the four `*_bend` vars (through their own
setters, so mod flags stay in sync) and then reorders `Body/*` children, then emits
`facing_profile_changed`. It early-outs if the value is unchanged (prevents redundant re-applies,
e.g. re-spawning with the default profile).
- The four `*_bend` vars are the **per-joint source of truth**. Each setter stores the value,
updates the resolved `SkeletonModification2DTwoBoneIK.flip_bend_direction`, and emits
`bend_flag_changed`. `BendDirection.INVERTED``flip_bend_direction = true`.
- All setters are **guarded by a `_nodes_ready` flag** (set at the end of `_ready()`): before the
rig has resolved its children (e.g. while `PackedScene.instantiate()` is still hydrating the
serialized exports), the setter only stores the value; `_ready()` then applies the full state
once. This makes the apply path order-independent and robust against setter timing during
instantiation.
### 1d. Signals
```gdscript
## Emitted when the facing preset changes (after flags + z-order are applied).
signal facing_profile_changed(profile: int)
## Emitted when a single joint's bend direction changes. `flipped` is the new
## flip_bend_direction value (true == inverted).
signal bend_flag_changed(joint: String, flipped: bool)
```
The harness primarily refreshes menu labels on `about_to_popup` (the established pattern used by
the editor's snap/guide menus), so the signals are not strictly required for label sync — but they
are emitted for programmatic consumers and match the repo's signal-driven data-flow convention.
### 1e. Public methods
```gdscript
## Facing preset accessors.
func set_facing_profile(profile: int) -> void
func get_facing_profile() -> int
## Per-joint bend accessors (bool form mirrors flip_bend_direction directly).
func set_joint_bend_flipped(joint: String, flipped: bool) -> void
func get_joint_bend_flipped(joint: String) -> bool
## The list of bend-joint names (["LeftArm", "RightArm", "LeftLeg", "RightLeg"]).
func get_bend_joints() -> Array[String]
## World position of a bend joint (its lower bone's global_position) for
## right-click hit-testing. Unknown joint → push_warning + Vector2.ZERO.
func get_bend_joint_global_position(joint: String) -> Vector2
```
`set_facing_profile` / `set_joint_bend_flipped` are the runtime API; the exported var setters funnel
into the same internal apply functions, so there is exactly one mutation path (no drift between the
inspector values, the internal state, and the live mod flags).
### 1f. Internal state & resolution
```gdscript
var _nodes_ready: bool = false
var _skeleton: Skeleton2D = null
var _body_container: Node2D = null
var _bend_joint_bones: Dictionary = {} # { String : Bone2D } (lower bones)
var _bend_modifications: Dictionary = {} # { String : SkeletonModification2DTwoBoneIK }
```
- `_ready()`: resolves `_skeleton` (`get_node_or_null(SKELETON_PATH)`), `_body_container`
(`get_node_or_null(BODY_CONTAINER_PATH)`), the 4 lower `Bone2D`s via `BEND_JOINT_BONE_PATHS`, and
the 4 TwoBoneIK modifications; enables the modification stack (`stack.enabled = true`); then
applies the current `facing_profile` once (`_apply_profile()`) and sets `_nodes_ready = true`.
- `_resolve_bend_modifications()` mirrors the current harness `_resolve_bend_joints()` matching: walk
`_skeleton.modification_stack`, and for each `SkeletonModification2DTwoBoneIK` match by
`joint_two_bone2d_node == NodePath(BEND_JOINT_BONE_PATHS[joint])` (**no hardcoded stack index**).
Missing bone/mod → `push_warning` + skip (never crash).
- `_apply_profile()`: writes the four `*_bend` vars from `PROFILE_FLAGS[profile]` (through their
setters), then `_apply_body_z_order()`.
- `_apply_body_z_order()`: preserves the Task 2 algorithm — walk the profile's ordered part names
**back-to-front** and `move_child(part, get_child_count() - 1)` for each existing child; unknown/
extra children stay at the back; missing parts skipped silently.
### 1g. Editor-time vs runtime
- **The script is NOT `@tool`.** Exported properties still appear in the editor inspector (they are
serialized into `master_rig.tscn` when the scene is saved), but the script body — node resolution,
`flip_bend_direction` writes, `Body/*` reordering, mod-stack enabling — runs **only at runtime**
(`_ready()` + setters on a live instance).
- Rationale: `SkeletonModificationStack2D` only solves at runtime, so an in-editor live preview of
bend direction would not be meaningful, and mutating `Body/*` child order / mod sub-resources in
the editor would dirty the scene and fight the editor's undo/ownership. Runtime-only application
is the safe minimum the task explicitly permits ("at minimum runtime"). Making it `@tool` later
(gated behind `Engine.is_editor_hint()`) is a possible future enhancement, not part of this task.
### 1h. Failure handling
Matches repo style throughout (`get_node_or_null`, `push_warning`, skip):
- Missing `Skeleton2D`, `Body`, a bend-joint bone, or a TwoBoneIK mod → `push_warning` (prefixed
`"StickmanRig: ..."`) and skip that piece; the rig never crashes.
- Unknown joint name in `set_joint_bend_flipped` / `get_joint_bend_flipped` /
`get_bend_joint_global_position``push_warning` and no-op / `false` / `Vector2.ZERO`
respectively.
- Empty `Body` container or missing part nodes → z-order reorder silently skips (as today).
## 2. `master_rig.tscn` changes
1. **Attach the script** to the root `Master` node: `script = ExtResource("…")` for
`res://scripts/stickman_rig.gd`. The exported defaults get serialized (`facing_profile = 2`,
`left_arm_bend = 0`, `right_arm_bend = 1`, `left_leg_bend = 1`, `right_leg_bend = 0`).
2. **Align authored state to the FORWARD default** (Q1 **resolved: yes**). So the
scene's as-authored state == the runtime default (and the editor view of the raw scene is
coherent):
- TwoBoneIK `flip_bend_direction` (currently authored = the **RIGHT** profile, see §6):
- `LeftArm` (`…TwoBoneIK_f0s26`, `target_nodepath = ../IK_Targets/Left_Hand`): `true`**remove the flag / `false`**.
- `RightArm` (`…TwoBoneIK_yvxej`): `true`**unchanged**.
- `RightLeg` (`…TwoBoneIK_ylko5`): absent → **unchanged** (stays `false`).
- `LeftLeg` (`…TwoBoneIK_2leu7`): absent → **add `flip_bend_direction = true`**.
- `Body/*` child order: the authored order is `Head, Body, LeftUpperLeg, RightUpperLeg,
LeftLowerLeg, RightLowerLeg, LeftUpperArm, RightUpperArm, LeftLowerArm, RightLowerArm`. Move
the `Body/Head` node to the **end** → order becomes exactly the FORWARD table. (Safe: the
`RemoteTransform2D` drivers and the adapter reference `Body/*` by name, never by index.)
Both edits are pure scene text edits. Runtime behavior is unchanged either way (the rig applies
FORWARD on `_ready`), so this only affects the as-authored appearance and makes the "default
leaves the scene as-authored" verification true.
## 3. Harness changes — `scripts/test_harness.gd`
The harness stops owning bend/facing state and drives the rig script. It keeps a thin UI mirror for
menu labels + re-application across respawns (the rig remains the authority over the actual flags).
### 3a. Removed
| Harness element | Disposition |
|---|---|
| `enum FacingProfile` | Delete — use `StickmanRig.FacingProfile`. |
| `PROFILE_FLAGS`, `Z_ORDER_BY_PROFILE` | Delete — moved to `StickmanRig`. |
| `BEND_JOINTS`, `BEND_JOINT_BONE_PATHS` | Delete — the rig exposes `get_bend_joints()` + `get_bend_joint_global_position()`. |
| `BODY_CONTAINER_PATH` | Delete — only the rig needs it. |
| `_body_container` | Delete (resolve + clear). |
| `_bend_joint_bones`, `_bend_modifications` | Delete (resolve + clear). |
| `_apply_facing_profile()` | Delete — call `_rig_script.set_facing_profile()`. |
| `_apply_body_z_order()` | Delete — moved to the rig. |
| `_resolve_bend_joints()` | Delete — moved to the rig's `_resolve_bend_modifications()`. |
| `_ensure_modification_stack_enabled()` | Delete — the rig enables its own stack in `_ready()`. |
Note: `SKELETON_PATH` **stays** in the harness (still used to resolve `_skeleton` for the bone
overlay and the coordinates panel). `_facing_profile` **stays** but is repurposed as a UI mirror
(§3b).
### 3b. State (changed / added)
```gdscript
var _rig_script: StickmanRig = null
var _facing_profile: int = StickmanRig.FacingProfile.FORWARD # UI mirror only
var _context_joint: String = ""
```
- `_facing_profile` is now only "the user's last-selected facing profile", used for the `[√] ` menu
prefix and to re-apply after each spawn. The rig's exported `facing_profile` is the actual state.
- `_context_joint` is unchanged (which joint the right-click menu targets).
### 3c. Spawn flow — `_load_and_spawn()`
1. `_free_current_rig()` (unchanged except it clears `_rig_script` instead of the deleted state).
2. `var rig := StickmanFactory.spawn(path)`; `_rig_script = rig as StickmanRig` (null-guarded:
foreign rig without the script → `push_warning` and disable facing/bend UI).
3. Connect signals **before** `add_child`:
`_rig_script.facing_profile_changed.connect(_on_facing_profile_changed)`,
`_rig_script.bend_flag_changed.connect(_on_bend_flag_changed)` (both may be no-ops; label sync
is done on `about_to_popup`, but connecting keeps future consumers working).
4. `_world.add_child(_rig)` (rig `_ready` runs here, applies its default FORWARD and enables its
mod stack).
5. `_resolve_rig_nodes()` — now only resolves `_skeleton`, `_ik_handles`, `_coord_bones` (drops
`_body_container` and `_resolve_bend_joints()`).
6. `_rig_script.set_facing_profile(_facing_profile)` — re-applies the remembered selection (no-op
when it equals the rig's FORWARD default).
### 3d. Facing menu
- Menu item ids stay `StickmanRig.FacingProfile.LEFT/RIGHT/FORWARD`.
- `_on_facing_menu_id_pressed(id)`: `_facing_profile = id`; if `_rig_script` valid →
`_rig_script.set_facing_profile(id)`; then `_update_facing_menu_labels()`.
- `_facing_menu_label(profile)`: unchanged (`[√] ` prefix based on `_facing_profile`).
### 3e. Right-click bend toggle
- `_hit_test_bend_joint(world_pos)`: iterate `_rig_script.get_bend_joints()`; position from
`_rig_script.get_bend_joint_global_position(joint)`; same `JOINT_HIT_RADIUS_PX / _camera.zoom.x`
nearest-joint selection.
- `_handle_right_click()`: unchanged flow (set `_context_joint`, set label, popup).
- `_context_menu_label(joint)`: `"Normal Bend" if _rig_script.get_joint_bend_flipped(joint) else "Invert Bend"`
(fallback `"Invert Bend"` when the rig script is missing).
- `_on_context_menu_id_pressed(_id)`: if `_context_joint` non-empty and `_rig_script` valid →
`_rig_script.set_joint_bend_flipped(_context_joint, not _rig_script.get_joint_bend_flipped(_context_joint))`.
### 3f. Signal handlers (optional but included)
```gdscript
func _on_facing_profile_changed(_profile: int) -> void:
_facing_profile = _rig_script.get_facing_profile() if _rig_script else _facing_profile
_update_facing_menu_labels()
_debug_overlay.queue_redraw()
func _on_bend_flag_changed(_joint: String, _flipped: bool) -> void:
_debug_overlay.queue_redraw()
```
(These keep the `[√] ` prefix in sync if the profile is ever changed from outside the menu; the
menu still refreshes on `about_to_popup` as the primary mechanism.)
## 4. Factory / adapter impact
- `stickman_factory.gd`: **no functional change.** `spawn_from_data` / `spawn` still return the
rig root (now carrying the `StickmanRig` script); narrow the return type to `StickmanRig`
(`return RIG_SCENE.instantiate() as StickmanRig`) for stronger typing (Q6 **resolved: yes**).
- `stk_rig_adapter.gd`: **no change.** The adapter mounts shapes onto `Body/*` by node path and
does not read or write bend/facing state. The rig's `_ready` (z-order + flags + stack-enable) runs
**after** `StkRigAdapter.apply()` (which happens inside `spawn_from_data`, before the rig enters
the tree), so mounted shape children are already present when the rig reorders `Body/*` — moving a
part node moves its whole shape group, exactly as today.
## 5. `master_rig_builder.gd`, `master_rig2.tscn`
Out of scope and untouched (per `docs/phase9_round1_bugfix_spec.md`): `master_rig_builder.gd` builds
a **different** rig (`Sticky/Stickman/.../Hip` naming), and `master_rig2.tscn` is a pose-override
variant not referenced by the factory. The new `StickmanRig` script targets `master_rig.tscn` only.
## 6. Defaults & backward compatibility
- **Default profile = FORWARD** (`facing_profile = FacingProfile.FORWARD`), matching the current
harness default `_facing_profile = FacingProfile.FORWARD`. On a fresh spawn the rig applies
FORWARD flags `{LeftArm:false, RightArm:true, LeftLeg:true, RightLeg:false}` and the FORWARD
z-order — **identical to today's runtime behavior**.
- **Authored `master_rig.tscn` flags are NOT currently FORWARD** — they are the **RIGHT** profile
(`LeftArm:true, RightArm:true, LeftLeg:false, RightLeg:false`, see `master_rig.tscn` lines 23/31
and the absent flags on the two leg mods). The harness already overwrites these at runtime, so
behavior is unchanged before/after this refactor; the mismatch only matters for the "as-authored
== default" goal (§2 / Q1).
- **No `.stk` format change.** `FILE_VERSION` stays `"1.5"`. Bend/facing state is rig-instance
state, not figure data; it is never serialized into `.stk`.
- **No `settings.json` change.** The harness facing selection stays non-persistent (as today).
- Old `.stk` files, foreign rigs, and partial figures load unchanged (null-guarded resolution).
## 7. Files modified
| File | Changes |
|---|---|
| `scripts/stickman_rig.gd` | **New.** `class_name StickmanRig extends Node2D` — enums, constants, exported properties, signals, methods, runtime resolution/apply. |
| `master_rig.tscn` | Attach `StickmanRig` to root `Master`; (recommended) align authored TwoBoneIK flags + `Body/*` order to FORWARD (§2). |
| `scripts/test_harness.gd` | Delete bend/facing ownership (§3a); add `_rig_script` + repurposed `_facing_profile`; rewire spawn, facing menu, right-click toggle (§3c3f). |
| `scripts/stickman_factory.gd` | Narrow `spawn_from_data`/`spawn` return type to `StickmanRig` (Q6). |
| `docs/phase9_task4_refactor_spec.md` | This file. |
| `AGENTS.md` | Add `scripts/stickman_rig.gd` bullet; update `test_harness.gd` bullet (facing/bend now drive the rig); note `master_rig.tscn` root script. |
| `README.md` | Project-structure table row for `stickman_rig.gd`; update test-harness + factory bullets. |
| `RIGGING.md` | Mark Task 4 implemented. |
## 8. Edge cases
- **No rig script / foreign rig** (`rig as StickmanRig` == null): `push_warning`; facing menu and
right-click bend toggle become no-ops; bone overlay/coords still work.
- **Missing `Skeleton2D` / `Body` / bones / mods**: `push_warning` + skip per section; rig never
crashes.
- **Setters before `_ready`** (during instantiation): guarded by `_nodes_ready`; `_ready` applies the
full state once — no ordering bug.
- **Re-spawn**: the rig is freed and a fresh one spawns; the harness re-applies the remembered
`_facing_profile` (§3c). `_free_current_rig()` clears `_rig_script` (not the deleted state).
- **Manual bend override then profile change**: setting a profile overwrites all four per-joint
flags (the preset wins), exactly like the current `_apply_facing_profile()`.
- **Unknown `Body/*` children**: stay at the back during reorder (unchanged Task 2 semantics).
- **Head always frontmost**: preserved in all three `Z_ORDER_BY_PROFILE` tables.
## 9. Design decisions
| # | Decision | Justification |
|---|---|---|
| D1 | New `class_name StickmanRig extends Node2D` on the `master_rig.tscn` root | The rig is the natural owner of per-instance facing/bend; a `class_name` script makes it a typed, reusable API for the harness and future consumers. |
| D2 | Enums live in the rig script (`StickmanRig.FacingProfile` / `StickmanRig.BendDirection`) | No autoload/singleton; idiomatic `class_name` enum access; single source of truth both sides can reference. |
| D3 | `facing_profile` is a preset; the four `*_bend` enum exports are the per-joint source of truth | Matches the harness's existing preset-then-override behavior (RIGGING.md: "user is free to change bend manually"); the enum dropdowns give readable "Normal"/"Inverted" inspector labels (the task's "per-joint bend enums" hint). |
| D4 | Public bool-form methods (`set_joint_bend_flipped`/`get_joint_bend_flipped`) alongside the enum exports | `flip_bend_direction` is a bool; the bool form is the engine-accurate contract and keeps the harness context-menu label logic unchanged. |
| D5 | TwoBoneIK resolved by `joint_two_bone2d_node` NodePath matching, not stack index | Preserves the existing (Task 1) resolution approach; robust to stack reordering. |
| D6 | Non-`@tool` script; apply only at runtime | IK doesn't solve in-editor; mutating `Body/*` order / mod flags in-editor would dirty the scene; runtime-only is the task's permitted minimum. |
| D7 | Harness keeps a `_facing_profile` **UI mirror** but not the bend authority | Preserves current UX (selection persists across respawns, `[√] ` label) without the harness owning flags/z-order. |
| D8 | Rig enables its own mod stack in `_ready()` | Facing/bend are meaningless until the stack is live; the rig should self-enable at runtime (single consumer today always enables it). |
| D9 | Rig exposes `get_bend_joint_global_position()` | Lets the harness drop `BEND_JOINT_BONE_PATHS`/`_bend_joint_bones` entirely; the rig owns the whole bend domain. |
## 9a. Round N — whole-rig Y-axis mirror (2026-09-05 design change)
**Decision (user-approved):** facing LEFT is now rendered as a **whole-rig Y-axis mirror** —
`Master.scale.x = -1` (RIGHT/FORWARD → `(1,1)`) — replacing the per-part/head mirroring. This flips
the head **and** body together so the figure faces the correct direction.
- `_apply_head_flip()` and the `Body/Head.scale.x` mirror are **removed** (the Head Pivot node's
driver transform is untouched).
- `PROFILE_FLAGS` (per-joint `flip_bend_direction`) and `Z_ORDER_BY_PROFILE` are **kept
provisionally** (unchanged). The mirror reflects the whole skeleton + `IK_Targets` + mounted
`Body/*` geometry, but does **not** affect depth (draw order); whether the bend flags can be
collapsed to a single canonical set must still be verified empirically (a root mirror is not
provably reflection-invariant for TwoBoneIK's `flip_bend_direction` sign).
- **Walk-clip mapping — Option A (single canonical clip):** `walk_right` is the canonical walk.
For `FacingProfile.LEFT` the rig root is X-mirrored and the **same `walk_right`** clip plays
mirrored; `walk_left` is no longer used at runtime. The animation `.:facing_profile` tracks are
neutralized/removed — facing is set explicitly by `set_facing_profile()` / `walk_to()`.
**Follow-up (implemented, tested):** two head-related fixes were required to make the LEFT root
mirror render the head correctly. (1) The head `RemoteTransform2D` (`Skeleton2D/Torso/Head/Pivot`)
no longer sets `update_scale = false` — it pushes the **full transform** like every other `Body`
driver, so `Body/Head.scale` stays identity under the mirrored root (the old partial-channel push
re-canonicalized the scale and caused per-frame Y-flips/wrap-jumps). (2) The `SkeletonModification2DLookAt`
that aims the Head bone is **not mirror-invariant**: under the LEFT root mirror it writes a bone
rotation 180° off the FORWARD aim, flipping the head to hang below the neck. New
`_apply_head_lookat_mirror_mode()` (called from `_apply_profile()`) disables the LookAt and pins the
head bone to the FORWARD canonical aim (π) when facing LEFT; `_pin_mirrored_head_rotation()`
re-asserts the pin each `_physics_process` frame while ANIMATED/RECOVERING so recovery's stack
re-arm can't let LookAt flip the bone. RIGHT/FORWARD re-enable the LookAt. Consequence: interactive
head-aiming while facing LEFT is intentionally static.
## 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`.