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)