Files
stickman/docs/phase9_task5_animation_spec.md
ryan 07bef66703 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.
2026-08-24 23:41:23 -04:00

399 lines
19 KiB
Markdown
Raw Permalink 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 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 12) + manual F6 (§8 item 3).
3. Docs: `AGENTS.md`, `README.md`, `RIGGING.md`, this spec.