Files
stickman/scripts/stickman_rig.gd
T
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

305 lines
12 KiB
GDScript

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)