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)