feat: Implement Stickman Factory and Test Harness for runtime rigging
- Added StickmanFactory to load and instantiate .stk files into master_rig.tscn. - Created a Test Harness scene for debugging bone scales, vector drawing offsets, and IK limits. - Updated stk_rig_adapter to fit head bone and mount head shapes as full geometry. - Enhanced README and PROJECT documentation to reflect new features and usage. - Introduced UI elements for file selection and visual debugging in the Test Harness. - Refactored code to improve clarity and maintainability, including removal of unused functions.
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
class_name StickmanFactory
|
||||
extends RefCounted
|
||||
## StickmanFactory - Runtime factory for spawning rig instances from .stk files
|
||||
## (Phase 9).
|
||||
##
|
||||
## Loads a versioned .stk JSON dictionary and instantiates a master_rig.tscn,
|
||||
## adapting it via StkRigAdapter. Consumed by the standalone test harness, not
|
||||
## referenced by the editor.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const RIG_SCENE := preload("res://master_rig.tscn")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
static func load_stk(path: String) -> Dictionary:
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("StickmanFactory: failed to open '%s' (error %d)." % [path, FileAccess.get_open_error()])
|
||||
return {}
|
||||
|
||||
var text := file.get_as_text()
|
||||
file.close()
|
||||
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if parsed == null or not parsed is Dictionary:
|
||||
push_warning("StickmanFactory: failed to parse '%s' as JSON." % path)
|
||||
return {}
|
||||
|
||||
return parsed as Dictionary
|
||||
|
||||
|
||||
static func spawn_from_data(stk_data: Dictionary) -> Node2D:
|
||||
var rig := RIG_SCENE.instantiate() as Node2D
|
||||
StkRigAdapter.apply(stk_data, rig)
|
||||
return rig
|
||||
|
||||
|
||||
static func spawn(path: String) -> Node2D:
|
||||
var data := load_stk(path)
|
||||
if data.is_empty():
|
||||
return null
|
||||
return spawn_from_data(data)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cdrs7705oidyc
|
||||
+15
-49
@@ -61,6 +61,8 @@ const IK_RIGHT_HAND := "IK_Targets/Right_Hand"
|
||||
const IK_LEFT_LEG := "IK_Targets/Left_Leg"
|
||||
const IK_RIGHT_LEG := "IK_Targets/Right_Leg"
|
||||
|
||||
const HEAD_BONE_PATH := "Skeleton2D/Torso/Head"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -90,6 +92,7 @@ static func _fit_bones(stk_data: Dictionary, rig: Node2D) -> void:
|
||||
var la := float(proportions.get("lower_arm_length", DEFAULT_PROPORTIONS["lower_arm_length"]))
|
||||
var ul := float(proportions.get("upper_leg_length", DEFAULT_PROPORTIONS["upper_leg_length"]))
|
||||
var ll := float(proportions.get("lower_leg_length", DEFAULT_PROPORTIONS["lower_leg_length"]))
|
||||
var torso := float(proportions.get("torso_length", DEFAULT_PROPORTIONS["torso_length"]))
|
||||
|
||||
# Arms — upper length + lower-bone origin on X.
|
||||
_set_prop(rig, BONE_PATHS["left_upper_arm"], "length", ua)
|
||||
@@ -107,6 +110,12 @@ static func _fit_bones(stk_data: Dictionary, rig: Node2D) -> void:
|
||||
_set_prop(rig, BONE_PATHS["right_lower_leg"], "position", Vector2(0.0, ul))
|
||||
_set_prop(rig, BONE_PATHS["right_lower_leg"], "length", ll)
|
||||
|
||||
# Head — position the head bone at the top of the torso (preserve the
|
||||
# existing x so the rig's ~ -0.1288 x-offset is retained).
|
||||
var head_bone := rig.get_node_or_null(NodePath(HEAD_BONE_PATH))
|
||||
if head_bone is Node2D:
|
||||
_set_prop(rig, HEAD_BONE_PATH, "position", Vector2((head_bone as Node2D).position.x, -torso))
|
||||
|
||||
|
||||
static func _set_prop(rig: Node2D, path: String, prop: String, value: Variant) -> void:
|
||||
var node := rig.get_node_or_null(NodePath(path))
|
||||
@@ -183,16 +192,17 @@ static func _mount_shapes(stk_data: Dictionary, rig: Node2D) -> void:
|
||||
pivot = Vector2(float(pv.get("x", 0.0)), float(pv.get("y", 0.0)))
|
||||
part_length = float(pd.get("length", 0.0))
|
||||
|
||||
# Head: prefer setting the circle script's exports.
|
||||
if part_name == "head" and ("radius" in visual):
|
||||
_mount_head_circle(visual, shapes)
|
||||
continue
|
||||
|
||||
var bone_length := _bone_length_for(part_name, proportions)
|
||||
var scale_factor := 1.0
|
||||
if part_name != "head" and part_length > 0.0001:
|
||||
scale_factor = bone_length / part_length
|
||||
|
||||
# Phase 9: the Body/Head node is a plain Node2D carrying an inline
|
||||
# @tool circle-drawing script; clear it so the head is mounted with the
|
||||
# same full-geometry path as every other part.
|
||||
if part_name == "head":
|
||||
visual.set_script(null)
|
||||
|
||||
_reset_own_geometry(visual)
|
||||
_clear_visual_children(visual)
|
||||
|
||||
@@ -217,21 +227,6 @@ static func _bone_length_for(part_name: String, proportions: Dictionary) -> floa
|
||||
return 1.0
|
||||
|
||||
|
||||
static func _mount_head_circle(visual: Node, shapes: Array) -> void:
|
||||
_clear_visual_children(visual)
|
||||
if shapes.is_empty():
|
||||
visual.set("radius", 0.0)
|
||||
return
|
||||
|
||||
var radius := 0.0
|
||||
var bbox := _compute_shapes_bbox(shapes)
|
||||
if bbox.size.x > 0.0001 or bbox.size.y > 0.0001:
|
||||
var diameter: float = bbox.size.y if bbox.size.y > 0.0001 else bbox.size.x
|
||||
radius = diameter * 0.5
|
||||
visual.set("radius", radius)
|
||||
visual.set("color", _first_shape_color(shapes))
|
||||
|
||||
|
||||
static func _mount_shape(visual: Node, shape: Dictionary, pivot: Vector2, scale_factor: float) -> void:
|
||||
var pts := _transform_points(shape.get("points", []), pivot, scale_factor)
|
||||
if pts.size() < 2:
|
||||
@@ -288,32 +283,3 @@ static func _clear_visual_children(visual: Node) -> void:
|
||||
if child is Line2D or child is Polygon2D:
|
||||
visual.remove_child(child)
|
||||
child.queue_free()
|
||||
|
||||
|
||||
static func _compute_shapes_bbox(shapes: Array) -> Rect2:
|
||||
var min_x := INF
|
||||
var min_y := INF
|
||||
var max_x := -INF
|
||||
var max_y := -INF
|
||||
for shape in shapes:
|
||||
if not shape is Dictionary:
|
||||
continue
|
||||
var pts_var: Variant = (shape as Dictionary).get("points", [])
|
||||
if pts_var is Array:
|
||||
for p in pts_var as Array:
|
||||
if p is Dictionary:
|
||||
var d := p as Dictionary
|
||||
min_x = minf(min_x, float(d.get("x", 0.0)))
|
||||
min_y = minf(min_y, float(d.get("y", 0.0)))
|
||||
max_x = maxf(max_x, float(d.get("x", 0.0)))
|
||||
max_y = maxf(max_y, float(d.get("y", 0.0)))
|
||||
if min_x > max_x or min_y > max_y:
|
||||
return Rect2()
|
||||
return Rect2(Vector2(min_x, min_y), Vector2(max_x - min_x, max_y - min_y))
|
||||
|
||||
|
||||
static func _first_shape_color(shapes: Array) -> Color:
|
||||
for shape in shapes:
|
||||
if shape is Dictionary:
|
||||
return Color.from_string(str((shape as Dictionary).get("color", "#ffffff")), Color.WHITE)
|
||||
return Color.WHITE
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
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 SKELETON_PATH := "Skeleton2D"
|
||||
|
||||
## 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.
|
||||
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",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _rig: Node2D = null
|
||||
var _skeleton: Skeleton2D = null
|
||||
var _ik_handles: Dictionary = {} # { String : Marker2D }
|
||||
|
||||
var _show_bones: bool = true
|
||||
var _show_ik: bool = true
|
||||
|
||||
var _is_panning: bool = false
|
||||
var _pan_last: Vector2 = Vector2.ZERO
|
||||
var _dragging_handle: Marker2D = null
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)
|
||||
|
||||
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)
|
||||
|
||||
_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)
|
||||
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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_LEFT:
|
||||
if mb.pressed:
|
||||
_dragging_handle = _hit_test_handle(_viewport_to_world(mb.position))
|
||||
else:
|
||||
_dragging_handle = null
|
||||
|
||||
|
||||
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)
|
||||
|
||||
var parent := bone.get_parent()
|
||||
if parent is Bone2D:
|
||||
_debug_overlay.draw_line((parent as Bone2D).global_position, origin, color, line_w)
|
||||
|
||||
|
||||
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_HAND if handle_name.ends_with("Hand") else HANDLE_COLOR_FOOT
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
_world.add_child(_rig)
|
||||
_world.move_child(_rig, 0) # keep the rig behind the debug overlay
|
||||
|
||||
_resolve_rig_nodes()
|
||||
_ensure_modification_stack_enabled()
|
||||
|
||||
_status_label.text = path.get_file()
|
||||
_recenter_camera()
|
||||
_debug_overlay.queue_redraw()
|
||||
|
||||
|
||||
func _free_current_rig() -> void:
|
||||
if _rig != null and is_instance_valid(_rig):
|
||||
_rig.queue_free()
|
||||
_rig = null
|
||||
_skeleton = null
|
||||
_ik_handles.clear()
|
||||
_dragging_handle = null
|
||||
|
||||
|
||||
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])
|
||||
|
||||
|
||||
func _ensure_modification_stack_enabled() -> void:
|
||||
if _skeleton == null:
|
||||
return
|
||||
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
|
||||
if stack != null:
|
||||
stack.enabled = true
|
||||
else:
|
||||
push_warning("TestHarness: Skeleton2D has no modification_stack assigned.")
|
||||
|
||||
|
||||
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()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://b7wkt57rkpfo4
|
||||
Reference in New Issue
Block a user