Files
stickman/scripts/test_harness.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

887 lines
29 KiB
GDScript

extends Control
## TestHarness - Standalone runtime rig viewer / IK playground (Phase 9).
##
## Loads .stk files via StickmanFactory, spawns a master_rig.tscn instance into
## a SubViewport, and provides a debug overlay (skeleton bones + IK handles)
## plus interactive IK-handle dragging. NOT wired into the editor — run
## standalone via F6 on res://scenes/test_harness.tscn.
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const MIN_ZOOM: float = 0.1
const MAX_ZOOM: float = 5.0
const ZOOM_STEP: float = 1.10
const HANDLE_HIT_RADIUS_PX: float = 12.0 # screen-space grab radius
const HANDLE_DRAW_RADIUS_PX: float = 6.0 # screen-space marker radius
const JOINT_DOT_RADIUS_PX: float = 4.0 # screen-space bone-joint dot radius
const BONE_LINE_WIDTH_PX: float = 2.0 # screen-space bone line width
const BONE_COLOR_LEFT := Color(0.35, 0.70, 1.00)
const BONE_COLOR_RIGHT := Color(1.00, 0.50, 0.20)
const BONE_COLOR_CENTRAL := Color(1.00, 1.00, 1.00)
const HANDLE_COLOR_HAND := Color(0.00, 1.00, 0.00) # green
const HANDLE_COLOR_FOOT := Color(0.00, 0.50, 1.00) # blue
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"
## 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
## Bone node paths (relative to Skeleton2D) shown in the coordinates panel, in
## display order. The display name is the last path segment.
const COORD_BONE_PATHS: Array[String] = [
"Torso",
"Torso/Head",
"Torso/LeftUpperArm",
"Torso/LeftUpperArm/LeftLowerArm",
"Torso/RightUpperArm",
"Torso/RightUpperArm/RightLowerArm",
"Torso/LeftUpperLeg",
"Torso/LeftUpperLeg/LeftLowerLeg",
"Torso/RightUpperLeg",
"Torso/RightUpperLeg/RightLowerLeg",
]
## Quick-load buttons: [label, res:// path].
const QUICK_LOADS: Array = [
["Break", "res://stickmen/break.stk"],
["Basic", "res://stickmen/basic.stk"],
["Test", "res://stickmen/test.stk"],
]
## IK handle node paths (relative to rig root), keyed by handle name.
## The four limb targets drive TwoBoneIK; "Head" is the LookAt aim point and
## "Torso" is the hip anchor (dragging it translates the whole rig via its
## RemoteTransform2D — no target-following logic).
const IK_HANDLE_PATHS: Dictionary = {
"Left_Hand": "IK_Targets/Left_Hand",
"Right_Hand": "IK_Targets/Right_Hand",
"Left_Leg": "IK_Targets/Left_Leg",
"Right_Leg": "IK_Targets/Right_Leg",
"Head": "IK_Targets/Head",
"Torso": "IK_Targets/Torso",
}
## Leaf bone → IK target node path (relative to rig root), keyed by bone name.
## Used by the bone overlay to draw the forearm/shin segments out to their
## wrist/ankle targets. The Head leaf is NOT listed here — its IK target is a
## LookAt aim point, so it draws along its own bone direction instead (see
## _draw_bones' leaf fallback).
const LEAF_BONE_IK_PATHS: Dictionary = {
"LeftLowerArm": "IK_Targets/Left_Hand",
"RightLowerArm": "IK_Targets/Right_Hand",
"LeftLowerLeg": "IK_Targets/Left_Leg",
"RightLowerLeg": "IK_Targets/Right_Leg",
}
const JOINT_HIT_RADIUS_PX: float = 14.0 # screen-space grab radius for bend joints
## 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
# ---------------------------------------------------------------------------
var _viewport_container: SubViewportContainer
var _viewport: SubViewport
var _world: Node2D
var _camera: Camera2D
var _debug_overlay: Node2D
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 _ik_handles: Dictionary = {} # { String : Marker2D }
var _coord_bones: Dictionary = {} # { String : Bone2D }
var _show_bones: bool = true
var _show_ik: bool = true
var _show_coords: bool = true
var _is_panning: bool = false
var _pan_last: Vector2 = Vector2.ZERO
var _dragging_handle: Marker2D = null
# 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 _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
# ---------------------------------------------------------------------------
func _ready() -> void:
_build_ui()
_viewport_container.resized.connect(_on_viewport_container_resized)
call_deferred("_on_viewport_container_resized")
func _input(event: InputEvent) -> void:
if _file_dialog != null and _file_dialog.visible:
return
if event is InputEventMouseButton:
_handle_mouse_button(event as InputEventMouseButton)
elif event is InputEventMouseMotion:
_handle_mouse_motion(event as InputEventMouseMotion)
# ---------------------------------------------------------------------------
# UI construction
# ---------------------------------------------------------------------------
func _build_ui() -> void:
# Dark background behind the transparent SubViewport.
var bg := ColorRect.new()
bg.color = Color(0.10, 0.10, 0.10, 1.0)
bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(bg)
# Top UI bar.
var top_bar := PanelContainer.new()
top_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
top_bar.offset_bottom = 40.0
add_child(top_bar)
var hbox := HBoxContainer.new()
hbox.add_theme_constant_override("separation", 8)
top_bar.add_child(hbox)
_facing_button = MenuButton.new()
_facing_button.text = "Facing"
_facing_menu = _facing_button.get_popup()
_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)
hbox.add_child(open_btn)
for entry: Array in QUICK_LOADS:
var btn := Button.new()
btn.text = str(entry[0])
btn.pressed.connect(_on_quick_load.bind(str(entry[1])))
hbox.add_child(btn)
var bones_cb := CheckBox.new()
bones_cb.text = "Show Bones"
bones_cb.button_pressed = true
bones_cb.toggled.connect(_on_show_bones_toggled)
hbox.add_child(bones_cb)
var ik_cb := CheckBox.new()
ik_cb.text = "Show IK Handles"
ik_cb.button_pressed = true
ik_cb.toggled.connect(_on_show_ik_toggled)
hbox.add_child(ik_cb)
var coords_cb := CheckBox.new()
coords_cb.text = "Show Coords"
coords_cb.button_pressed = true
coords_cb.toggled.connect(_on_show_coords_toggled)
hbox.add_child(coords_cb)
_status_label = Label.new()
_status_label.text = "No file loaded"
_status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_status_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_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)
_viewport_container.offset_top = 40.0
_viewport_container.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_viewport_container)
_viewport = SubViewport.new()
_viewport.transparent_bg = true
_viewport_container.add_child(_viewport)
_world = Node2D.new()
_world.name = "World"
_viewport.add_child(_world)
_camera = Camera2D.new()
_camera.enabled = true
_world.add_child(_camera)
_camera.make_current()
_debug_overlay = Node2D.new()
_debug_overlay.name = "DebugOverlay"
_debug_overlay.draw.connect(_on_debug_overlay_draw)
_world.add_child(_debug_overlay)
# Coordinates readout panel (added after the viewport container so it
# renders in front of it).
_build_coords_panel()
# File dialog for "Open .stk…".
_file_dialog = FileDialog.new()
_file_dialog.title = "Open .stk"
_file_dialog.access = FileDialog.ACCESS_FILESYSTEM
_file_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
_file_dialog.filters = PackedStringArray(["*.stk ; Stickman Files"])
_file_dialog.file_selected.connect(_on_file_selected)
add_child(_file_dialog)
# Right-click context menu for toggling a bend joint's bend direction.
_context_menu = PopupMenu.new()
_context_menu.add_item("Invert Bend", 0)
_context_menu.id_pressed.connect(_on_context_menu_id_pressed)
add_child(_context_menu)
func _build_coords_panel() -> void:
_coords_panel = PanelContainer.new()
_coords_panel.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
_coords_panel.offset_top = 40.0
_coords_panel.offset_right = -8.0
_coords_panel.offset_left = -COORDS_PANEL_WIDTH
_coords_panel.grow_vertical = Control.GROW_DIRECTION_END
_coords_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
_coords_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
var style := StyleBoxFlat.new()
style.bg_color = Color(0.0, 0.0, 0.0, 0.55)
style.border_color = Color(1.0, 1.0, 1.0, 0.12)
style.border_width_left = 1
style.border_width_top = 1
style.border_width_right = 1
style.border_width_bottom = 1
style.corner_radius_top_left = 4
style.corner_radius_top_right = 4
style.corner_radius_bottom_right = 4
style.corner_radius_bottom_left = 4
style.content_margin_left = 8.0
style.content_margin_top = 8.0
style.content_margin_right = 8.0
style.content_margin_bottom = 8.0
_coords_panel.add_theme_stylebox_override("panel", style)
_coords_label = RichTextLabel.new()
_coords_label.selection_enabled = true
_coords_label.context_menu_enabled = true
_coords_label.fit_content = true
_coords_label.autowrap_mode = TextServer.AUTOWRAP_OFF
_coords_label.scroll_active = false
_coords_label.focus_mode = Control.FOCUS_CLICK
var font := SystemFont.new()
font.font_names = PackedStringArray(["Consolas", "Menlo", "DejaVu Sans Mono", "Courier New"])
_coords_label.add_theme_font_override("normal_font", font)
_coords_label.add_theme_font_size_override("normal_font_size", 18)
_coords_panel.add_child(_coords_label)
add_child(_coords_panel)
func _on_viewport_container_resized() -> void:
var size := _viewport_container.size
if size.x < 1.0 or size.y < 1.0:
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
# ---------------------------------------------------------------------------
func _handle_mouse_button(mb: InputEventMouseButton) -> void:
if not _is_over_viewport(mb.position):
return
if mb.button_index == MOUSE_BUTTON_MIDDLE:
if mb.pressed:
_is_panning = true
_pan_last = mb.position
else:
_is_panning = false
return
if mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed:
_apply_zoom(mb.position, ZOOM_STEP)
return
if mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed:
_apply_zoom(mb.position, 1.0 / ZOOM_STEP)
return
if mb.button_index == MOUSE_BUTTON_RIGHT:
if mb.pressed:
_handle_right_click(mb.position)
return
if mb.button_index == MOUSE_BUTTON_LEFT:
if mb.pressed:
_dragging_handle = _hit_test_handle(_viewport_to_world(mb.position))
else:
_dragging_handle = null
func _handle_right_click(screen_pos: Vector2) -> void:
if _skeleton == null or not is_instance_valid(_skeleton):
return
var world_pos := _viewport_to_world(screen_pos)
var joint := _hit_test_bend_joint(world_pos)
if joint.is_empty():
return
_context_joint = joint
_context_menu.set_item_text(0, _context_menu_label(joint))
_context_menu.popup(Rect2i(Vector2i(screen_pos), Vector2i(1, 1)))
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 _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
return best_joint
func _handle_mouse_motion(mm: InputEventMouseMotion) -> void:
if _is_panning:
var delta := mm.position - _pan_last
_pan_last = mm.position
_camera.position -= delta / _camera.zoom
_debug_overlay.queue_redraw()
return
if _dragging_handle != null and is_instance_valid(_dragging_handle):
_dragging_handle.global_position = _viewport_to_world(mm.position)
_debug_overlay.queue_redraw()
func _is_over_viewport(screen_pos: Vector2) -> bool:
return _viewport_container.get_global_rect().has_point(screen_pos)
func _viewport_to_world(screen_pos: Vector2) -> Vector2:
var local := screen_pos - _viewport_container.global_position
return (local - _viewport.size * 0.5) / _camera.zoom + _camera.position
func _apply_zoom(screen_pos: Vector2, factor: float) -> void:
var world_before := _viewport_to_world(screen_pos)
var new_zoom := clampf(_camera.zoom.x * factor, MIN_ZOOM, MAX_ZOOM)
_camera.zoom = Vector2(new_zoom, new_zoom)
var world_after := _viewport_to_world(screen_pos)
_camera.position += world_before - world_after
_debug_overlay.queue_redraw()
func _hit_test_handle(world_pos: Vector2) -> Marker2D:
var hit_radius := HANDLE_HIT_RADIUS_PX / _camera.zoom.x
var best: Marker2D = null
var best_dist := hit_radius
for handle_name: String in _ik_handles:
var handle := _ik_handles[handle_name] as Marker2D
if handle == null or not is_instance_valid(handle):
continue
var dist := world_pos.distance_to(handle.global_position)
if dist <= best_dist:
best = handle
best_dist = dist
return best
# ---------------------------------------------------------------------------
# Debug overlay drawing
# ---------------------------------------------------------------------------
func _on_debug_overlay_draw() -> void:
if _show_bones:
_draw_bones()
if _show_ik:
_draw_ik_handles()
func _draw_bones() -> void:
if _skeleton == null or not is_instance_valid(_skeleton):
return
var zoom := _camera.zoom.x
var line_w := BONE_LINE_WIDTH_PX / zoom
var dot_r := JOINT_DOT_RADIUS_PX / zoom
for i: int in _skeleton.get_bone_count():
var bone := _skeleton.get_bone(i)
if bone == null:
continue
var color := _bone_color(bone.name)
var origin := bone.global_position
_debug_overlay.draw_circle(origin, dot_r, color)
# Collect the bone's Bone2D children (true bone segments, including the
# nested lower bones).
var bone_children: Array[Bone2D] = []
for child in bone.get_children():
if child is Bone2D:
bone_children.append(child as Bone2D)
if bone_children.is_empty():
# Leaf bone — draw out to its IK target (forearm/shin), falling
# back to the bone's own direction (bone_angle + rotation) when
# there is no target (e.g. the Head, whose target is a LookAt aim
# point, not a joint).
var target := _leaf_bone_target(bone)
if target != null:
_debug_overlay.draw_line(origin, target.global_position, color, line_w)
else:
var fallback := origin + Vector2(bone.get_length(), 0.0).rotated(deg_to_rad(bone.bone_angle)).rotated(bone.global_rotation)
_debug_overlay.draw_line(origin, fallback, color, line_w)
else:
for child_bone in bone_children:
_debug_overlay.draw_line(origin, child_bone.global_position, color, line_w)
func _leaf_bone_target(bone: Bone2D) -> Node2D:
if not LEAF_BONE_IK_PATHS.has(bone.name):
return null
if _rig == null or not is_instance_valid(_rig):
return null
return _rig.get_node_or_null(NodePath(str(LEAF_BONE_IK_PATHS[bone.name]))) as Node2D
func _draw_ik_handles() -> void:
var zoom := _camera.zoom.x
var r := HANDLE_DRAW_RADIUS_PX / zoom
var outline_w := 1.5 / zoom
for handle_name: String in _ik_handles:
var handle := _ik_handles[handle_name] as Marker2D
if handle == null or not is_instance_valid(handle):
continue
var color := _handle_color(handle_name)
var pos := handle.global_position
_debug_overlay.draw_circle(pos, r, color)
_debug_overlay.draw_arc(pos, r, 0.0, TAU, 24, Color(1.0, 1.0, 1.0, 0.8), outline_w)
# Head aim line — visual aid showing what the head bone is aiming at.
var head_handle: Marker2D = _ik_handles.get("Head", null) as Marker2D
if head_handle != null and is_instance_valid(head_handle) \
and _skeleton != null and is_instance_valid(_skeleton):
var head_bone := _skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
if head_bone != null and is_instance_valid(head_bone):
_debug_overlay.draw_line(
head_bone.global_position,
head_handle.global_position,
Color(1.0, 1.0, 0.0, 0.5),
1.5 / zoom
)
func _handle_color(handle_name: String) -> Color:
match handle_name:
"Head":
return HANDLE_COLOR_HEAD
"Torso":
return HANDLE_COLOR_TORSO
_:
return HANDLE_COLOR_HAND if handle_name.ends_with("Hand") else HANDLE_COLOR_FOOT
func _bone_color(bone_name: String) -> Color:
if bone_name.begins_with("Left"):
return BONE_COLOR_LEFT
if bone_name.begins_with("Right"):
return BONE_COLOR_RIGHT
return BONE_COLOR_CENTRAL
# ---------------------------------------------------------------------------
# Facing profile & bend-direction toggles
# ---------------------------------------------------------------------------
func _facing_menu_label(profile: int) -> String:
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 [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:
_facing_profile = id
if _rig_script != null and is_instance_valid(_rig_script):
_rig_script.set_facing_profile(id)
_update_facing_menu_labels()
func _context_menu_label(joint: String) -> String:
if _rig_script == null or not is_instance_valid(_rig_script):
return "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
if _rig_script == null or not is_instance_valid(_rig_script):
return
_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()
# ---------------------------------------------------------------------------
# Spawn / swap
# ---------------------------------------------------------------------------
func _on_open_pressed() -> void:
_file_dialog.popup_centered_ratio(0.6)
func _on_quick_load(path: String) -> void:
_load_and_spawn(path)
func _on_file_selected(path: String) -> void:
_load_and_spawn(path)
func _load_and_spawn(path: String) -> void:
_free_current_rig()
var rig := StickmanFactory.spawn(path)
if rig == null:
_status_label.text = "Error: failed to load " + path.get_file()
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()
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()
func _free_current_rig() -> void:
if _rig != null and is_instance_valid(_rig):
_rig.queue_free()
_rig = null
_rig_script = null
_skeleton = null
_ik_handles.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:
_skeleton = _rig.get_node_or_null(NodePath(SKELETON_PATH)) as Skeleton2D
if _skeleton == null:
push_warning("TestHarness: missing '%s' node in rig." % SKELETON_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
if handle != null:
_ik_handles[handle_name] = handle
else:
push_warning("TestHarness: missing IK handle '%s'." % IK_HANDLE_PATHS[handle_name])
_resolve_coord_bones()
_resolve_anim_player()
func _resolve_coord_bones() -> void:
_coord_bones.clear()
if _skeleton == null:
return
for path: String in COORD_BONE_PATHS:
var bone := _skeleton.get_node_or_null(NodePath(path)) as Bone2D
if bone != null and is_instance_valid(bone):
_coord_bones[path.get_file()] = bone
else:
push_warning("TestHarness: missing coordinate bone '%s'." % path)
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)
func _recenter_camera() -> void:
if _rig == null:
return
# The rig root sits at the hips; nudge the camera up so the whole figure is
# roughly centered in the viewport.
_camera.position = _rig.global_position + Vector2(0.0, -50.0)
_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
# ---------------------------------------------------------------------------
func _on_show_bones_toggled(pressed: bool) -> void:
_show_bones = pressed
_debug_overlay.queue_redraw()
func _on_show_ik_toggled(pressed: bool) -> void:
_show_ik = pressed
_debug_overlay.queue_redraw()
func _on_show_coords_toggled(pressed: bool) -> void:
_show_coords = pressed
if _coords_panel != null:
_coords_panel.visible = pressed
# ---------------------------------------------------------------------------
# Coordinates display
# ---------------------------------------------------------------------------
func _process(_delta: float) -> void:
if not _show_coords or _coords_label == null:
return
_update_coords_display()
func _update_coords_display() -> void:
if _rig == null or not is_instance_valid(_rig):
if _coords_label.text != "No rig loaded":
_coords_label.text = "No rig loaded"
return
var lines := PackedStringArray()
# Skeleton2D root.
if _skeleton != null and is_instance_valid(_skeleton):
lines.append("%s pos %s rot %s" % ["Skeleton2D".lpad(14), _fmt_vec2(_skeleton.global_position), _fmt_deg(_skeleton.global_rotation)])
# Bones.
lines.append("Bones")
for path: String in COORD_BONE_PATHS:
var bone := _coord_bones.get(path.get_file(), null) as Bone2D
if bone == null or not is_instance_valid(bone):
continue
lines.append("%s pos %s rot %s" % [path.get_file().lpad(14), _fmt_vec2(bone.global_position), _fmt_deg(bone.global_rotation)])
# IK targets.
lines.append("IK Targets")
for handle_name: String in IK_HANDLE_PATHS:
var handle := _ik_handles.get(handle_name, null) as Marker2D
if handle == null or not is_instance_valid(handle):
continue
lines.append("%s pos %s" % [handle_name.lpad(14), _fmt_vec2(handle.global_position)])
var text := "\n".join(lines)
if _coords_label.text != text:
_coords_label.text = text
func _fmt_vec2(v: Vector2) -> String:
return "(%8.1f, %8.1f)" % [v.x, v.y]
func _fmt_deg(rad: float) -> String:
return "%7.1f°" % rad_to_deg(rad)