class_name StkRigAdapter extends RefCounted ## StkRigAdapter - Standalone runtime adapter (Phase 8). ## ## Fits an instantiated master_rig.tscn to a loaded .stk dictionary: re-fits ## the skeleton bone lengths, recalibrates the IK targets, and mounts the ## .stk vector shapes onto the rig's Body/ visual nodes. Consumed by a future ## runtime pipeline, never referenced by the editor. # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- const DEFAULT_LINE_WIDTH := 16.0 const ELBOW_REST_Y := -256.0 const DEFAULT_PROPORTIONS: Dictionary = { "upper_arm_length": 168.0, "lower_arm_length": 200.0, "upper_leg_length": 200.0, "lower_leg_length": 200.0, "torso_length": 391.5, } const PART_KEYS: PackedStringArray = [ "head", "torso", "left_upper_arm", "left_lower_arm", "right_upper_arm", "right_lower_arm", "left_upper_leg", "left_lower_leg", "right_upper_leg", "right_lower_leg", ] ## Bone node paths (relative to rig root), keyed by part name. const BONE_PATHS: Dictionary = { "left_upper_arm": "Skeleton2D/Torso/LeftUpperArm", "left_lower_arm": "Skeleton2D/Torso/LeftUpperArm/LeftLowerArm", "right_upper_arm": "Skeleton2D/Torso/RightUpperArm", "right_lower_arm": "Skeleton2D/Torso/RightUpperArm/RightLowerArm", "left_upper_leg": "Skeleton2D/Torso/LeftUpperLeg", "left_lower_leg": "Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg", "right_upper_leg": "Skeleton2D/Torso/RightUpperLeg", "right_lower_leg": "Skeleton2D/Torso/RightUpperLeg/RightLowerLeg", } ## Body visual node paths (relative to rig root), keyed by part name. const BODY_PATHS: Dictionary = { "head": "Body/Head", "torso": "Body/Body", "left_upper_arm": "Body/LeftUpperArm", "left_lower_arm": "Body/LeftLowerArm", "right_upper_arm": "Body/RightUpperArm", "right_lower_arm": "Body/RightLowerArm", "left_upper_leg": "Body/LeftUpperLeg", "left_lower_leg": "Body/LeftLowerLeg", "right_upper_leg": "Body/RightUpperLeg", "right_lower_leg": "Body/RightLowerLeg", } const IK_LEFT_HAND := "IK_Targets/Left_Hand" const IK_RIGHT_HAND := "IK_Targets/Right_Hand" const IK_LEFT_LEG := "IK_Targets/Left_Leg" const IK_RIGHT_LEG := "IK_Targets/Right_Leg" # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- static func apply(stk_data: Dictionary, rig: Node2D) -> void: _fit_bones(stk_data, rig) _recalibrate_ik(stk_data, rig) _mount_shapes(stk_data, rig) # --------------------------------------------------------------------------- # Proportions # --------------------------------------------------------------------------- static func _get_proportions(stk_data: Dictionary) -> Dictionary: var raw: Variant = stk_data.get("proportions", {}) if raw is Dictionary: return raw as Dictionary return {} # --------------------------------------------------------------------------- # Bone fitting # --------------------------------------------------------------------------- static func _fit_bones(stk_data: Dictionary, rig: Node2D) -> void: var proportions := _get_proportions(stk_data) var ua := float(proportions.get("upper_arm_length", DEFAULT_PROPORTIONS["upper_arm_length"])) var la := float(proportions.get("lower_arm_length", DEFAULT_PROPORTIONS["lower_arm_length"])) var ul := float(proportions.get("upper_leg_length", DEFAULT_PROPORTIONS["upper_leg_length"])) var ll := float(proportions.get("lower_leg_length", DEFAULT_PROPORTIONS["lower_leg_length"])) # Arms — upper length + lower-bone origin on X. _set_prop(rig, BONE_PATHS["left_upper_arm"], "length", ua) _set_prop(rig, BONE_PATHS["left_lower_arm"], "position", Vector2(-ua, 0.0)) _set_prop(rig, BONE_PATHS["left_lower_arm"], "length", la) _set_prop(rig, BONE_PATHS["right_upper_arm"], "length", ua) _set_prop(rig, BONE_PATHS["right_lower_arm"], "position", Vector2(ua, 0.0)) _set_prop(rig, BONE_PATHS["right_lower_arm"], "length", la) # Legs — upper length + lower-bone origin on Y. _set_prop(rig, BONE_PATHS["left_upper_leg"], "length", ul) _set_prop(rig, BONE_PATHS["left_lower_leg"], "position", Vector2(0.0, ul)) _set_prop(rig, BONE_PATHS["left_lower_leg"], "length", ll) _set_prop(rig, BONE_PATHS["right_upper_leg"], "length", ul) _set_prop(rig, BONE_PATHS["right_lower_leg"], "position", Vector2(0.0, ul)) _set_prop(rig, BONE_PATHS["right_lower_leg"], "length", ll) static func _set_prop(rig: Node2D, path: String, prop: String, value: Variant) -> void: var node := rig.get_node_or_null(NodePath(path)) if node == null: push_warning("StkRigAdapter: missing node '%s'; skipped '%s'." % [path, prop]) return node.set(prop, value) # --------------------------------------------------------------------------- # IK target recalibration # --------------------------------------------------------------------------- static func _recalibrate_ik(stk_data: Dictionary, rig: Node2D) -> void: var proportions := _get_proportions(stk_data) var ua := float(proportions.get("upper_arm_length", DEFAULT_PROPORTIONS["upper_arm_length"])) var la := float(proportions.get("lower_arm_length", DEFAULT_PROPORTIONS["lower_arm_length"])) var ul := float(proportions.get("upper_leg_length", DEFAULT_PROPORTIONS["upper_leg_length"])) var ll := float(proportions.get("lower_leg_length", DEFAULT_PROPORTIONS["lower_leg_length"])) var left_leg := rig.get_node_or_null(NodePath(IK_LEFT_LEG)) as Node2D if left_leg != null: left_leg.position = Vector2(left_leg.position.x, ul + ll) else: push_warning("StkRigAdapter: missing IK target '%s'." % IK_LEFT_LEG) var right_leg := rig.get_node_or_null(NodePath(IK_RIGHT_LEG)) as Node2D if right_leg != null: right_leg.position = Vector2(right_leg.position.x, ul + ll) else: push_warning("StkRigAdapter: missing IK target '%s'." % IK_RIGHT_LEG) var left_hand := rig.get_node_or_null(NodePath(IK_LEFT_HAND)) as Node2D if left_hand != null: left_hand.position = Vector2(-ua, ELBOW_REST_Y - la) else: push_warning("StkRigAdapter: missing IK target '%s'." % IK_LEFT_HAND) var right_hand := rig.get_node_or_null(NodePath(IK_RIGHT_HAND)) as Node2D if right_hand != null: right_hand.position = Vector2(ua, ELBOW_REST_Y - la) else: push_warning("StkRigAdapter: missing IK target '%s'." % IK_RIGHT_HAND) # --------------------------------------------------------------------------- # Visual shape mount # --------------------------------------------------------------------------- static func _mount_shapes(stk_data: Dictionary, rig: Node2D) -> void: var body_parts_var: Variant = stk_data.get("body_parts", {}) if not body_parts_var is Dictionary: push_warning("StkRigAdapter: 'body_parts' missing or invalid; no shapes mounted.") return var body_parts: Dictionary = body_parts_var as Dictionary var proportions := _get_proportions(stk_data) for part_name: String in PART_KEYS: var visual := rig.get_node_or_null(NodePath(BODY_PATHS[part_name])) if visual == null: push_warning("StkRigAdapter: missing Body node for part '%s'; skipped." % part_name) continue var shapes: Array = [] var pivot := Vector2.ZERO var part_length := 0.0 var part_data: Variant = body_parts.get(part_name, {}) if part_data is Dictionary: var pd := part_data as Dictionary var shapes_var: Variant = pd.get("shapes", []) if shapes_var is Array: shapes = shapes_var as Array var pivot_var: Variant = pd.get("pivot", {}) if pivot_var is Dictionary: var pv := pivot_var as Dictionary pivot = Vector2(float(pv.get("x", 0.0)), float(pv.get("y", 0.0))) part_length = float(pd.get("length", 0.0)) # Head: prefer setting the circle script's exports. if part_name == "head" and ("radius" in visual): _mount_head_circle(visual, shapes) continue var bone_length := _bone_length_for(part_name, proportions) var scale_factor := 1.0 if part_name != "head" and part_length > 0.0001: scale_factor = bone_length / part_length _reset_own_geometry(visual) _clear_visual_children(visual) for shape in shapes: if shape is Dictionary: _mount_shape(visual, shape as Dictionary, pivot, scale_factor) static func _bone_length_for(part_name: String, proportions: Dictionary) -> float: match part_name: "left_upper_arm", "right_upper_arm": return float(proportions.get("upper_arm_length", DEFAULT_PROPORTIONS["upper_arm_length"])) "left_lower_arm", "right_lower_arm": return float(proportions.get("lower_arm_length", DEFAULT_PROPORTIONS["lower_arm_length"])) "left_upper_leg", "right_upper_leg": return float(proportions.get("upper_leg_length", DEFAULT_PROPORTIONS["upper_leg_length"])) "left_lower_leg", "right_lower_leg": return float(proportions.get("lower_leg_length", DEFAULT_PROPORTIONS["lower_leg_length"])) "torso": return float(proportions.get("torso_length", DEFAULT_PROPORTIONS["torso_length"])) _: return 1.0 static func _mount_head_circle(visual: Node, shapes: Array) -> void: _clear_visual_children(visual) if shapes.is_empty(): visual.set("radius", 0.0) return var radius := 0.0 var bbox := _compute_shapes_bbox(shapes) if bbox.size.x > 0.0001 or bbox.size.y > 0.0001: var diameter: float = bbox.size.y if bbox.size.y > 0.0001 else bbox.size.x radius = diameter * 0.5 visual.set("radius", radius) visual.set("color", _first_shape_color(shapes)) static func _mount_shape(visual: Node, shape: Dictionary, pivot: Vector2, scale_factor: float) -> void: var pts := _transform_points(shape.get("points", []), pivot, scale_factor) if pts.size() < 2: return var color := Color.from_string(str(shape.get("color", "#ffffff")), Color.WHITE) var closed := bool(shape.get("closed", false)) if closed: var poly := Polygon2D.new() poly.polygon = pts poly.color = color visual.add_child(poly) var outline := Line2D.new() outline.points = pts outline.closed = true outline.width = DEFAULT_LINE_WIDTH outline.default_color = color visual.add_child(outline) else: var line := Line2D.new() line.points = pts line.width = DEFAULT_LINE_WIDTH line.default_color = color visual.add_child(line) static func _transform_points(pts_var: Variant, pivot: Vector2, scale_factor: float) -> PackedVector2Array: var out := PackedVector2Array() if pts_var is Array: for p in pts_var as Array: if p is Dictionary: var d := p as Dictionary var pt := Vector2(float(d.get("x", 0.0)), float(d.get("y", 0.0))) out.append((pt - pivot) * scale_factor) elif p is Vector2: out.append(((p as Vector2) - pivot) * scale_factor) elif pts_var is PackedVector2Array: for pt in pts_var as PackedVector2Array: out.append((pt - pivot) * scale_factor) return out static func _reset_own_geometry(visual: Node) -> void: if visual is Line2D: (visual as Line2D).points = PackedVector2Array() elif visual is Polygon2D: (visual as Polygon2D).polygon = PackedVector2Array() static func _clear_visual_children(visual: Node) -> void: for child in visual.get_children(): if child is Line2D or child is Polygon2D: visual.remove_child(child) child.queue_free() static func _compute_shapes_bbox(shapes: Array) -> Rect2: var min_x := INF var min_y := INF var max_x := -INF var max_y := -INF for shape in shapes: if not shape is Dictionary: continue var pts_var: Variant = (shape as Dictionary).get("points", []) if pts_var is Array: for p in pts_var as Array: if p is Dictionary: var d := p as Dictionary min_x = minf(min_x, float(d.get("x", 0.0))) min_y = minf(min_y, float(d.get("y", 0.0))) max_x = maxf(max_x, float(d.get("x", 0.0))) max_y = maxf(max_y, float(d.get("y", 0.0))) if min_x > max_x or min_y > max_y: return Rect2() return Rect2(Vector2(min_x, min_y), Vector2(max_x - min_x, max_y - min_y)) static func _first_shape_color(shapes: Array) -> Color: for shape in shapes: if shape is Dictionary: return Color.from_string(str((shape as Dictionary).get("color", "#ffffff")), Color.WHITE) return Color.WHITE