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:
@@ -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)
|
||||
Reference in New Issue
Block a user