309 lines
12 KiB
GDScript
309 lines
12 KiB
GDScript
class_name StickmanRig
|
|
extends Node2D
|
|
## StickmanRig - Runtime owner of facing direction and per-joint bone bend for
|
|
## master_rig.tscn (Phase 9 / Task 4).
|
|
##
|
|
## Attached to the `Master` root node of master_rig.tscn. Owns the facing
|
|
## preset, the four per-joint TwoBoneIK "Flip Bend Direction" flags, and the
|
|
## Body/* draw order. Non-@tool: node resolution, flag writes and z-order
|
|
## reordering run only at runtime (_ready + setters on a live instance).
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Enums
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Facing profiles. Values are used directly as facing-menu item ids in the
|
|
## harness (0/1/2), so they must stay stable.
|
|
enum FacingProfile { LEFT, RIGHT, FORWARD }
|
|
|
|
## Per-joint bend direction. INVERTED == flip_bend_direction = true.
|
|
enum BendDirection { NORMAL, INVERTED }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
const SKELETON_PATH := "Skeleton2D"
|
|
const BODY_CONTAINER_PATH := "Body"
|
|
|
|
## Bend joints (upper↔lower limb connectors) whose TwoBoneIK "Flip Bend
|
|
## Direction" flag is user-controllable.
|
|
const BEND_JOINTS: Array[String] = ["LeftArm", "RightArm", "LeftLeg", "RightLeg"]
|
|
|
|
## Lower-bone NodePath (relative to Skeleton2D) per bend joint. Used both to
|
|
## resolve the TwoBoneIK modification (via joint_two_bone2d_node) and to report
|
|
## each joint's world position for hit-testing.
|
|
const BEND_JOINT_BONE_PATHS: Dictionary = {
|
|
"LeftArm": "Torso/LeftUpperArm/LeftLowerArm",
|
|
"RightArm": "Torso/RightUpperArm/RightLowerArm",
|
|
"LeftLeg": "Torso/LeftUpperLeg/LeftLowerLeg",
|
|
"RightLeg": "Torso/RightUpperLeg/RightLowerLeg",
|
|
}
|
|
|
|
## flip_bend_direction value per facing profile, keyed by bend-joint name.
|
|
const PROFILE_FLAGS: Dictionary = {
|
|
FacingProfile.LEFT: { "LeftArm": false, "RightArm": false, "LeftLeg": true, "RightLeg": true },
|
|
FacingProfile.RIGHT: { "LeftArm": true, "RightArm": true, "LeftLeg": false, "RightLeg": false },
|
|
FacingProfile.FORWARD: { "LeftArm": false, "RightArm": true, "LeftLeg": true, "RightLeg": false },
|
|
}
|
|
|
|
## Body/* visual part node names in draw order (back-to-front) per profile.
|
|
## First entry backmost, last frontmost. Upper limbs behind lower limbs; on the
|
|
## far (behind-torso) side the arm pair draws behind the leg pair, on the near
|
|
## side the arm pair draws in front; head always frontmost.
|
|
const Z_ORDER_BY_PROFILE: Dictionary = {
|
|
FacingProfile.FORWARD: [
|
|
"Body",
|
|
"LeftUpperLeg", "RightUpperLeg",
|
|
"LeftLowerLeg", "RightLowerLeg",
|
|
"LeftUpperArm", "RightUpperArm",
|
|
"LeftLowerArm", "RightLowerArm",
|
|
"Head",
|
|
],
|
|
FacingProfile.LEFT: [
|
|
"LeftUpperArm", "LeftLowerArm",
|
|
"LeftUpperLeg", "LeftLowerLeg",
|
|
"Body",
|
|
"RightUpperLeg", "RightLowerLeg",
|
|
"RightUpperArm", "RightLowerArm",
|
|
"Head",
|
|
],
|
|
FacingProfile.RIGHT: [
|
|
"RightUpperArm", "RightLowerArm",
|
|
"RightUpperLeg", "RightLowerLeg",
|
|
"Body",
|
|
"LeftUpperLeg", "LeftLowerLeg",
|
|
"LeftUpperArm", "LeftLowerArm",
|
|
"Head",
|
|
],
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Exported controls
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Facing preset. Setting it overwrites the four per-joint bend values from
|
|
## PROFILE_FLAGS and reorders Body/* children. Default FORWARD.
|
|
@export var facing_profile: FacingProfile = FacingProfile.FORWARD:
|
|
set(value):
|
|
if facing_profile == value:
|
|
return
|
|
facing_profile = value
|
|
if _nodes_ready:
|
|
_apply_profile()
|
|
|
|
## Per-joint bend direction (the actual flip_bend_direction source of truth).
|
|
## Defaults match the FORWARD profile. Individually overridable after a profile
|
|
## is applied (drifting away from the preset, matching the harness right-click).
|
|
@export_group("Bend Direction")
|
|
@export_enum("Normal", "Inverted") var left_arm_bend: int = BendDirection.NORMAL:
|
|
set(value):
|
|
left_arm_bend = value
|
|
_set_joint_bend_inverted("LeftArm", value == BendDirection.INVERTED)
|
|
@export_enum("Normal", "Inverted") var right_arm_bend: int = BendDirection.INVERTED:
|
|
set(value):
|
|
right_arm_bend = value
|
|
_set_joint_bend_inverted("RightArm", value == BendDirection.INVERTED)
|
|
@export_enum("Normal", "Inverted") var left_leg_bend: int = BendDirection.INVERTED:
|
|
set(value):
|
|
left_leg_bend = value
|
|
_set_joint_bend_inverted("LeftLeg", value == BendDirection.INVERTED)
|
|
@export_enum("Normal", "Inverted") var right_leg_bend: int = BendDirection.NORMAL:
|
|
set(value):
|
|
right_leg_bend = value
|
|
_set_joint_bend_inverted("RightLeg", value == BendDirection.INVERTED)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Signals
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Emitted when the facing preset changes (after flags + z-order are applied).
|
|
signal facing_profile_changed(profile: int)
|
|
|
|
## Emitted when a single joint's bend direction changes. `flipped` is the new
|
|
## flip_bend_direction value (true == inverted).
|
|
signal bend_flag_changed(joint: String, flipped: bool)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
var _nodes_ready: bool = false
|
|
var _skeleton: Skeleton2D = null
|
|
var _body_container: Node2D = null
|
|
var _bend_joint_bones: Dictionary = {} # { String : Bone2D } (lower bones)
|
|
var _bend_modifications: Dictionary = {} # { String : SkeletonModification2DTwoBoneIK }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _ready() -> void:
|
|
# Resolve all runtime node references.
|
|
_skeleton = get_node_or_null(NodePath(SKELETON_PATH)) as Skeleton2D
|
|
if _skeleton == null:
|
|
push_warning("StickmanRig: missing '%s' node in rig." % SKELETON_PATH)
|
|
|
|
_body_container = get_node_or_null(NodePath(BODY_CONTAINER_PATH)) as Node2D
|
|
if _body_container == null:
|
|
push_warning("StickmanRig: missing '%s' node in rig." % BODY_CONTAINER_PATH)
|
|
|
|
_resolve_bend_modifications()
|
|
|
|
# Enable the modification stack (IK solves only at runtime).
|
|
if _skeleton != null:
|
|
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
|
|
if stack != null:
|
|
stack.enabled = true
|
|
else:
|
|
push_warning("StickmanRig: Skeleton2D has no modification_stack assigned.")
|
|
|
|
# Apply the authored/default profile once: writes the four mod flags from the
|
|
# current var values, reorders Body/* children, and emits the profile signal.
|
|
# _nodes_ready is set first so the per-joint setters route their mod updates
|
|
# + signal emissions through the live apply path (during instantiation the
|
|
# setters only stored values).
|
|
_nodes_ready = true
|
|
_apply_profile()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func set_facing_profile(profile: int) -> void:
|
|
if not PROFILE_FLAGS.has(profile):
|
|
push_warning("StickmanRig: unknown facing profile %d; ignored." % profile)
|
|
return
|
|
facing_profile = profile
|
|
|
|
|
|
func get_facing_profile() -> int:
|
|
return int(facing_profile)
|
|
|
|
|
|
func set_joint_bend_flipped(joint: String, flipped: bool) -> void:
|
|
match joint:
|
|
"LeftArm":
|
|
left_arm_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL
|
|
"RightArm":
|
|
right_arm_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL
|
|
"LeftLeg":
|
|
left_leg_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL
|
|
"RightLeg":
|
|
right_leg_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL
|
|
_:
|
|
push_warning("StickmanRig: unknown bend joint '%s'; ignored." % joint)
|
|
|
|
|
|
func get_joint_bend_flipped(joint: String) -> bool:
|
|
match joint:
|
|
"LeftArm":
|
|
return left_arm_bend == BendDirection.INVERTED
|
|
"RightArm":
|
|
return right_arm_bend == BendDirection.INVERTED
|
|
"LeftLeg":
|
|
return left_leg_bend == BendDirection.INVERTED
|
|
"RightLeg":
|
|
return right_leg_bend == BendDirection.INVERTED
|
|
_:
|
|
push_warning("StickmanRig: unknown bend joint '%s'." % joint)
|
|
return false
|
|
|
|
|
|
func get_bend_joints() -> Array[String]:
|
|
return BEND_JOINTS
|
|
|
|
|
|
func get_bend_joint_global_position(joint: String) -> Vector2:
|
|
var bone := _bend_joint_bones.get(joint) as Bone2D
|
|
if bone == null or not is_instance_valid(bone):
|
|
push_warning("StickmanRig: unknown or missing bend joint '%s'." % joint)
|
|
return Vector2.ZERO
|
|
return bone.global_position
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal resolution / apply
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _resolve_bend_modifications() -> void:
|
|
_bend_joint_bones.clear()
|
|
_bend_modifications.clear()
|
|
if _skeleton == null:
|
|
return
|
|
|
|
for joint: String in BEND_JOINTS:
|
|
var path: String = BEND_JOINT_BONE_PATHS[joint]
|
|
var bone := _skeleton.get_node_or_null(NodePath(path)) as Bone2D
|
|
if bone != null and is_instance_valid(bone):
|
|
_bend_joint_bones[joint] = bone
|
|
else:
|
|
push_warning("StickmanRig: missing bend-joint bone '%s'." % path)
|
|
|
|
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
|
|
if stack == null:
|
|
return
|
|
for i: int in stack.modification_count:
|
|
var mod := stack.get_modification(i)
|
|
if not (mod is SkeletonModification2DTwoBoneIK):
|
|
continue
|
|
var ik := mod as SkeletonModification2DTwoBoneIK
|
|
for joint: String in BEND_JOINTS:
|
|
if ik.joint_two_bone2d_node == NodePath(BEND_JOINT_BONE_PATHS[joint]):
|
|
_bend_modifications[joint] = ik
|
|
break
|
|
|
|
for joint: String in BEND_JOINTS:
|
|
if not _bend_modifications.has(joint):
|
|
push_warning("StickmanRig: missing TwoBoneIK modification for bend joint '%s'." % joint)
|
|
|
|
|
|
## Writes the four per-joint bend vars from PROFILE_FLAGS (through their setters,
|
|
## so the live mods stay in sync), reorders Body/* children, then emits
|
|
## facing_profile_changed.
|
|
func _apply_profile() -> void:
|
|
var flags: Dictionary = PROFILE_FLAGS.get(facing_profile, PROFILE_FLAGS[FacingProfile.FORWARD])
|
|
left_arm_bend = BendDirection.INVERTED if bool(flags.get("LeftArm", false)) else BendDirection.NORMAL
|
|
right_arm_bend = BendDirection.INVERTED if bool(flags.get("RightArm", false)) else BendDirection.NORMAL
|
|
left_leg_bend = BendDirection.INVERTED if bool(flags.get("LeftLeg", false)) else BendDirection.NORMAL
|
|
right_leg_bend = BendDirection.INVERTED if bool(flags.get("RightLeg", false)) else BendDirection.NORMAL
|
|
_apply_body_z_order()
|
|
_apply_head_flip()
|
|
facing_profile_changed.emit(int(facing_profile))
|
|
|
|
func _apply_head_flip() -> void:
|
|
var head := get_node_or_null("Skeleton2D/Torso/Head") as Node2D
|
|
var pivot := get_node_or_null("Skeleton2D/Torso/Head/Pivot") as Node2D
|
|
if pivot != null:
|
|
var is_left := (facing_profile == FacingProfile.LEFT)
|
|
if is_left:
|
|
# Mirror local X and invert double the bone's rotation to mirror in world space
|
|
pivot.scale = Vector2(-1.0, 1.0)
|
|
pivot.rotation = -1.0 * head.rotation
|
|
else:
|
|
pivot.scale = Vector2(1.0, 1.0)
|
|
pivot.rotation = 0.0
|
|
|
|
## Per-joint setter notify: updates the resolved TwoBoneIK mod's
|
|
## flip_bend_direction and emits bend_flag_changed. No-op before _ready (the
|
|
## setter only stored the backing value during instantiation).
|
|
func _set_joint_bend_inverted(joint: String, inverted: bool) -> void:
|
|
if not _nodes_ready:
|
|
return
|
|
var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK
|
|
if mod != null:
|
|
mod.flip_bend_direction = inverted
|
|
bend_flag_changed.emit(joint, inverted)
|
|
|
|
|
|
## Task 2 algorithm: walk the profile's ordered part names back-to-front and
|
|
## move_child(part, count - 1) each existing part; unknown/extra children stay
|
|
## at the back; missing parts skipped silently.
|
|
func _apply_body_z_order() -> void:
|
|
if _body_container == null or not is_instance_valid(_body_container):
|
|
return
|
|
var order: Array = Z_ORDER_BY_PROFILE.get(facing_profile, Z_ORDER_BY_PROFILE[FacingProfile.FORWARD])
|
|
for part_name: String in order:
|
|
var part := _body_container.get_node_or_null(NodePath(part_name))
|
|
if part != null:
|
|
_body_container.move_child(part, _body_container.get_child_count() - 1)
|