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
+42 -39
View File
@@ -1,60 +1,63 @@
@tool
extends Node2D
extends EditorScript
@export var build_walk_animation: bool = false:
set(value):
if value:
_create_walk_right_animation()
func _create_walk_right_animation() -> void:
var anim_player: AnimationPlayer = get_node_or_null("AnimationPlayer")
if not anim_player:
push_error("AnimationPlayer node not found as direct child of Master!")
func _run() -> void:
var root = EditorInterface.get_edited_scene_root()
if not root:
push_error("EditorScript: No active scene open.")
return
var anim_player = root.get_node_or_null("AnimationPlayer") as AnimationPlayer
if not anim_player:
push_error("EditorScript: AnimationPlayer node not found under root.")
return
_generate_walk_animation(anim_player, "walk_right", 1, false) # FacingProfile.RIGHT (1)
_generate_walk_animation(anim_player, "walk_left", 0, true) # FacingProfile.LEFT (0)
func _generate_walk_animation(anim_player: AnimationPlayer, anim_name: String, profile_enum: int, flip_x: bool) -> void:
var anim = Animation.new()
anim.length = 0.8
anim.loop_mode = Animation.LOOP_LINEAR
var dir_mult: float = -1.0 if flip_x else 1.0
var times = [0.0, 0.2, 0.4, 0.6, 0.8]
# Keyframe tracks mapped relative to root Master node
var tracks = {
"IK_Targets/Torso:position": [
Vector2(0, 10), Vector2(0, -15), Vector2(0, 10), Vector2(0, -15), Vector2(0, 10)
],
"IK_Targets/Head:position": [
Vector2(100, -614), Vector2(100, -639), Vector2(100, -614), Vector2(100, -639), Vector2(100, -614)
],
"IK_Targets/Right_Leg:position": [
Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390)
],
"IK_Targets/Left_Leg:position": [
Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380)
],
"IK_Targets/Right_Hand:position": [
Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110)
],
"IK_Targets/Left_Hand:position": [
Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110), Vector2(0, 115), Vector2(-90, 110)
]
# 1. Profile Track (0 = LEFT, 1 = RIGHT)
var profile_track = anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(profile_track, ".:facing_profile")
anim.value_track_set_update_mode(profile_track, Animation.UPDATE_DISCRETE)
anim.track_insert_key(profile_track, 0.0, profile_enum)
# 2. Keyframe positions
var raw_tracks = {
"IK_Targets/Torso:position": [Vector2(0, 10), Vector2(0, -15), Vector2(0, 10), Vector2(0, -15), Vector2(0, 10)],
"IK_Targets/Head:position": [Vector2(100, -614), Vector2(100, -639), Vector2(100, -614), Vector2(100, -639), Vector2(100, -614)],
"IK_Targets/Right_Leg:position": [Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390)],
"IK_Targets/Left_Leg:position": [Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380)],
"IK_Targets/Right_Hand:position": [Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110)],
"IK_Targets/Left_Hand:position": [Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110)]
}
for path in tracks:
var times = [0.0, 0.2, 0.4, 0.6, 0.8]
for path in raw_tracks:
var track_idx = anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(track_idx, path)
anim.track_set_interpolation_type(track_idx, Animation.INTERPOLATION_CUBIC)
for i in range(times.size()):
anim.track_insert_key(track_idx, times[i], tracks[path][i])
var orig_pos: Vector2 = raw_tracks[path][i]
var mirrored_pos = Vector2(orig_pos.x * dir_mult, orig_pos.y)
anim.track_insert_key(track_idx, times[i], mirrored_pos)
# Add or retrieve default library
# 3. Attach animation to AnimationPlayer library
var lib = anim_player.get_animation_library("")
if not lib:
lib = AnimationLibrary.new()
anim_player.add_animation_library("", lib)
if lib.has_animation("walk_right"):
lib.remove_animation("walk_right")
if lib.has_animation(anim_name):
lib.remove_animation(anim_name)
lib.add_animation("walk_right", anim)
print("Successfully generated 'walk_right' animation in AnimationPlayer!")
lib.add_animation(anim_name, anim)
print("Successfully generated '%s' animation!" % anim_name)
+3 -3
View File
@@ -34,13 +34,13 @@ static func load_stk(path: String) -> Dictionary:
return parsed as Dictionary
static func spawn_from_data(stk_data: Dictionary) -> Node2D:
var rig := RIG_SCENE.instantiate() as Node2D
static func spawn_from_data(stk_data: Dictionary) -> StickmanRig:
var rig := RIG_SCENE.instantiate() as StickmanRig
StkRigAdapter.apply(stk_data, rig)
return rig
static func spawn(path: String) -> Node2D:
static func spawn(path: String) -> StickmanRig:
var data := load_stk(path)
if data.is_empty():
return null
+304
View File
@@ -0,0 +1,304 @@
class_name StickmanRig
extends Node2D
## StickmanRig - Runtime owner of facing direction and per-joint bone bend for
## master_rig.tscn (Phase 9 / Task 4).
##
## Attached to the `Master` root node of master_rig.tscn. Owns the facing
## preset, the four per-joint TwoBoneIK "Flip Bend Direction" flags, and the
## Body/* draw order. Non-@tool: node resolution, flag writes and z-order
## reordering run only at runtime (_ready + setters on a live instance).
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
## 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 }
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
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",
],
}
# ---------------------------------------------------------------------------
# Exported controls
# ---------------------------------------------------------------------------
## 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
if _nodes_ready:
_apply_profile()
## Per-joint bend direction (the actual flip_bend_direction source of truth).
## Defaults match the FORWARD profile. Individually overridable after a profile
## is applied (drifting away from the preset, matching the harness right-click).
@export_group("Bend Direction")
@export_enum("Normal", "Inverted") var left_arm_bend: int = BendDirection.NORMAL:
set(value):
left_arm_bend = value
_set_joint_bend_inverted("LeftArm", value == BendDirection.INVERTED)
@export_enum("Normal", "Inverted") var right_arm_bend: int = BendDirection.INVERTED:
set(value):
right_arm_bend = value
_set_joint_bend_inverted("RightArm", value == BendDirection.INVERTED)
@export_enum("Normal", "Inverted") var left_leg_bend: int = BendDirection.INVERTED:
set(value):
left_leg_bend = value
_set_joint_bend_inverted("LeftLeg", value == BendDirection.INVERTED)
@export_enum("Normal", "Inverted") var right_leg_bend: int = BendDirection.NORMAL:
set(value):
right_leg_bend = value
_set_joint_bend_inverted("RightLeg", value == BendDirection.INVERTED)
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## 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)
# ---------------------------------------------------------------------------
# Internal state
# ---------------------------------------------------------------------------
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 }
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
# Resolve all runtime node references.
_skeleton = get_node_or_null(NodePath(SKELETON_PATH)) as Skeleton2D
if _skeleton == null:
push_warning("StickmanRig: missing '%s' node in rig." % SKELETON_PATH)
_body_container = get_node_or_null(NodePath(BODY_CONTAINER_PATH)) as Node2D
if _body_container == null:
push_warning("StickmanRig: missing '%s' node in rig." % BODY_CONTAINER_PATH)
_resolve_bend_modifications()
# Enable the modification stack (IK solves only at runtime).
if _skeleton != null:
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
if stack != null:
stack.enabled = true
else:
push_warning("StickmanRig: Skeleton2D has no modification_stack assigned.")
# Apply the authored/default profile once: writes the four mod flags from the
# current var values, reorders Body/* children, and emits the profile signal.
# _nodes_ready is set first so the per-joint setters route their mod updates
# + signal emissions through the live apply path (during instantiation the
# setters only stored values).
_nodes_ready = true
_apply_profile()
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
func set_facing_profile(profile: int) -> void:
if not PROFILE_FLAGS.has(profile):
push_warning("StickmanRig: unknown facing profile %d; ignored." % profile)
return
facing_profile = profile
func get_facing_profile() -> int:
return int(facing_profile)
func set_joint_bend_flipped(joint: String, flipped: bool) -> void:
match joint:
"LeftArm":
left_arm_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL
"RightArm":
right_arm_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL
"LeftLeg":
left_leg_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL
"RightLeg":
right_leg_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL
_:
push_warning("StickmanRig: unknown bend joint '%s'; ignored." % joint)
func get_joint_bend_flipped(joint: String) -> bool:
match joint:
"LeftArm":
return left_arm_bend == BendDirection.INVERTED
"RightArm":
return right_arm_bend == BendDirection.INVERTED
"LeftLeg":
return left_leg_bend == BendDirection.INVERTED
"RightLeg":
return right_leg_bend == BendDirection.INVERTED
_:
push_warning("StickmanRig: unknown bend joint '%s'." % joint)
return false
func get_bend_joints() -> Array[String]:
return BEND_JOINTS
func get_bend_joint_global_position(joint: String) -> Vector2:
var bone := _bend_joint_bones.get(joint) as Bone2D
if bone == null or not is_instance_valid(bone):
push_warning("StickmanRig: unknown or missing bend joint '%s'." % joint)
return Vector2.ZERO
return bone.global_position
# ---------------------------------------------------------------------------
# Internal resolution / apply
# ---------------------------------------------------------------------------
func _resolve_bend_modifications() -> void:
_bend_joint_bones.clear()
_bend_modifications.clear()
if _skeleton == null:
return
for joint: String in BEND_JOINTS:
var path: String = BEND_JOINT_BONE_PATHS[joint]
var bone := _skeleton.get_node_or_null(NodePath(path)) as Bone2D
if bone != null and is_instance_valid(bone):
_bend_joint_bones[joint] = bone
else:
push_warning("StickmanRig: missing bend-joint bone '%s'." % path)
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
if stack == null:
return
for i: int in stack.modification_count:
var mod := stack.get_modification(i)
if not (mod is SkeletonModification2DTwoBoneIK):
continue
var ik := mod as SkeletonModification2DTwoBoneIK
for joint: String in BEND_JOINTS:
if ik.joint_two_bone2d_node == NodePath(BEND_JOINT_BONE_PATHS[joint]):
_bend_modifications[joint] = ik
break
for joint: String in BEND_JOINTS:
if not _bend_modifications.has(joint):
push_warning("StickmanRig: missing TwoBoneIK modification for bend joint '%s'." % joint)
## Writes the four per-joint bend vars from PROFILE_FLAGS (through their setters,
## so the live mods stay in sync), reorders Body/* children, then emits
## facing_profile_changed.
func _apply_profile() -> void:
var flags: Dictionary = PROFILE_FLAGS.get(facing_profile, PROFILE_FLAGS[FacingProfile.FORWARD])
left_arm_bend = BendDirection.INVERTED if bool(flags.get("LeftArm", false)) else BendDirection.NORMAL
right_arm_bend = BendDirection.INVERTED if bool(flags.get("RightArm", false)) else BendDirection.NORMAL
left_leg_bend = BendDirection.INVERTED if bool(flags.get("LeftLeg", false)) else BendDirection.NORMAL
right_leg_bend = BendDirection.INVERTED if bool(flags.get("RightLeg", false)) else BendDirection.NORMAL
_apply_body_z_order()
_apply_head_flip()
facing_profile_changed.emit(int(facing_profile))
func _apply_head_flip() -> void:
if _body_container == null:
return
var head := _body_container.get_node_or_null("Head") as Node2D
if head != null:
var is_left := (facing_profile == FacingProfile.LEFT)
# Keep local X positive (neck upright), invert local Y (mirror brim & face)
head.scale = Vector2(1.0, -1.0) if is_left else Vector2(1.0, 1.0)
## Per-joint setter notify: updates the resolved TwoBoneIK mod's
## flip_bend_direction and emits bend_flag_changed. No-op before _ready (the
## setter only stored the backing value during instantiation).
func _set_joint_bend_inverted(joint: String, inverted: bool) -> void:
if not _nodes_ready:
return
var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK
if mod != null:
mod.flip_bend_direction = inverted
bend_flag_changed.emit(joint, inverted)
## Task 2 algorithm: walk the profile's ordered part names back-to-front and
## move_child(part, count - 1) each existing part; unknown/extra children stay
## at the back; missing parts skipped silently.
func _apply_body_z_order() -> void:
if _body_container == null or not is_instance_valid(_body_container):
return
var order: Array = Z_ORDER_BY_PROFILE.get(facing_profile, Z_ORDER_BY_PROFILE[FacingProfile.FORWARD])
for part_name: String in order:
var part := _body_container.get_node_or_null(NodePath(part_name))
if part != null:
_body_container.move_child(part, _body_container.get_child_count() - 1)
+1
View File
@@ -0,0 +1 @@
uid://b4rsae8suaxj7
+201 -148
View File
@@ -28,7 +28,12 @@ const HANDLE_COLOR_HEAD := Color(1.00, 1.00, 0.00) # yellow
const HANDLE_COLOR_TORSO := Color(1.00, 0.00, 1.00) # magenta
const SKELETON_PATH := "Skeleton2D"
const BODY_CONTAINER_PATH := "Body"
## 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"
## Width of the coordinates readout panel (Task 3).
const COORDS_PANEL_WIDTH: float = 320.0
@@ -80,63 +85,12 @@ const LEAF_BONE_IK_PATHS: Dictionary = {
"RightLowerLeg": "IK_Targets/Right_Leg",
}
## Facing profiles for the rig's TwoBoneIK "Flip Bend Direction" flags (Phase
## 9 / Task 1). LEFT = facing left, RIGHT = facing right, FORWARD = facing the
## user. The enum values are used directly as the facing-menu item ids.
enum FacingProfile { LEFT, RIGHT, FORWARD }
const JOINT_HIT_RADIUS_PX: float = 14.0 # screen-space grab radius for bend joints
## Bend joints (upper↔lower limb connectors) whose TwoBoneIK "Flip Bend
## Direction" flag is user-controllable. Keyed by joint name; values are the
## lower bone's NodePath relative to the Skeleton2D.
const BEND_JOINTS: Array[String] = ["LeftArm", "RightArm", "LeftLeg", "RightLeg"]
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 facing
## profile (Phase 9 / Task 2). First entry draws backmost, last frontmost.
## Upper limbs stay behind lower limbs; on the far (behind-torso) side the arm
## pair draws behind the leg pair, on the near (in-front) side the arm pair
## draws in front of the leg pair; the head is 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",
],
}
## 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 }
# ---------------------------------------------------------------------------
# Runtime-built node references
@@ -151,14 +105,18 @@ var _status_label: Label
var _file_dialog: FileDialog
var _coords_panel: PanelContainer
var _coords_label: RichTextLabel
var _anim_dropdown: OptionButton
var _play_button: Button
var _stop_button: Button
var _loop_check: CheckBox
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var _rig: Node2D = null
var _rig_script: StickmanRig = null
var _skeleton: Skeleton2D = null
var _body_container: Node2D = null
var _ik_handles: Dictionary = {} # { String : Marker2D }
var _coord_bones: Dictionary = {} # { String : Bone2D }
@@ -170,15 +128,20 @@ var _is_panning: bool = false
var _pan_last: Vector2 = Vector2.ZERO
var _dragging_handle: Marker2D = null
# Task 1: IK bend-direction state (survives respawn; matches authored defaults).
var _facing_profile: int = FacingProfile.FORWARD
# UI mirror of the last-selected facing profile (persists across respawns; the
# rig's StickmanRig script owns the actual flags/z-order). Used for the [√] menu
# prefix and re-application after each spawn.
var _facing_profile: int = StickmanRig.FacingProfile.FORWARD
var _context_joint: String = ""
var _bend_joint_bones: Dictionary = {} # { String : Bone2D }
var _bend_modifications: Dictionary = {} # { String : SkeletonModification2DTwoBoneIK }
var _facing_button: MenuButton
var _facing_menu: PopupMenu
var _context_menu: PopupMenu
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)
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
@@ -222,13 +185,33 @@ func _build_ui() -> void:
_facing_button = MenuButton.new()
_facing_button.text = "Facing"
_facing_menu = _facing_button.get_popup()
_facing_menu.add_item(_facing_menu_label(FacingProfile.LEFT), FacingProfile.LEFT)
_facing_menu.add_item(_facing_menu_label(FacingProfile.RIGHT), FacingProfile.RIGHT)
_facing_menu.add_item(_facing_menu_label(FacingProfile.FORWARD), FacingProfile.FORWARD)
_facing_menu.add_item(_facing_menu_label(StickmanRig.FacingProfile.LEFT), StickmanRig.FacingProfile.LEFT)
_facing_menu.add_item(_facing_menu_label(StickmanRig.FacingProfile.RIGHT), StickmanRig.FacingProfile.RIGHT)
_facing_menu.add_item(_facing_menu_label(StickmanRig.FacingProfile.FORWARD), StickmanRig.FacingProfile.FORWARD)
_facing_menu.id_pressed.connect(_on_facing_menu_id_pressed)
_facing_menu.about_to_popup.connect(_update_facing_menu_labels)
hbox.add_child(_facing_button)
_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)
var open_btn := Button.new()
open_btn.text = "Open .stk…"
open_btn.pressed.connect(_on_open_pressed)
@@ -266,6 +249,10 @@ func _build_ui() -> void:
_status_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
hbox.add_child(_status_label)
# Rig-behavior controls (Facing + animation) are hidden until an .stk is
# loaded — they are meaningless without a spawned rig.
_set_rig_controls_visible(false)
# Viewport area (fills everything below the top bar).
_viewport_container = SubViewportContainer.new()
_viewport_container.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
@@ -360,6 +347,12 @@ func _on_viewport_container_resized() -> void:
return
_viewport.size = Vector2i(size)
func _set_rig_controls_visible(visible: bool) -> void:
for control: Control in [_facing_button, _anim_dropdown, _play_button, _stop_button, _loop_check]:
if control != null:
control.visible = visible
# ---------------------------------------------------------------------------
# Input handling
# ---------------------------------------------------------------------------
@@ -408,14 +401,13 @@ func _handle_right_click(screen_pos: Vector2) -> void:
func _hit_test_bend_joint(world_pos: Vector2) -> String:
if _rig_script == null or not is_instance_valid(_rig_script):
return ""
var hit_radius := JOINT_HIT_RADIUS_PX / _camera.zoom.x
var best_joint := ""
var best_dist := hit_radius
for joint: String in BEND_JOINTS:
var bone: Bone2D = _bend_joint_bones.get(joint) as Bone2D
if bone == null or not is_instance_valid(bone):
continue
var dist := world_pos.distance_to(bone.global_position)
for joint: String in _rig_script.get_bend_joints():
var dist := world_pos.distance_to(_rig_script.get_bend_joint_global_position(joint))
if dist <= best_dist:
best_joint = joint
best_dist = dist
@@ -574,58 +566,47 @@ func _bone_color(bone_name: String) -> Color:
# ---------------------------------------------------------------------------
func _facing_menu_label(profile: int) -> String:
return ("[√] " if profile == _facing_profile else "") + FacingProfile.keys()[profile].capitalize()
return ("[√] " if profile == _facing_profile else "") + StickmanRig.FacingProfile.keys()[profile].capitalize()
func _update_facing_menu_labels() -> void:
if _facing_menu == null:
return
for profile: int in [FacingProfile.LEFT, FacingProfile.RIGHT, FacingProfile.FORWARD]:
for profile: int in [StickmanRig.FacingProfile.LEFT, StickmanRig.FacingProfile.RIGHT, StickmanRig.FacingProfile.FORWARD]:
var idx := _facing_menu.get_item_index(profile)
if idx >= 0:
_facing_menu.set_item_text(idx, _facing_menu_label(profile))
func _on_facing_menu_id_pressed(id: int) -> void:
_apply_facing_profile(id)
_facing_profile = id
if _rig_script != null and is_instance_valid(_rig_script):
_rig_script.set_facing_profile(id)
_update_facing_menu_labels()
func _apply_facing_profile(profile: int) -> void:
_facing_profile = profile
for joint: String in BEND_JOINTS:
var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK
if mod == null:
continue
mod.flip_bend_direction = bool(PROFILE_FLAGS[profile][joint])
_apply_body_z_order()
_debug_overlay.queue_redraw()
func _apply_body_z_order() -> void:
if _body_container == null or not is_instance_valid(_body_container):
return
var order: Array = Z_ORDER_BY_PROFILE.get(_facing_profile, Z_ORDER_BY_PROFILE[FacingProfile.FORWARD])
for part_name: String in order:
var part := _body_container.get_node_or_null(NodePath(part_name))
if part != null:
_body_container.move_child(part, _body_container.get_child_count() - 1)
func _context_menu_label(joint: String) -> String:
var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK
if mod == null:
if _rig_script == null or not is_instance_valid(_rig_script):
return "Invert Bend"
return "Normal Bend" if mod.flip_bend_direction else "Invert Bend"
return "Normal Bend" if _rig_script.get_joint_bend_flipped(joint) else "Invert Bend"
func _on_context_menu_id_pressed(_id: int) -> void:
if _context_joint.is_empty():
return
var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(_context_joint) as SkeletonModification2DTwoBoneIK
if mod == null:
if _rig_script == null or not is_instance_valid(_rig_script):
return
mod.flip_bend_direction = not mod.flip_bend_direction
_rig_script.set_joint_bend_flipped(_context_joint, not _rig_script.get_joint_bend_flipped(_context_joint))
_debug_overlay.queue_redraw()
func _on_facing_profile_changed(_profile: int) -> void:
_facing_profile = _rig_script.get_facing_profile() if _rig_script != null 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()
# ---------------------------------------------------------------------------
@@ -653,14 +634,27 @@ func _load_and_spawn(path: String) -> void:
return
_rig = rig
_rig_script = rig as StickmanRig
# Capture the user's last-selected profile before add_child: the rig's
# _ready() emits facing_profile_changed(FORWARD), which routes through
# _on_facing_profile_changed and would otherwise clobber the remembered
# _facing_profile mirror before it is re-applied below.
var remembered_profile := _facing_profile
if _rig_script == null:
push_warning("TestHarness: spawned rig is missing the StickmanRig script; facing/bend controls disabled.")
else:
_rig_script.facing_profile_changed.connect(_on_facing_profile_changed)
_rig_script.bend_flag_changed.connect(_on_bend_flag_changed)
_world.add_child(_rig)
_world.move_child(_rig, 0) # keep the rig behind the debug overlay
_resolve_rig_nodes()
_ensure_modification_stack_enabled()
_apply_facing_profile(_facing_profile)
if _rig_script != null and is_instance_valid(_rig_script):
_rig_script.set_facing_profile(remembered_profile)
_status_label.text = path.get_file()
_set_rig_controls_visible(true)
_recenter_camera()
_debug_overlay.queue_redraw()
@@ -669,14 +663,18 @@ func _free_current_rig() -> void:
if _rig != null and is_instance_valid(_rig):
_rig.queue_free()
_rig = null
_rig_script = null
_skeleton = null
_body_container = null
_ik_handles.clear()
_bend_joint_bones.clear()
_bend_modifications.clear()
_coord_bones.clear()
_context_joint = ""
_dragging_handle = null
_anim_player = null
_anim_dropdown.clear()
_selected_animation = ""
_playback_state = PlaybackState.STOPPED
_update_play_button()
_set_rig_controls_visible(false)
func _resolve_rig_nodes() -> void:
@@ -684,10 +682,6 @@ func _resolve_rig_nodes() -> void:
if _skeleton == null:
push_warning("TestHarness: missing '%s' node in rig." % SKELETON_PATH)
_body_container = _rig.get_node_or_null(NodePath(BODY_CONTAINER_PATH)) as Node2D
if _body_container == null:
push_warning("TestHarness: missing '%s' node in rig." % BODY_CONTAINER_PATH)
_ik_handles.clear()
for handle_name: String in IK_HANDLE_PATHS:
var handle := _rig.get_node_or_null(NodePath(IK_HANDLE_PATHS[handle_name])) as Marker2D
@@ -696,40 +690,8 @@ func _resolve_rig_nodes() -> void:
else:
push_warning("TestHarness: missing IK handle '%s'." % IK_HANDLE_PATHS[handle_name])
_resolve_bend_joints()
_resolve_coord_bones()
func _resolve_bend_joints() -> void:
_bend_joint_bones.clear()
_bend_modifications.clear()
if _skeleton == null:
return
for joint: String in BEND_JOINTS:
var path: String = BEND_JOINT_BONE_PATHS[joint]
var bone := _skeleton.get_node_or_null(NodePath(path)) as Bone2D
if bone != null and is_instance_valid(bone):
_bend_joint_bones[joint] = bone
else:
push_warning("TestHarness: missing bend-joint bone '%s'." % path)
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
if stack == null:
return
for i: int in stack.modification_count:
var mod := stack.get_modification(i)
if not (mod is SkeletonModification2DTwoBoneIK):
continue
var ik := mod as SkeletonModification2DTwoBoneIK
for joint: String in BEND_JOINTS:
if ik.joint_two_bone2d_node == NodePath(BEND_JOINT_BONE_PATHS[joint]):
_bend_modifications[joint] = ik
break
for joint: String in BEND_JOINTS:
if not _bend_modifications.has(joint):
push_warning("TestHarness: missing TwoBoneIK modification for bend joint '%s'." % joint)
_resolve_anim_player()
func _resolve_coord_bones() -> void:
@@ -744,14 +706,32 @@ func _resolve_coord_bones() -> void:
push_warning("TestHarness: missing coordinate bone '%s'." % path)
func _ensure_modification_stack_enabled() -> void:
if _skeleton == null:
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
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
if stack != null:
stack.enabled = true
else:
push_warning("TestHarness: Skeleton2D has no modification_stack assigned.")
_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)
func _recenter_camera() -> void:
@@ -763,6 +743,79 @@ func _recenter_camera() -> void:
_camera.zoom = Vector2.ONE
_debug_overlay.queue_redraw()
# ---------------------------------------------------------------------------
# Animation playback
# ---------------------------------------------------------------------------
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()
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"
# ---------------------------------------------------------------------------
# Checkbox handlers
# ---------------------------------------------------------------------------