503 lines
29 KiB
Markdown
503 lines
29 KiB
Markdown
# 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. |
|
||
|
||
## 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`.
|