- 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.
19 KiB
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:
- A dropdown listing the rig's animations (from
AnimationPlayer.get_animation_list()). - 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.
- 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 childRemoteTransform2D(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, value1=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— theAnimationresource.AnimationPlayer.play(name: StringName, ...)— if the player is stopped, callingplay(name)restarts from position 0. If the player is paused on the same animation,play(name)(orplay()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()— defaultkeep_state = false: stops and resets position to 0.Animation.loop_mode—Animation.LOOP_NONE(0) /Animation.LOOP_LINEAR(1). Loop is a property of theAnimationresource, not ofplay(), so the toggle writesanim.loop_modeon the selected animation before playing.AnimationPlayer.animation_finished(anim_name: StringName)— emitted when an animation reaches its end and stops. Not emitted onpause()/stop(). For looping animations the emit-on- wrap behavior varies across 4.x versions, so the handler ignores the signal while_loopis 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
## 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
## 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)
var _anim_dropdown: OptionButton
var _play_button: Button
var _stop_button: Button
var _loop_check: CheckBox
4d. State (added to the existing block)
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():
_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()):
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)
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
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
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 (freshAnimationPlayer→ fresh list), resets_playback_statetoSTOPPED, and re-connectsanimation_finished._free_current_rig()— add:(The old player is_anim_player = null _anim_dropdown.clear() _selected_animation = "" _playback_state = PlaybackState.STOPPED _update_play_button()queue_freed with the rig; itsanimation_finishedconnection dies with it._loopis 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 readsglobal_position/global_rotationevery frame), andAnimationPlayerself-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_toggledno-op-guard. - Re-spawn: dropdown repopulated,
_playback_statereset toSTOPPED, play button label back to"Play";_looptoggle 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_loopguard; 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. TheRESETanimation is available to restore facing; full rest-pose restoration on stop is out of scope. - The 0.001s
RESETanimation + 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.
-
Whole-project parse check (established form used by every prior phase — the plain
--check-onlyform hangs on renderer init in 4.7.x, so use the--headless --check-only --quitvariant). Run from the project dirC:\Godot4\stickman:..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit -
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" -
Manual F6 check (
res://scenes/test_harness.tscn):- Load
stickmen/basic.stk→ dropdown listsRESETandwalk_right,walk_rightselected, 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.
- Load
9. Implementation order
scripts/test_harness.gd— constants, enum, node refs, state,_build_ui()block, handlers, helpers, lifecycle hooks.- Parse checks (§8 items 1–2) + manual F6 (§8 item 3).
- Docs:
AGENTS.md,README.md,RIGGING.md, this spec.