Phase 14 - Organized directories.
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
@tool
|
||||
extends EditorScript
|
||||
|
||||
func _run() -> void:
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if not root:
|
||||
print("No active scene found.")
|
||||
return
|
||||
|
||||
_clean_node(root)
|
||||
print("Cleanup complete! Save your scene (Ctrl+S / Cmd+S) to commit changes.")
|
||||
|
||||
func _clean_node(node: Node) -> void:
|
||||
if node is Bone2D:
|
||||
if node.has_meta("_local_pose_override_enabled_"):
|
||||
node.remove_meta("_local_pose_override_enabled_")
|
||||
node.scale = Vector2.ONE
|
||||
node.transform = node.rest
|
||||
elif node is Node2D and not node.scale.is_equal_approx(Vector2.ONE):
|
||||
node.scale = Vector2.ONE
|
||||
|
||||
for child in node.get_children():
|
||||
_clean_node(child)
|
||||
@@ -0,0 +1 @@
|
||||
uid://v6y7jowrm2c3
|
||||
@@ -0,0 +1,49 @@
|
||||
class_name CliSceneLauncher
|
||||
extends RefCounted
|
||||
## Pure command-line argument parser for the Sandbox Stage scene loader.
|
||||
## No scene-tree or OS access, so it is directly unit-testable. Parses
|
||||
## OS.get_cmdline_user_args() output into a structured {scene_path, play, error}
|
||||
## result; the caller (SandboxStage) is responsible for the error dialog and
|
||||
## autosave policy.
|
||||
|
||||
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
|
||||
|
||||
static func parse(args: Array) -> Dictionary:
|
||||
var scene_path := ""
|
||||
var play := false
|
||||
var used_scene_flag := false
|
||||
var used_positional := false
|
||||
var error := ""
|
||||
var i := 0
|
||||
while i < args.size():
|
||||
var token := String(args[i])
|
||||
if token == "--play":
|
||||
play = true
|
||||
elif token == "--scene":
|
||||
used_scene_flag = true
|
||||
if i + 1 >= args.size():
|
||||
return {"scene_path": "", "play": play, "error": "--scene requires a path."}
|
||||
var value := String(args[i + 1])
|
||||
if value.is_empty():
|
||||
return {"scene_path": "", "play": play, "error": "--scene requires a path."}
|
||||
scene_path = value
|
||||
i += 1
|
||||
elif token.begins_with("--scene="):
|
||||
used_scene_flag = true
|
||||
var value := token.substr("--scene=".length())
|
||||
if value.is_empty():
|
||||
return {"scene_path": "", "play": play, "error": "--scene requires a path."}
|
||||
scene_path = value
|
||||
elif token.begins_with("--"):
|
||||
return {"scene_path": "", "play": play, "error": "Unknown option: %s" % token}
|
||||
else:
|
||||
if used_positional:
|
||||
return {"scene_path": "", "play": play, "error": "Specify the scene with either --scene or a single positional path."}
|
||||
used_positional = true
|
||||
scene_path = token
|
||||
i += 1
|
||||
if used_scene_flag and used_positional:
|
||||
error = "Specify the scene with either --scene or a single positional path."
|
||||
elif play and scene_path.is_empty():
|
||||
error = "--play requires --scene <path>."
|
||||
return {"scene_path": CONTENT_PATHS.canonicalize(scene_path), "play": play, "error": error}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dmoyg7nq46v07
|
||||
@@ -0,0 +1,134 @@
|
||||
@tool
|
||||
extends EditorScript
|
||||
|
||||
## Unified animation generator: bakes walk_left, walk_right and stand_up into
|
||||
## the open scene's AnimationPlayer. Supersedes the old create_walk.gd.
|
||||
##
|
||||
## Note: runtime ragdoll recovery does NOT play the baked stand_up — StickmanRig
|
||||
## tweens the IK targets directly from the captured ragdoll pose to the standing
|
||||
## pose (a fixed first keyframe can never match an arbitrary rest pose). The
|
||||
## baked stand_up is kept as an authored reference / manual-play animation.
|
||||
|
||||
const STAND_UP_DURATION := 2.0
|
||||
|
||||
## IK target marker node paths (relative to the rig root), keyed by marker name.
|
||||
const POSE_PATHS: Dictionary = {
|
||||
"Torso": "IK_Targets/Torso",
|
||||
"Head": "IK_Targets/Head",
|
||||
"Left_Hand": "IK_Targets/Left_Hand",
|
||||
"Right_Hand": "IK_Targets/Right_Hand",
|
||||
"Left_Leg": "IK_Targets/Left_Leg",
|
||||
"Right_Leg": "IK_Targets/Right_Leg",
|
||||
}
|
||||
|
||||
## Ordered marker names, used to iterate the pose templates deterministically.
|
||||
const POSE_MARKERS: Array[String] = ["Torso", "Head", "Left_Hand", "Right_Hand", "Left_Leg", "Right_Leg"]
|
||||
|
||||
## Pose templates (rig-local). POSE_DOWN is a generic "lying on back" rest pose;
|
||||
## POSE_STANDING matches scenes/characters/stickman/stickman_rig.tscn's authored IK-target defaults. The
|
||||
## baked stand_up is an authored reference only — runtime recovery tweens the
|
||||
## IK targets directly from the captured ragdoll pose instead.
|
||||
const POSE_DOWN: Dictionary = {
|
||||
"Torso": { "pos": Vector2(0, 330), "rot": -PI / 2.0 },
|
||||
"Head": { "pos": Vector2(-60, 300), "rot": 0.0 },
|
||||
"Left_Hand": { "pos": Vector2(100, 330), "rot": 0.0 },
|
||||
"Right_Hand": { "pos": Vector2(-100, 330), "rot": 0.0 },
|
||||
"Left_Leg": { "pos": Vector2(90, 300), "rot": 0.0 },
|
||||
"Right_Leg": { "pos": Vector2(-90, 300), "rot": 0.0 },
|
||||
}
|
||||
|
||||
const POSE_STANDING: Dictionary = {
|
||||
"Torso": { "pos": Vector2(0, 10), "rot": 0.0 },
|
||||
"Head": { "pos": Vector2(100, -614), "rot": 0.0 },
|
||||
"Left_Hand": { "pos": Vector2(90, 110), "rot": 0.0 },
|
||||
"Right_Hand": { "pos": Vector2(-90, 110), "rot": 0.0 },
|
||||
"Left_Leg": { "pos": Vector2(-110, 380), "rot": 0.0 },
|
||||
"Right_Leg": { "pos": Vector2(110, 390), "rot": 0.0 },
|
||||
}
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var root = EditorInterface.get_edited_scene_root()
|
||||
if not root:
|
||||
push_error("EditorScript: No active scene open.")
|
||||
return
|
||||
|
||||
var anim_player = root.get_node_or_null("AnimationPlayer") as AnimationPlayer
|
||||
if not anim_player:
|
||||
push_error("EditorScript: AnimationPlayer node not found under root.")
|
||||
return
|
||||
|
||||
_generate_walk_animation(anim_player, "walk_right", false)
|
||||
_generate_walk_animation(anim_player, "walk_left", true)
|
||||
_generate_pose_animation(anim_player, "stand_up", STAND_UP_DURATION, POSE_DOWN, POSE_STANDING)
|
||||
|
||||
|
||||
func _generate_walk_animation(anim_player: AnimationPlayer, anim_name: String, flip_x: bool) -> void:
|
||||
var anim = Animation.new()
|
||||
anim.length = 0.8
|
||||
anim.loop_mode = Animation.LOOP_LINEAR
|
||||
var dir_mult: float = -1.0 if flip_x else 1.0
|
||||
|
||||
# Keyframe positions
|
||||
var raw_tracks = {
|
||||
"IK_Targets/Torso:position": [Vector2(0, 10), Vector2(0, -15), Vector2(0, 10), Vector2(0, -15), Vector2(0, 10)],
|
||||
"IK_Targets/Head:position": [Vector2(100, -614), Vector2(100, -639), Vector2(100, -614), Vector2(100, -639), Vector2(100, -614)],
|
||||
"IK_Targets/Right_Leg:position": [Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390)],
|
||||
"IK_Targets/Left_Leg:position": [Vector2(-110, 380), Vector2(-20, 320), Vector2(110, 390), Vector2(0, 397), Vector2(-110, 380)],
|
||||
"IK_Targets/Right_Hand:position": [Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110)],
|
||||
"IK_Targets/Left_Hand:position": [Vector2(90, 110), Vector2(0, 115), Vector2(-90, 110), Vector2(0, 115), Vector2(90, 110)]
|
||||
}
|
||||
|
||||
var times = [0.0, 0.2, 0.4, 0.6, 0.8]
|
||||
|
||||
for path in raw_tracks:
|
||||
var track_idx = anim.add_track(Animation.TYPE_VALUE)
|
||||
anim.track_set_path(track_idx, path)
|
||||
anim.track_set_interpolation_type(track_idx, Animation.INTERPOLATION_CUBIC)
|
||||
|
||||
for i in range(times.size()):
|
||||
var orig_pos: Vector2 = raw_tracks[path][i]
|
||||
var mirrored_pos = Vector2(orig_pos.x * dir_mult, orig_pos.y)
|
||||
anim.track_insert_key(track_idx, times[i], mirrored_pos)
|
||||
|
||||
_add_animation(anim_player, anim_name, anim)
|
||||
|
||||
|
||||
## Generates a two-keyframe pose animation (start -> end) keying the 6 IK-target
|
||||
## positions plus the Torso rotation. loop_mode is always LOOP_NONE.
|
||||
func _generate_pose_animation(anim_player: AnimationPlayer, anim_name: String, duration: float, start_pose: Dictionary, end_pose: Dictionary) -> void:
|
||||
var anim = Animation.new()
|
||||
anim.length = duration
|
||||
anim.loop_mode = Animation.LOOP_NONE
|
||||
|
||||
for marker_name: String in POSE_MARKERS:
|
||||
var start_pos: Vector2 = start_pose[marker_name]["pos"]
|
||||
var end_pos: Vector2 = end_pose[marker_name]["pos"]
|
||||
var pos_track = anim.add_track(Animation.TYPE_VALUE)
|
||||
anim.track_set_path(pos_track, "%s:position" % POSE_PATHS[marker_name])
|
||||
anim.track_set_interpolation_type(pos_track, Animation.INTERPOLATION_CUBIC)
|
||||
anim.track_insert_key(pos_track, 0.0, start_pos)
|
||||
anim.track_insert_key(pos_track, duration, end_pos)
|
||||
|
||||
var start_rot: float = start_pose["Torso"]["rot"]
|
||||
var end_rot: float = end_pose["Torso"]["rot"]
|
||||
var rot_track = anim.add_track(Animation.TYPE_VALUE)
|
||||
anim.track_set_path(rot_track, "IK_Targets/Torso:rotation")
|
||||
anim.track_set_interpolation_type(rot_track, Animation.INTERPOLATION_CUBIC)
|
||||
anim.track_insert_key(rot_track, 0.0, start_rot)
|
||||
anim.track_insert_key(rot_track, duration, end_rot)
|
||||
|
||||
_add_animation(anim_player, anim_name, anim)
|
||||
|
||||
|
||||
func _add_animation(anim_player: AnimationPlayer, anim_name: String, anim: Animation) -> void:
|
||||
var lib = anim_player.get_animation_library("")
|
||||
if not lib:
|
||||
lib = AnimationLibrary.new()
|
||||
anim_player.add_animation_library("", lib)
|
||||
|
||||
if lib.has_animation(anim_name):
|
||||
lib.remove_animation(anim_name)
|
||||
|
||||
lib.add_animation(anim_name, anim)
|
||||
print("Successfully generated '%s' animation!" % anim_name)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bxsrevw15hyc6
|
||||
@@ -0,0 +1,207 @@
|
||||
# res://scripts/rig_builder.gd
|
||||
@tool
|
||||
extends Node2D
|
||||
|
||||
## Builder script mirroring the exact bone layout, RemoteTransform2D links,
|
||||
## and IK modifications from the source scene.
|
||||
|
||||
@export var build_trigger: bool = false:
|
||||
set(val):
|
||||
if val:
|
||||
build_rig()
|
||||
build_trigger = false
|
||||
|
||||
func build_rig() -> void:
|
||||
for child in get_children():
|
||||
child.queue_free()
|
||||
|
||||
name = "Node2D"
|
||||
|
||||
# 1. Main Hierarchy Containers
|
||||
var sticky := Node2D.new()
|
||||
sticky.name = "Sticky"
|
||||
add_child(sticky)
|
||||
|
||||
var stickman := Node2D.new()
|
||||
stickman.name = "Stickman"
|
||||
sticky.add_child(stickman)
|
||||
|
||||
# Container for visual nodes (Line2D / drawn elements)
|
||||
var body := Node2D.new()
|
||||
body.name = "Body"
|
||||
stickman.add_child(body)
|
||||
|
||||
# Container for skeleton
|
||||
var bones := Node2D.new()
|
||||
bones.name = "Bones"
|
||||
bones.visible = false
|
||||
stickman.add_child(bones)
|
||||
|
||||
var skeleton := Skeleton2D.new()
|
||||
skeleton.name = "Skeleton2D"
|
||||
bones.add_child(skeleton)
|
||||
|
||||
# --- 2. BONE HIERARCHY & REMOTE TRANSFORMS ---
|
||||
# Hip (Root Bone)
|
||||
var hip := Bone2D.new()
|
||||
hip.name = "Hip"
|
||||
hip.position = Vector2(-1, 27)
|
||||
hip.rest = Transform2D(0.0, Vector2(0, 32))
|
||||
skeleton.add_child(hip)
|
||||
|
||||
# Hip drives main Body visual
|
||||
_create_remote_transform("HipTransform", hip, "../../../../Body/Body", Vector2(0, -24))
|
||||
|
||||
# Left Leg
|
||||
var leg_u_l := _create_bone("Leg_Upper_Left", Vector2(-1, 1), hip)
|
||||
_create_remote_transform("RemoteTransform2D", leg_u_l, "../../../../../Body/Leg_Upper_Left", Vector2(1, 0), 2.35619)
|
||||
|
||||
var leg_l_l := _create_bone("Leg_Lower_Left", Vector2(-8, 8), leg_u_l)
|
||||
leg_l_l.auto_calculate_length_and_angle = false
|
||||
leg_l_l.length = 10.425
|
||||
leg_l_l.bone_angle = 89.9999
|
||||
_create_remote_transform("RemoteTransform2D", leg_l_l, "../../../../../../Body/Leg_Lower_Left", Vector2.ZERO, 1.57079)
|
||||
|
||||
# Right Leg
|
||||
var leg_u_r := _create_bone("Leg_Upper_Right", Vector2(2, 1), hip)
|
||||
_create_remote_transform("RemoteTransform2D", leg_u_r, "../../../../../Body/Leg_Upper_Right", Vector2(-2, 0), 0.785397)
|
||||
|
||||
var leg_l_r := _create_bone("Leg_Lower_Right", Vector2(7, 8), leg_u_r)
|
||||
leg_l_r.auto_calculate_length_and_angle = false
|
||||
leg_l_r.length = 10.425
|
||||
leg_l_r.bone_angle = 89.9999
|
||||
_create_remote_transform("RemoteTransform2D", leg_l_r, "../../../../../../Body/Leg_Lower_Right", Vector2.ZERO, 1.57079)
|
||||
|
||||
# Left Arm
|
||||
var arm_u_l := _create_bone("Arm_Upper_Left", Vector2(-2, -17), hip)
|
||||
_create_remote_transform("RemoteTransform2D", arm_u_l, "../../../../../Body/Arm_Upper_Left", Vector2(-11, 0))
|
||||
|
||||
var arm_l_l := _create_bone("Arm_Lower_Left", Vector2(-11, 0), arm_u_l)
|
||||
arm_l_l.auto_calculate_length_and_angle = false
|
||||
arm_l_l.length = 10.0
|
||||
arm_l_l.bone_angle = -180.0
|
||||
_create_remote_transform("RemoteTransform2D", arm_l_l, "../../../../../../Body/Arm_Lower_Left", Vector2(-10, 0))
|
||||
|
||||
# Right Arm
|
||||
var arm_u_r := _create_bone("Arm_Upper_Right", Vector2(2, -17), hip)
|
||||
_create_remote_transform("RemoteTransform2D", arm_u_r, "../../../../../Body/Arm_Upper_Right", Vector2.ZERO)
|
||||
|
||||
var arm_l_r := _create_bone("Arm_Lower_Right", Vector2(11, 0), arm_u_r)
|
||||
arm_l_r.auto_calculate_length_and_angle = false
|
||||
arm_l_r.length = 10.0
|
||||
arm_l_r.bone_angle = 0.0
|
||||
_create_remote_transform("RemoteTransform2D", arm_l_r, "../../../../../../Body/Arm_Lower_Right", Vector2.ZERO)
|
||||
|
||||
# Head
|
||||
var head := _create_bone("Head", Vector2(0, -23), hip)
|
||||
head.rotation = 0.0713012
|
||||
head.rest = Transform2D(
|
||||
Vector2(2.22127e-06, -1.0), # X-axis basis
|
||||
Vector2(1.0, 2.22127e-06), # Y-axis basis
|
||||
Vector2(0.0, -23.0) # Origin position
|
||||
)
|
||||
_create_remote_transform("RemoteTransform2D", head, "../../../../../Body/Head", Vector2(-0.498689, -6.98226))
|
||||
|
||||
# --- 3. IK TARGET CONTAINERS ---
|
||||
var ik_targets := Node2D.new()
|
||||
ik_targets.name = "IK Targets"
|
||||
sticky.add_child(ik_targets)
|
||||
|
||||
_create_target("Arm_Left", Vector2(0, 30), ik_targets)
|
||||
_create_target("Arm_Right", Vector2(4, 29), ik_targets)
|
||||
_create_target("Leg_Left", Vector2(-1, 50), ik_targets)
|
||||
_create_target("Leg_Right", Vector2(0, 51), ik_targets)
|
||||
_create_target("Head", Vector2(0, -10), ik_targets)
|
||||
|
||||
# Body target uses RemoteTransform2D to move Hip bone position
|
||||
var body_target := _create_target("Body", Vector2(-3, -4), ik_targets)
|
||||
_create_remote_transform("RemoteTransform2D", body_target, "../../../Stickman/Bones/Skeleton2D/Hip", Vector2(2, 31))
|
||||
|
||||
# --- 4. MODIFICATION STACK & SOLVERS ---
|
||||
var mod_stack := SkeletonModificationStack2D.new()
|
||||
|
||||
# Leg Left 2-Bone IK
|
||||
var leg_left_ik := SkeletonModification2DTwoBoneIK.new()
|
||||
leg_left_ik.target_nodepath = NodePath("../../../IK Targets/Leg_Left")
|
||||
leg_left_ik.joint_one_bone_idx = 1
|
||||
leg_left_ik.joint_one_bone2d_node = NodePath("Hip/Leg_Upper_Left")
|
||||
leg_left_ik.joint_two_bone_idx = 2
|
||||
leg_left_ik.joint_two_bone2d_node = NodePath("Hip/Leg_Upper_Left/Leg_Lower_Left")
|
||||
|
||||
# Leg Right 2-Bone IK
|
||||
var leg_right_ik := SkeletonModification2DTwoBoneIK.new()
|
||||
leg_right_ik.target_nodepath = NodePath("../../../IK Targets/Leg_Right")
|
||||
leg_right_ik.joint_one_bone_idx = 3
|
||||
leg_right_ik.joint_one_bone2d_node = NodePath("Hip/Leg_Upper_Right")
|
||||
leg_right_ik.joint_two_bone_idx = 4
|
||||
leg_right_ik.joint_two_bone2d_node = NodePath("Hip/Leg_Upper_Right/Leg_Lower_Right")
|
||||
|
||||
# Arm Right 2-Bone IK
|
||||
var arm_right_ik := SkeletonModification2DTwoBoneIK.new()
|
||||
arm_right_ik.target_nodepath = NodePath("../../../IK Targets/Arm_Right")
|
||||
arm_right_ik.flip_bend_direction = true
|
||||
arm_right_ik.joint_one_bone_idx = 7
|
||||
arm_right_ik.joint_one_bone2d_node = NodePath("Hip/Arm_Upper_Right")
|
||||
arm_right_ik.joint_two_bone_idx = 8
|
||||
arm_right_ik.joint_two_bone2d_node = NodePath("Hip/Arm_Upper_Right/Arm_Lower_Right")
|
||||
|
||||
# Arm Left 2-Bone IK
|
||||
var arm_left_ik := SkeletonModification2DTwoBoneIK.new()
|
||||
arm_left_ik.target_nodepath = NodePath("../../../IK Targets/Arm_Left")
|
||||
arm_left_ik.flip_bend_direction = true
|
||||
arm_left_ik.joint_one_bone_idx = 5
|
||||
arm_left_ik.joint_one_bone2d_node = NodePath("Hip/Arm_Upper_Left")
|
||||
arm_left_ik.joint_two_bone_idx = 6
|
||||
arm_left_ik.joint_two_bone2d_node = NodePath("Hip/Arm_Upper_Left/Arm_Lower_Left")
|
||||
|
||||
# Head LookAt Modifier
|
||||
var head_lookat := SkeletonModification2DLookAt.new()
|
||||
head_lookat.bone_index = 9
|
||||
head_lookat.bone2d_node = NodePath("Hip/Head")
|
||||
head_lookat.target_nodepath = NodePath("../../../IK Targets/Head")
|
||||
head_lookat.enable_constraint = true
|
||||
head_lookat.constraint_angle_min = 65.0
|
||||
head_lookat.constraint_angle_max = 295.0
|
||||
head_lookat.constraint_angle_invert = true
|
||||
head_lookat.constraint_in_localspace = true
|
||||
|
||||
mod_stack.add_modification(leg_left_ik)
|
||||
mod_stack.add_modification(leg_right_ik)
|
||||
mod_stack.add_modification(arm_right_ik)
|
||||
mod_stack.add_modification(arm_left_ik)
|
||||
mod_stack.add_modification(head_lookat)
|
||||
|
||||
skeleton.set_modification_stack(mod_stack)
|
||||
mod_stack.enabled = true
|
||||
|
||||
if Engine.is_editor_hint():
|
||||
_set_owners(self, get_tree().edited_scene_root)
|
||||
|
||||
func _create_bone(b_name: String, pos: Vector2, parent_node: Node) -> Bone2D:
|
||||
var bone := Bone2D.new()
|
||||
bone.name = b_name
|
||||
bone.position = pos
|
||||
bone.rest = bone.transform
|
||||
parent_node.add_child(bone)
|
||||
return bone
|
||||
|
||||
func _create_target(t_name: String, pos: Vector2, parent_node: Node) -> Node2D:
|
||||
var target := Node2D.new()
|
||||
target.name = t_name
|
||||
target.position = pos
|
||||
parent_node.add_child(target)
|
||||
return target
|
||||
|
||||
func _create_remote_transform(rt_name: String, parent_node: Node, target_path: String, pos: Vector2 = Vector2.ZERO, rot: float = 0.0) -> RemoteTransform2D:
|
||||
var rt := RemoteTransform2D.new()
|
||||
rt.name = rt_name
|
||||
rt.position = pos
|
||||
rt.rotation = rot
|
||||
rt.remote_path = NodePath(target_path)
|
||||
parent_node.add_child(rt)
|
||||
return rt
|
||||
|
||||
func _set_owners(node: Node, scene_root: Node) -> void:
|
||||
for child in node.get_children():
|
||||
child.owner = scene_root
|
||||
_set_owners(child, scene_root)
|
||||
@@ -0,0 +1 @@
|
||||
uid://6bcl1j1yvpv4
|
||||
@@ -0,0 +1,340 @@
|
||||
class_name PhysicsTestHarness
|
||||
extends Node2D
|
||||
## PhysicsTestHarness - Standalone vector-terrain physics test scene (Phase 2).
|
||||
##
|
||||
## Builds flat ground, an angled ramp and stepped terrain via the TerrainUtils
|
||||
## factory, spawns a scenes/characters/stickman/stickman_rig.tscn instance standing on the flat ground, and
|
||||
## provides camera zoom/pan input plus a top-bar UI (spawn buttons, a
|
||||
## Stickman/Ragdoll mode toggle, and a "Knock Up" force button). NOT wired into
|
||||
## the editor — run standalone via F6 on res://scenes/tools/physics_test_harness.tscn.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const MIN_ZOOM: float = 0.25
|
||||
const MAX_ZOOM: float = 3.0
|
||||
const ZOOM_STEP: float = 1.10
|
||||
|
||||
const RIG_SCENE := preload("res://scenes/characters/stickman/stickman_rig.tscn")
|
||||
|
||||
## Preloaded prop scripts: resolved via preload (not the global class registry)
|
||||
## so the harness compiles even when the editor's class cache is stale.
|
||||
const PROP_UTILS_SCRIPT := preload("res://scripts/props/prop_utils.gd")
|
||||
const PROP_BLOCK_SCRIPT := preload("res://scripts/props/prop_block.gd")
|
||||
|
||||
## World-space Y of the flat ground's top surface.
|
||||
const GROUND_TOP_Y: float = 0.0
|
||||
|
||||
## Rig root placement: the rig's feet rest ~385 px below its root, so placing
|
||||
## the root 385 px above the ground puts the feet on the top surface.
|
||||
const RIG_SPAWN_POSITION := Vector2(0.0, -385.0)
|
||||
|
||||
## Spawn point for dynamic props: above the angled ramp so they tumble down.
|
||||
const PROP_SPAWN_POSITION := Vector2(300.0, -300.0)
|
||||
|
||||
## Upward velocity delta applied by the "Knock Up" button (negative Y = up).
|
||||
const KNOCK_UP_VELOCITY := Vector2(0.0, -450.0)
|
||||
|
||||
## Best-effort collision proxy for the rig (which has no physics bodies): a
|
||||
## static box matching the standing figure's world bounds (x ±120, y 0..-1000).
|
||||
const RIG_PROXY_SIZE := Vector2(240.0, 1000.0)
|
||||
const RIG_PROXY_CENTER := Vector2(0.0, -500.0)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node references
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@onready var _camera: Camera2D = $Camera2D
|
||||
@onready var _environment: Node2D = $Environment
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _is_panning: bool = false
|
||||
var _pan_last: Vector2 = Vector2.ZERO
|
||||
|
||||
## The spawned rig instance (StickmanRig). Stored so the mode toggle can
|
||||
## switch between Stickman (animated) and Ragdoll modes.
|
||||
var _rig: StickmanRig = null
|
||||
|
||||
## Top-bar mode toggle button (text flips "Stickman" <-> "Ragdoll").
|
||||
var _ragdoll_toggle: Button = null
|
||||
|
||||
## Rest-timeout spinbox (director-controlled auto-recovery delay).
|
||||
var _rest_timeout_spinbox: SpinBox = null
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
_camera.make_current()
|
||||
_build_environment()
|
||||
_spawn_rig()
|
||||
_build_ui()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input (camera zoom / pan)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton:
|
||||
_handle_mouse_button(event as InputEventMouseButton)
|
||||
elif event is InputEventMouseMotion:
|
||||
_handle_mouse_motion(event as InputEventMouseMotion)
|
||||
|
||||
|
||||
func _handle_mouse_button(mb: InputEventMouseButton) -> void:
|
||||
match mb.button_index:
|
||||
MOUSE_BUTTON_WHEEL_UP:
|
||||
if mb.pressed:
|
||||
_set_zoom(_camera.zoom.x * ZOOM_STEP)
|
||||
MOUSE_BUTTON_WHEEL_DOWN:
|
||||
if mb.pressed:
|
||||
_set_zoom(_camera.zoom.x / ZOOM_STEP)
|
||||
MOUSE_BUTTON_MIDDLE:
|
||||
_is_panning = mb.pressed
|
||||
_pan_last = mb.position
|
||||
|
||||
|
||||
func _handle_mouse_motion(mm: InputEventMouseMotion) -> void:
|
||||
if _is_panning:
|
||||
_camera.position -= mm.relative / _camera.zoom.x
|
||||
|
||||
|
||||
func _set_zoom(value: float) -> void:
|
||||
var z := clampf(value, MIN_ZOOM, MAX_ZOOM)
|
||||
_camera.zoom = Vector2(z, z)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Top-bar UI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _build_ui() -> void:
|
||||
var ui := CanvasLayer.new()
|
||||
ui.name = "UI"
|
||||
add_child(ui)
|
||||
|
||||
var top_bar := PanelContainer.new()
|
||||
top_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
|
||||
top_bar.offset_bottom = 40.0
|
||||
ui.add_child(top_bar)
|
||||
|
||||
var hbox := HBoxContainer.new()
|
||||
hbox.add_theme_constant_override("separation", 8)
|
||||
top_bar.add_child(hbox)
|
||||
|
||||
var crate_btn := Button.new()
|
||||
crate_btn.text = "Spawn Crate"
|
||||
crate_btn.pressed.connect(_spawn_prop_crate)
|
||||
hbox.add_child(crate_btn)
|
||||
|
||||
var ball_btn := Button.new()
|
||||
ball_btn.text = "Spawn Ball"
|
||||
ball_btn.pressed.connect(_spawn_prop_ball)
|
||||
hbox.add_child(ball_btn)
|
||||
|
||||
var plank_btn := Button.new()
|
||||
plank_btn.text = "Spawn Plank"
|
||||
plank_btn.pressed.connect(_spawn_prop_plank)
|
||||
hbox.add_child(plank_btn)
|
||||
|
||||
_ragdoll_toggle = Button.new()
|
||||
_ragdoll_toggle.toggle_mode = true
|
||||
_ragdoll_toggle.toggled.connect(_on_ragdoll_toggled)
|
||||
hbox.add_child(_ragdoll_toggle)
|
||||
_update_ragdoll_toggle()
|
||||
|
||||
var knock_btn := Button.new()
|
||||
knock_btn.text = "Knock Up"
|
||||
knock_btn.pressed.connect(_knock_up)
|
||||
hbox.add_child(knock_btn)
|
||||
|
||||
var rest_label := Label.new()
|
||||
rest_label.text = "Rest"
|
||||
hbox.add_child(rest_label)
|
||||
|
||||
_rest_timeout_spinbox = SpinBox.new()
|
||||
_rest_timeout_spinbox.min_value = 0.1
|
||||
_rest_timeout_spinbox.max_value = 10.0
|
||||
_rest_timeout_spinbox.step = 0.1
|
||||
_rest_timeout_spinbox.value = 2.0
|
||||
_rest_timeout_spinbox.value_changed.connect(_on_rest_timeout_changed)
|
||||
hbox.add_child(_rest_timeout_spinbox)
|
||||
|
||||
var recover_btn := Button.new()
|
||||
recover_btn.text = "Recover Now"
|
||||
recover_btn.pressed.connect(_recover_now)
|
||||
hbox.add_child(recover_btn)
|
||||
|
||||
if _rig != null:
|
||||
_rest_timeout_spinbox.value = _rig.rest_timeout
|
||||
|
||||
|
||||
func _on_rest_timeout_changed(v: float) -> void:
|
||||
if _rig != null:
|
||||
_rig.rest_timeout = v
|
||||
|
||||
|
||||
func _recover_now() -> void:
|
||||
if _rig != null:
|
||||
_rig.request_recovery()
|
||||
|
||||
|
||||
func _on_ragdoll_toggled(pressed: bool) -> void:
|
||||
if _rig == null:
|
||||
return
|
||||
_rig.set_ragdoll(pressed)
|
||||
if pressed:
|
||||
_remove_rig_collision_proxy()
|
||||
else:
|
||||
_add_rig_collision_proxy()
|
||||
_update_ragdoll_toggle()
|
||||
|
||||
|
||||
func _update_ragdoll_toggle() -> void:
|
||||
if _ragdoll_toggle == null or _rig == null:
|
||||
return
|
||||
var in_ragdoll: bool = _rig.is_in_ragdoll()
|
||||
_ragdoll_toggle.set_pressed_no_signal(in_ragdoll)
|
||||
_ragdoll_toggle.text = "Ragdoll" if in_ragdoll else "Stickman"
|
||||
|
||||
|
||||
func _knock_up() -> void:
|
||||
# Ragdoll: boost every ragdoll body upward (preserves internal structure).
|
||||
if _rig != null and _rig.is_in_ragdoll():
|
||||
_rig.apply_ragdoll_velocity_boost(KNOCK_UP_VELOCITY)
|
||||
# Dynamic props: same upward delta so everything flies together.
|
||||
for child: Node in _environment.get_children():
|
||||
if child is RigidBody2D and is_instance_valid(child):
|
||||
var body := child as RigidBody2D
|
||||
body.apply_central_impulse(KNOCK_UP_VELOCITY * body.mass)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Environment / rig construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _build_environment() -> void:
|
||||
# Flat ground: top surface at GROUND_TOP_Y, extending downward (solid).
|
||||
TerrainUtils.spawn_block(_environment, PackedVector2Array([
|
||||
Vector2(-800.0, GROUND_TOP_Y),
|
||||
Vector2(800.0, GROUND_TOP_Y),
|
||||
Vector2(800.0, GROUND_TOP_Y + 64.0),
|
||||
Vector2(-800.0, GROUND_TOP_Y + 64.0),
|
||||
]))
|
||||
|
||||
# Angled ramp (a sloped quad rising 128 px over its 192 px run).
|
||||
TerrainUtils.spawn_block(_environment, PackedVector2Array([
|
||||
Vector2(208.0, GROUND_TOP_Y),
|
||||
Vector2(400.0, GROUND_TOP_Y - 128.0),
|
||||
Vector2(400.0, GROUND_TOP_Y - 64.0),
|
||||
Vector2(208.0, GROUND_TOP_Y + 64.0),
|
||||
]))
|
||||
|
||||
# Stepped terrain (a single concave staircase — exercises BUILD_SOLIDS).
|
||||
TerrainUtils.spawn_block(_environment, PackedVector2Array([
|
||||
Vector2(496.0, GROUND_TOP_Y + 64.0),
|
||||
Vector2(752.0, GROUND_TOP_Y + 64.0),
|
||||
Vector2(752.0, GROUND_TOP_Y - 192.0),
|
||||
Vector2(688.0, GROUND_TOP_Y - 192.0),
|
||||
Vector2(688.0, GROUND_TOP_Y - 128.0),
|
||||
Vector2(624.0, GROUND_TOP_Y - 128.0),
|
||||
Vector2(624.0, GROUND_TOP_Y - 64.0),
|
||||
Vector2(560.0, GROUND_TOP_Y - 64.0),
|
||||
Vector2(560.0, GROUND_TOP_Y),
|
||||
Vector2(496.0, GROUND_TOP_Y),
|
||||
]))
|
||||
|
||||
|
||||
func _spawn_rig() -> void:
|
||||
var rig := RIG_SCENE.instantiate() as StickmanRig
|
||||
if rig == null:
|
||||
push_warning("PhysicsTestHarness: failed to instantiate scenes/characters/stickman/stickman_rig.tscn.")
|
||||
return
|
||||
rig.position = RIG_SPAWN_POSITION
|
||||
# Manual recovery only ("Recover Now"): auto-recover is left off so the
|
||||
# ragdoll stays down until the operator triggers it, matching sandbox PLAY.
|
||||
rig.auto_recover = false
|
||||
add_child(rig)
|
||||
_rig = rig
|
||||
rig.state_changed.connect(_on_rig_state_changed)
|
||||
_add_rig_collision_proxy()
|
||||
|
||||
|
||||
## Keeps the best-effort collision proxy in sync with the rig's physics mode:
|
||||
## the ragdoll collides directly with the terrain, so the proxy is removed
|
||||
## during RAGDOLL and re-added otherwise. Proxy helpers are idempotent.
|
||||
func _on_rig_state_changed(new_state: int) -> void:
|
||||
if new_state == StickmanRig.RigState.RAGDOLL:
|
||||
_remove_rig_collision_proxy()
|
||||
else:
|
||||
_add_rig_collision_proxy()
|
||||
_update_ragdoll_toggle()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dynamic prop spawning (keys 1/2/3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _spawn_prop_crate() -> void:
|
||||
PROP_UTILS_SCRIPT.spawn_prop(
|
||||
_environment,
|
||||
PROP_SPAWN_POSITION + Vector2(-24.0, 0.0),
|
||||
PROP_UTILS_SCRIPT.create_box(),
|
||||
PROP_BLOCK_SCRIPT.MaterialPreset.WOOD,
|
||||
Vector2(60.0, 0.0)
|
||||
)
|
||||
|
||||
|
||||
func _spawn_prop_ball() -> void:
|
||||
PROP_UTILS_SCRIPT.spawn_prop(
|
||||
_environment,
|
||||
PROP_SPAWN_POSITION,
|
||||
PROP_UTILS_SCRIPT.create_ball(),
|
||||
PROP_BLOCK_SCRIPT.MaterialPreset.RUBBER,
|
||||
Vector2(-80.0, 0.0)
|
||||
)
|
||||
|
||||
|
||||
func _spawn_prop_plank() -> void:
|
||||
PROP_UTILS_SCRIPT.spawn_prop(
|
||||
_environment,
|
||||
PROP_SPAWN_POSITION + Vector2(24.0, 0.0),
|
||||
PROP_UTILS_SCRIPT.create_plank(),
|
||||
PROP_BLOCK_SCRIPT.MaterialPreset.METAL,
|
||||
Vector2(30.0, -40.0)
|
||||
)
|
||||
|
||||
|
||||
## The rig has no physics bodies, so a code-only StaticBody2D proxy provides a
|
||||
## collision surface matching its standing bounds. Props bounce/rest against it.
|
||||
func _add_rig_collision_proxy() -> void:
|
||||
if _find_rig_collision_proxy() != null:
|
||||
return
|
||||
|
||||
var proxy := StaticBody2D.new()
|
||||
proxy.name = "RigCollisionProxy"
|
||||
proxy.position = RIG_PROXY_CENTER
|
||||
|
||||
var shape := CollisionShape2D.new()
|
||||
shape.name = "CollisionShape2D"
|
||||
var rect := RectangleShape2D.new()
|
||||
rect.size = RIG_PROXY_SIZE
|
||||
shape.shape = rect
|
||||
proxy.add_child(shape)
|
||||
|
||||
add_child(proxy)
|
||||
|
||||
|
||||
func _remove_rig_collision_proxy() -> void:
|
||||
var proxy := _find_rig_collision_proxy()
|
||||
if proxy != null:
|
||||
proxy.queue_free()
|
||||
|
||||
|
||||
func _find_rig_collision_proxy() -> Node:
|
||||
for child: Node in get_children():
|
||||
if child.name == "RigCollisionProxy":
|
||||
return child
|
||||
return null
|
||||
@@ -0,0 +1 @@
|
||||
uid://cvejolojthtcs
|
||||
@@ -0,0 +1,890 @@
|
||||
extends Control
|
||||
## TestHarness - Standalone runtime rig viewer / IK playground (Phase 9).
|
||||
##
|
||||
## Loads .stk files via StickmanFactory, spawns a scenes/characters/stickman/stickman_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/tools/test_harness.tscn.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
|
||||
|
||||
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 (see docs/guides/RIGGING.md).
|
||||
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://content/stickmen/break.stk"],
|
||||
["Basic", "res://content/stickmen/basic.stk"],
|
||||
["Test", "res://content/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.current_dir = ProjectSettings.globalize_path(CONTENT_PATHS.STICKMEN_DIR)
|
||||
_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:
|
||||
path = CONTENT_PATHS.canonicalize(path)
|
||||
_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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b7wkt57rkpfo4
|
||||
Reference in New Issue
Block a user