Implement rig animation controls in the test harness

- Added a new animation specification document for Phase 9 Task 5 detailing the requirements for rig animation controls.
- Introduced a new `StickmanRig` script to manage the facing direction and joint bending for the rig.
- Implemented UI elements in the test harness for selecting animations, controlling playback (play/pause/resume/stop), and toggling loop mode.
- Enhanced the `test_harness.gd` script to handle animation playback state and UI interactions.
- Updated documentation in `AGENTS.md`, `README.md`, and `RIGGING.md` to reflect the new animation features.
This commit is contained in:
2026-08-24 23:41:23 -04:00
parent 273090993e
commit 07bef66703
12 changed files with 1695 additions and 283 deletions
+398
View File
@@ -0,0 +1,398 @@
# Phase 9 Task 5 — Feature: Rig Animation in the Test Harness
## Overview
RIGGING.md Task 5: `master_rig.tscn` already ships an `AnimationPlayer` and an `AnimationTree`.
The test harness must let the user **select** an animation from a dropdown, **play / pause /
resume / stop** it, and **toggle loop vs. play-once**. Concretely:
1. A **dropdown** listing the rig's animations (from `AnimationPlayer.get_animation_list()`).
2. A **play/pause/resume** button (single button whose label reflects playback state) plus a
**Stop** button. Per RIGGING.md, **Play always restarts from the beginning**; **Stop resets to
the start** so the next Play restarts.
3. A **Loop** checkbox that makes the selected animation loop or play just once.
This is a **harness-only** change. `master_rig.tscn` is **not** modified (the two animation nodes
already exist and are sufficient). `scripts/stickman_rig.gd` is **not** modified (the harness
resolves the `AnimationPlayer` directly by node path, exactly as it already resolves `Skeleton2D`
and the IK handles). Only `scripts/test_harness.gd` changes.
## 1. Findings — `master_rig.tscn` animation nodes
### 1a. Node paths (both are direct children of the `Master` rig root)
| Node | Path (rig-relative) | Properties |
|---|---|---|
| `AnimationPlayer` | `AnimationPlayer` | `libraries/ = AnimationLibrary_t75yq`; no `active` override (defaults to `true`). |
| `AnimationTree` | `AnimationTree` | `active = false`; `tree_root = AnimationNodeStateMachine_6rw38`; `anim_player = NodePath("../AnimationPlayer")`. |
The harness reaches the player via `_rig.get_node_or_null(NodePath("AnimationPlayer"))` — the
rig root is the `Master` node (`_rig`, a `StickmanRig`), and `AnimationPlayer` is a direct child
(`parent="."`).
### 1b. Animation library — two animations (not one)
The `AnimationLibrary_t75yq._data` dictionary holds **two** animations (RIGGING.md says "currently
just 'walk_right'", but there is also a pose-reset helper):
| Name | Length | `loop_mode` | Tracks |
|---|---|---|---|
| `RESET` | `0.001` | *(absent → `LOOP_NONE`)* | `.:facing_profile` (discrete, value `2` = `FacingProfile.FORWARD`) |
| `walk_right` | `0.8` | `1` (`LOOP_LINEAR`) | 6 IK-target position tracks + `.:facing_profile` (discrete, value `1` = `RIGHT`) |
`AnimationPlayer.get_animation_list()` therefore returns `["RESET", "walk_right"]` (library
dictionary insertion order). The dropdown lists both; the harness prefers `walk_right` as the
initially-selected item (see §5b).
### 1c. What `walk_right` animates
`walk_right` does **not** key `Bone2D` rotations directly. It animates the **6 `IK_Targets`
`Marker2D` positions** (the `TwoBoneIK`/`LookAt` solvers then flex the bones) plus a discrete
`facing_profile` set on the rig root:
- `IK_Targets/Torso:position` (5 keys, cubic interp) — bobbing; the Torso marker's child
`RemoteTransform2D` (`remote_path = ../../../Skeleton2D/Torso`) translates the whole skeleton.
- `IK_Targets/Head:position`, `IK_Targets/Right_Leg:position`, `IK_Targets/Left_Leg:position`,
`IK_Targets/Right_Hand:position`, `IK_Targets/Left_Hand:position` (5 keys each, cubic interp).
- `.:facing_profile` (discrete, `update = 1`, value `1` = `FacingProfile.RIGHT`).
The `.:facing_profile` track writes through the `StickmanRig.facing_profile` export setter
(animation tracks write via `Object.set()`, which triggers the setter), so **playing an animation
can change the facing profile** and emits `facing_profile_changed` — which the harness already
handles via `_on_facing_profile_changed` (updates the `_facing_profile` mirror, the `[√] ` menu
prefix, and the debug redraw). No special harness handling is required; it is documented behavior.
`RESET` likewise sets `facing_profile = FORWARD`.
### 1d. Is `AnimationTree` configured? — **No (placeholder)**
`AnimationTree` has `active = false` and an **empty** `AnimationNodeStateMachine` root
(`AnimationNodeStateMachine_6rw38` has no states, no transitions, and no `start_node`). There is
**no** `AnimationNodeAnimation`, no `AnimationNodeBlendTree`, and no output node set. It is a
placeholder.
**Decision (D1): the harness drives `AnimationPlayer` directly; configuring `AnimationTree` is
out of scope.** Justification: the task only needs select/play/pause/stop/loop, all of which
`AnimationPlayer` provides directly; a state-machine/blend-tree setup adds nothing for a single
animation stream and would require editing `master_rig.tscn`. The `AnimationTree` node is left
untouched for a future blending phase.
## 2. Godot 4 API notes (verified for 4.7)
- `AnimationPlayer.get_animation_list() -> PackedStringArray` — animation names.
- `AnimationPlayer.get_animation(name: StringName) -> Animation` — the `Animation` resource.
- `AnimationPlayer.play(name: StringName, ...)` — if the player is **stopped**, calling `play(name)`
**restarts from position 0**. If the player is **paused** on the same animation, `play(name)` (or
`play()` with no args) **resumes**. We rely on the documented distinction: *"the assigned
animation will resume playing if it was paused, or restart if it was stopped."*
- `AnimationPlayer.pause()` — pauses, keeps position.
- `AnimationPlayer.stop()` — default `keep_state = false`: stops and **resets position to 0**.
- `Animation.loop_mode``Animation.LOOP_NONE` (0) / `Animation.LOOP_LINEAR` (1). Loop is a
property of the **`Animation` resource**, not of `play()`, so the toggle writes
`anim.loop_mode` on the selected animation before playing.
- `AnimationPlayer.animation_finished(anim_name: StringName)` — emitted when an animation reaches
its end and stops. **Not** emitted on `pause()`/`stop()`. For **looping** animations the emit-on-
wrap behavior varies across 4.x versions, so the handler ignores the signal while `_loop` is true
(see §7 D4) — this is safe under either engine behavior.
## 3. UI design
New controls in the top-bar `HBox`, inserted **immediately after the "Facing" `MenuButton` and
before the "Open .stk…" button** (keeps the two rig-behavior control clusters — Facing + Animation
— adjacent at the left edge, and leaves the load / debug-display clusters untouched):
```
[ Facing ][ AnimDropdown ][ Play/Pause ][ Stop ][ ☑ Loop ][ Open .stk… ][ Break ][ Basic ][ Test ][ Show Bones ][ Show IK Handles ][ Show Coords ] …status…
```
| Node | Type | Text / state | Purpose |
|---|---|---|---|
| `_anim_dropdown` | `OptionButton` | populated per spawn | Select the animation. |
| `_play_button` | `Button` | `"Play"` / `"Pause"` / `"Resume"` (label swaps) | Play-from-start / pause / resume. |
| `_stop_button` | `Button` | `"Stop"` | Stop and reset to start. |
| `_loop_check` | `CheckBox` | `"Loop"`, `button_pressed = true` | Loop vs. play-once. |
Controls are **always enabled** (matching the harness's existing "Facing" menu / checkbox style);
each handler no-op-guards on a missing `AnimationPlayer` instead of disabling the control.
## 4. Implementation — `scripts/test_harness.gd`
### 4a. Constants
```gdscript
## AnimationPlayer node path (relative to rig root).
const ANIMATION_PLAYER_PATH := "AnimationPlayer"
## Initially-selected animation in the dropdown (RIGGING.md default).
const DEFAULT_ANIMATION := "walk_right"
```
### 4b. Enum
```gdscript
## Harness-tracked playback state (the harness is the sole driver of the
## AnimationPlayer, so it tracks state authoritatively via button handlers and
## the animation_finished signal rather than polling is_playing()).
enum PlaybackState { STOPPED, PLAYING, PAUSED }
```
### 4c. Runtime-built node references (added to the existing block)
```gdscript
var _anim_dropdown: OptionButton
var _play_button: Button
var _stop_button: Button
var _loop_check: CheckBox
```
### 4d. State (added to the existing block)
```gdscript
var _anim_player: AnimationPlayer = null
var _selected_animation: String = ""
var _playback_state: int = PlaybackState.STOPPED
var _loop: bool = true # harness-level, persists across respawns (like _show_coords)
```
### 4e. UI construction — insert in `_build_ui()`
Insert after `hbox.add_child(_facing_button)` and before `var open_btn := Button.new()`:
```gdscript
_anim_dropdown = OptionButton.new()
_anim_dropdown.item_selected.connect(_on_anim_dropdown_selected)
hbox.add_child(_anim_dropdown)
_play_button = Button.new()
_play_button.text = "Play"
_play_button.pressed.connect(_on_play_pressed)
hbox.add_child(_play_button)
_stop_button = Button.new()
_stop_button.text = "Stop"
_stop_button.pressed.connect(_on_stop_pressed)
hbox.add_child(_stop_button)
_loop_check = CheckBox.new()
_loop_check.text = "Loop"
_loop_check.button_pressed = true
_loop_check.toggled.connect(_on_loop_toggled)
hbox.add_child(_loop_check)
```
### 4f. Resolution — `_resolve_anim_player()` (new)
Called from `_resolve_rig_nodes()` (add the call at its end, after `_resolve_coord_bones()`):
```gdscript
func _resolve_anim_player() -> void:
_anim_player = _rig.get_node_or_null(NodePath(ANIMATION_PLAYER_PATH)) as AnimationPlayer
_populate_animation_dropdown()
if _anim_player == null:
push_warning("TestHarness: missing '%s' node in rig." % ANIMATION_PLAYER_PATH)
return
_anim_player.animation_finished.connect(_on_animation_finished)
```
```gdscript
func _populate_animation_dropdown() -> void:
_anim_dropdown.clear()
_selected_animation = ""
_playback_state = PlaybackState.STOPPED
_update_play_button()
if _anim_player == null:
return
var preferred_idx := 0
var anim_list: PackedStringArray = _anim_player.get_animation_list()
for i: int in anim_list.size():
var anim_name: String = anim_list[i]
_anim_dropdown.add_item(anim_name)
if anim_name == DEFAULT_ANIMATION:
preferred_idx = i
if _anim_dropdown.item_count > 0:
_anim_dropdown.select(preferred_idx)
_selected_animation = _anim_dropdown.get_item_text(preferred_idx)
```
### 4g. Handlers
```gdscript
func _on_anim_dropdown_selected(index: int) -> void:
_selected_animation = _anim_dropdown.get_item_text(index)
# Changing selection stops any in-progress playback (Play restarts it).
if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop()
_playback_state = PlaybackState.STOPPED
_update_play_button()
func _on_play_pressed() -> void:
if _anim_player == null or not is_instance_valid(_anim_player):
return
if _selected_animation.is_empty():
return
match _playback_state:
PlaybackState.STOPPED:
_apply_loop_mode()
_anim_player.play(_selected_animation) # restart from position 0
_playback_state = PlaybackState.PLAYING
PlaybackState.PLAYING:
_anim_player.pause()
_playback_state = PlaybackState.PAUSED
PlaybackState.PAUSED:
_anim_player.play() # resume the assigned (paused) animation
_playback_state = PlaybackState.PLAYING
_update_play_button()
func _on_stop_pressed() -> void:
if _anim_player == null or not is_instance_valid(_anim_player):
return
_anim_player.stop() # resets position to 0 and stops
_playback_state = PlaybackState.STOPPED
_update_play_button()
func _on_loop_toggled(pressed: bool) -> void:
_loop = pressed
_apply_loop_mode()
func _on_animation_finished(_anim_name: StringName) -> void:
if _loop:
return # looping: never treat a wrap as "finished"
_playback_state = PlaybackState.STOPPED
_update_play_button()
```
### 4h. Helpers
```gdscript
func _apply_loop_mode() -> void:
if _anim_player == null or not is_instance_valid(_anim_player):
return
if _selected_animation.is_empty():
return
var anim: Animation = _anim_player.get_animation(_selected_animation)
if anim != null:
anim.loop_mode = Animation.LOOP_LINEAR if _loop else Animation.LOOP_NONE
func _update_play_button() -> void:
if _play_button == null:
return
match _playback_state:
PlaybackState.STOPPED:
_play_button.text = "Play"
PlaybackState.PLAYING:
_play_button.text = "Pause"
PlaybackState.PAUSED:
_play_button.text = "Resume"
```
### 4i. Lifecycle
- **`_resolve_rig_nodes()`** — add `_resolve_anim_player()` after `_resolve_coord_bones()`. Each
spawn re-resolves the player, repopulates the dropdown (fresh `AnimationPlayer` → fresh list),
resets `_playback_state` to `STOPPED`, and re-connects `animation_finished`.
- **`_free_current_rig()`** — add:
```gdscript
_anim_player = null
_anim_dropdown.clear()
_selected_animation = ""
_playback_state = PlaybackState.STOPPED
_update_play_button()
```
(The old player is `queue_free`d with the rig; its `animation_finished` connection dies with it.
`_loop` is **not** reset — it is harness-level state that persists across respawns, like
`_show_coords` / `_facing_profile`.)
- **`_process(delta)`** — **unchanged.** The coordinates readout keeps updating during playback
(it reads `global_position`/`global_rotation` every frame), and `AnimationPlayer` self-animates
independent of the harness `_process`. No playback-state polling is added (state is tracked via
handlers + `animation_finished`).
## 5. Files modified
| File | Changes |
|---|---|
| `scripts/test_harness.gd` | `ANIMATION_PLAYER_PATH`, `DEFAULT_ANIMATION`, `PlaybackState`, `_anim_dropdown`/`_play_button`/`_stop_button`/`_loop_check`, `_anim_player`/`_selected_animation`/`_playback_state`/`_loop`, UI block in `_build_ui()`, `_resolve_anim_player()`, `_populate_animation_dropdown()`, `_on_anim_dropdown_selected()`, `_on_play_pressed()`, `_on_stop_pressed()`, `_on_loop_toggled()`, `_on_animation_finished()`, `_apply_loop_mode()`, `_update_play_button()`; lifecycle hooks in `_resolve_rig_nodes()` + `_free_current_rig()`. |
| `docs/phase9_task5_animation_spec.md` | This file. |
| `AGENTS.md` | Test-harness section: "Phase 9 Task 5 rig animation" bullet (dropdown + play/pause/stop + loop, drives `AnimationPlayer` directly). |
| `README.md` | Test-harness bullet: animation select/play/pause/stop/loop controls. |
| `RIGGING.md` | Mark Task 5 implemented. |
**Not modified:** `master_rig.tscn`, `scripts/stickman_rig.gd`, `scripts/stickman_factory.gd`,
`scripts/stk_rig_adapter.gd`.
## 6. Edge cases
- **No rig loaded / no `AnimationPlayer`** (foreign rig): `_resolve_anim_player()` warns once;
the dropdown is empty; `_on_play_pressed`/`_on_stop_pressed`/`_on_loop_toggled` no-op-guard.
- **Re-spawn**: dropdown repopulated, `_playback_state` reset to `STOPPED`, play button label back
to `"Play"`; `_loop` toggle persists and is re-applied on the next play (via `_apply_loop_mode()`).
- **Changing the dropdown selection mid-play**: the current animation stops and state → `STOPPED`
(the newly selected animation is not auto-started).
- **Non-looping animation finishes**: `animation_finished` → state → `STOPPED`, button → `"Play"`.
- **Looping animation**: `animation_finished` (if emitted on wrap in this engine version) is
ignored by the `_loop` guard; the button stays `"Pause"` indefinitely.
- **Manual IK dragging during playback**: not blocked; but the animated tracks overwrite the dragged
handles' positions on the next frame, so dragging an animated handle while playing has no lasting
effect (expected; documented, not "fixed").
- **Animation changes the facing profile**: `walk_right` → `RIGHT`, `RESET` → `FORWARD`; flows
through the rig setter and the existing `_on_facing_profile_changed` (menu `[√] ` + redraw).
- **Stopping does not restore the rest pose**: `stop()` resets the *playhead* to 0 but leaves
properties at their last keyed values. The `RESET` animation is available to restore facing;
full rest-pose restoration on stop is out of scope.
- **The 0.001s `RESET` animation + loop ON**: selecting it with loop ON makes a harmless tight
loop (facing stays FORWARD). Not special-cased.
## 7. Design decisions
| # | Decision | Justification |
|---|---|---|
| D1 | Drive `AnimationPlayer` directly; `AnimationTree` out of scope | `AnimationTree` is an unconfigured placeholder (`active = false`, empty state machine). Select/play/pause/stop/loop are all first-class `AnimationPlayer` APIs; wiring a blend tree would require editing `master_rig.tscn` for no gain here. |
| D2 | Single play/pause/resume button + separate Stop button | Matches RIGGING.md "pause/resume" (toggling) vs "stop (restart on play)" as distinct states; the label swap is the harness's existing dynamic-text pattern (cf. the editor's snap/guide menus). |
| D3 | Track playback state via `_playback_state` + `animation_finished`, not `is_playing()` polling | The harness is the sole driver, so state is deterministic. `animation_finished` reliably fires for non-looping end-of-play and never fires on `pause()`/`stop()`; the `_loop` guard makes the looping-wrap ambiguity moot. Avoids adding per-frame polling to `_process`. |
| D4 | Loop = write `Animation.loop_mode` on the selected `Animation` before playing | Loop is an `Animation`-resource property, not a `play()` argument; this is the only way to override the authored value. Re-applied on every play so the harness `_loop` toggle is authoritative regardless of authored `loop_mode`. |
| D5 | Loop default **ON** | Matches the authored `walk_right` (`loop_mode = 1`), and a walk cycle is the natural looping case. |
| D6 | Dropdown populated dynamically per spawn from `get_animation_list()` | The list comes from the rig's own library, so future animations appear automatically; no hardcoded list. |
| D7 | Prefer `walk_right` as initial selection (`DEFAULT_ANIMATION`) | Matches RIGGING.md's "currently just 'walk_right'" default even though the library also contains `RESET`. |
| D8 | No `stickman_rig.gd` change | The harness already resolves rig children by node path (`SKELETON_PATH`, `IK_HANDLE_PATHS`); `ANIMATION_PLAYER_PATH` follows that established pattern. A rig-level accessor is unnecessary. |
| D9 | Controls never disabled; handlers no-op-guard | Matches the existing harness style (the "Facing" menu and checkboxes are always enabled). |
## 8. Verification
Only `scripts/test_harness.gd` changes, so the syntax checks target that script.
1. **Whole-project parse check** (established form used by every prior phase — the plain
`--check-only` form hangs on renderer init in 4.7.x, so use the `--headless --check-only --quit`
variant). Run from the project dir `C:\Godot4\stickman`:
```
..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit
```
2. **Single-script check** (user-specified form; run from anywhere):
```
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --check-only --script "res://scripts/test_harness.gd"
```
3. **Manual F6 check** (`res://scenes/test_harness.tscn`):
- Load `stickmen/basic.stk` → dropdown lists `RESET` and `walk_right`, `walk_right` selected,
play button shows `"Play"`, Loop checked.
- Press **Play** → button `"Pause"`; the figure walks (IK targets animate, legs/arms swing,
facing menu flips to `[√] Right`). Coordinates readout updates live.
- Press **Pause** → button `"Resume"`; figure freezes. Press again → resumes.
- Press **Stop** → figure stops, button `"Play"`; press **Play** → restarts from the beginning
(not from the paused position).
- Uncheck **Loop** → press **Play** → animation plays once, then the button returns to `"Play"`
by itself.
- Select `RESET` → Play → facing returns to `[√] Forward`.
- Load a different `.stk` → dropdown repopulated, playback reset, Loop checkbox state kept.
## 9. Implementation order
1. `scripts/test_harness.gd` — constants, enum, node refs, state, `_build_ui()` block, handlers,
helpers, lifecycle hooks.
2. Parse checks (§8 items 12) + manual F6 (§8 item 3).
3. Docs: `AGENTS.md`, `README.md`, `RIGGING.md`, this spec.