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

19 KiB
Raw Permalink Blame History

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_modeAnimation.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

## 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 (fresh AnimationPlayer → fresh list), resets _playback_state to STOPPED, and re-connects animation_finished.
  • _free_current_rig() — add:
    _anim_player = null
    _anim_dropdown.clear()
    _selected_animation = ""
    _playback_state = PlaybackState.STOPPED
    _update_play_button()
    
    (The old player is queue_freed 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_rightRIGHT, RESETFORWARD; 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.