class_name PropAttachPreview extends PanelContainer ## Private editor visualization. It never creates a PropBlock or touches Sandbox state. enum PreviewHand { LEFT, RIGHT } enum PreviewMotion { FRONT_POSE, WALK_LEFT, WALK_RIGHT } const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd") const StickmanFactoryScript := preload("res://scripts/characters/stickman/stickman_factory.gd") const StkRigAdapterScript := preload("res://scripts/characters/stickman/stk_rig_adapter.gd") const RigScene := preload("res://scenes/characters/stickman/stickman_rig.tscn") const RIG_CENTER := Vector2(340.0, 575.0) const TARGET_RADIUS := 14.0 var _viewport: SubViewport var _viewport_container: SubViewportContainer var _world: Node2D var _rig_root: Node2D var _rig: StickmanRig var _prop: Node2D var _target_marker: Polygon2D var _camera: Camera2D var _hint: Label var _walk_playback_button: Button var _reset_arm_button: Button var _reset_view_button: Button var _hand_option: OptionButton var _motion_option: OptionButton var _shapes: Array[Dictionary] = [] var _anchors: Dictionary = {} var _selected: String = "" var _hand: int = PreviewHand.RIGHT var _motion: PreviewMotion = PreviewMotion.FRONT_POSE var _walk_paused: bool = true var _manual_arm_override: bool = false # Retained only for old test harnesses; Phase 9c UI uses _walk_paused. var _animate: bool = false var _dragging_target: bool = false var _camera_dragging: bool = false var _last_pointer := Vector2.ZERO var _hand_target_rests_local: Dictionary = {} var _camera_zoom := 1.0 var _fitted_zoom := 1.0 func _ready() -> void: custom_minimum_size.x = 360.0 var surface := StyleBoxFlat.new() surface.bg_color = Color(0.075, 0.09, 0.12, 1.0) surface.set_border_width_all(0) surface.set_corner_radius_all(5) add_theme_stylebox_override("panel", surface) _build_ui() _build_private_world() _reframe_camera() func _build_ui() -> void: var layout := VBoxContainer.new() layout.size_flags_vertical = Control.SIZE_EXPAND_FILL add_child(layout) var header := FlowContainer.new() layout.add_child(header) var title := Label.new() title.text = "Holding Preview" title.custom_minimum_size.x = 132.0 header.add_child(title) _hand_option = OptionButton.new() _hand_option.name = "HandOption" _hand_option.add_item("Left Hand", PreviewHand.LEFT) _hand_option.add_item("Right Hand", PreviewHand.RIGHT) _hand_option.select(PreviewHand.RIGHT) _hand_option.item_selected.connect(set_preview_hand) header.add_child(_hand_option) _motion_option = OptionButton.new() _motion_option.name = "MotionOption" _motion_option.add_item("Front Pose", PreviewMotion.FRONT_POSE) _motion_option.add_item("Walk Left", PreviewMotion.WALK_LEFT) _motion_option.add_item("Walk Right", PreviewMotion.WALK_RIGHT) _motion_option.item_selected.connect(set_preview_motion) header.add_child(_motion_option) _hint = Label.new() _hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART layout.add_child(_hint) _viewport_container = SubViewportContainer.new() # The pane keeps its 360 px width, but its height must yield to the editor's # fixed name/status rows when Attach mode makes the preview visible. _viewport_container.custom_minimum_size = Vector2(360.0, 0.0) _viewport_container.size_flags_vertical = Control.SIZE_EXPAND_FILL _viewport_container.stretch = true _viewport_container.gui_input.connect(_on_viewport_gui_input) layout.add_child(_viewport_container) var footer := FlowContainer.new() layout.add_child(footer) _walk_playback_button = Button.new() _walk_playback_button.name = "WalkPlaybackButton" _walk_playback_button.pressed.connect(toggle_walk_playback) footer.add_child(_walk_playback_button) _reset_arm_button = Button.new() _reset_arm_button.text = "Reset Arm" _reset_arm_button.pressed.connect(reset_arm_pose) footer.add_child(_reset_arm_button) _reset_view_button = Button.new() _reset_view_button.text = "Reset View" _reset_view_button.pressed.connect(_reframe_camera) footer.add_child(_reset_view_button) _apply_hit_target_metrics(36.0) func set_ui_hit_target_height(height: float) -> void: _apply_hit_target_metrics(maxf(height, 36.0)) func _apply_hit_target_metrics(height: float) -> void: for option: OptionButton in [_hand_option, _motion_option]: if option != null: option.custom_minimum_size = Vector2(88.0, height) option.mouse_filter = Control.MOUSE_FILTER_STOP option.focus_mode = Control.FOCUS_NONE for button: Button in [_walk_playback_button, _reset_arm_button, _reset_view_button]: if button != null: button.custom_minimum_size = Vector2(112.0, height) button.mouse_filter = Control.MOUSE_FILTER_STOP button.focus_mode = Control.FOCUS_NONE func _build_private_world() -> void: _viewport = SubViewport.new() _viewport.name = "PreviewViewport" _viewport.transparent_bg = false _viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS _viewport_container.add_child(_viewport) _viewport_container.resized.connect(_resize_viewport) _world = Node2D.new() _world.name = "PreviewWorld" _viewport.add_child(_world) _rig_root = Node2D.new() _rig_root.name = "PreviewRigRoot" _rig_root.position = RIG_CENTER _world.add_child(_rig_root) _rig = RigScene.instantiate() as StickmanRig _rig.set_physics_process(false) _rig_root.add_child(_rig) var data: Dictionary = StickmanFactoryScript.load_stk("res://content/stickmen/test.stk") if not data.is_empty(): StkRigAdapterScript.apply(data, _rig) _prop = Node2D.new() _prop.name = "PreviewProp" _world.add_child(_prop) _target_marker = Polygon2D.new() _target_marker.polygon = PackedVector2Array([Vector2(0, -10), Vector2(10, 0), Vector2(0, 10), Vector2(-10, 0)]) _target_marker.color = Color(0.25, 0.9, 1.0, 0.85) _world.add_child(_target_marker) _camera = Camera2D.new() _camera.name = "PreviewCamera" _world.add_child(_camera) _camera.make_current() _disable_collisions(_rig) call_deferred("_finish_rig_setup") func _finish_rig_setup() -> void: var player := _rig.get_node_or_null("AnimationPlayer") as AnimationPlayer if player != null: player.stop() _rig.stop_queue() _rig.clear_queue() _rig.set_facing_profile(StickmanRig.FacingProfile.FORWARD) for hand: int in [PreviewHand.LEFT, PreviewHand.RIGHT]: var target := _target_for(hand) if target != null: _hand_target_rests_local[hand] = target.position _apply_motion_pose() _update_target_marker() _rebuild_prop() _reframe_camera() func set_document(shapes: Array[Dictionary], anchors: Dictionary, selected_anchor_id: String) -> void: _shapes = shapes.duplicate(true) _anchors = anchors.duplicate(true) _selected = selected_anchor_id _rebuild_prop() _reframe_camera() func set_selected_anchor(anchor_id: String) -> void: _selected = anchor_id _rebuild_prop() func set_preview_hand(hand: int) -> void: _hand = clampi(hand, PreviewHand.LEFT, PreviewHand.RIGHT) _manual_arm_override = false _apply_motion_pose() _rebuild_prop() func set_preview_motion(motion: int) -> void: _motion = clampi(motion, PreviewMotion.FRONT_POSE, PreviewMotion.WALK_RIGHT) _manual_arm_override = false _apply_motion_pose(true) _rebuild_prop() _reframe_camera() # Compatibility facade for Phase 9a callers. The visible Facing control was # removed in 9c; callers now map to the equivalent preview motion. func set_facing_profile(profile: int) -> void: if profile == StickmanRig.FacingProfile.LEFT: set_preview_motion(PreviewMotion.WALK_LEFT) elif profile == StickmanRig.FacingProfile.RIGHT: set_preview_motion(PreviewMotion.WALK_RIGHT) else: set_preview_motion(PreviewMotion.FRONT_POSE) func set_walk_paused(paused: bool) -> void: if _motion == PreviewMotion.FRONT_POSE: _walk_paused = true _update_walk_button() return var player := _animation_player() _walk_paused = paused if player != null: if paused: player.pause() else: player.play() _manual_arm_override = false if not paused else _manual_arm_override _update_walk_button() func toggle_walk_playback() -> void: set_walk_paused(not _walk_paused) func set_arm_animation_enabled(enabled: bool) -> void: _animate = enabled if enabled and _motion == PreviewMotion.FRONT_POSE: set_preview_motion(PreviewMotion.WALK_RIGHT) elif not enabled: set_walk_paused(true) func reset_arm_pose() -> void: if _motion != PreviewMotion.FRONT_POSE: set_walk_paused(true) var player := _animation_player() if player != null: player.seek(player.current_animation_position, true) else: _restore_standing_targets() _manual_arm_override = false _animate = false _update_target_marker() _update_prop_transform() func is_preview_available() -> bool: return _rig != null and is_instance_valid(_rig) and _viewport != null func _process(delta: float) -> void: if not is_preview_available(): return _update_target_marker() _update_prop_transform() func _animation_player() -> AnimationPlayer: return _rig.get_node_or_null("AnimationPlayer") as AnimationPlayer if _rig != null else null func _restore_standing_targets() -> void: for hand: int in [PreviewHand.LEFT, PreviewHand.RIGHT]: var target := _target_for(hand) if target != null and _hand_target_rests_local.has(hand): target.position = _hand_target_rests_local[hand] func _apply_motion_pose(restart: bool = false) -> void: if _rig == null: return var player := _animation_player() if _motion == PreviewMotion.FRONT_POSE: _rig.set_facing_profile(StickmanRig.FacingProfile.FORWARD) _rig_root.scale = Vector2.ONE if player != null: player.stop(true) _restore_standing_targets() _walk_paused = true else: var profile: int = StickmanRig.FacingProfile.LEFT if _motion == PreviewMotion.WALK_LEFT else StickmanRig.FacingProfile.RIGHT _rig.set_facing_profile(profile) _rig.scale = Vector2.ONE _rig_root.scale = Vector2(-1.0, 1.0) if _motion == PreviewMotion.WALK_LEFT else Vector2.ONE if player != null: if restart or player.current_animation != "walk_right": player.play("walk_right") player.seek(0.0, true) else: player.play() _walk_paused = false _enable_arm_ik() _update_walk_button() func _update_walk_button() -> void: if _walk_playback_button == null: return var front: bool = _motion == PreviewMotion.FRONT_POSE _walk_playback_button.disabled = front _walk_playback_button.text = "Resume Walk" if _walk_paused else "Pause Walk" _walk_playback_button.tooltip_text = "Choose Walk Left or Walk Right to preview walking." if front else "" func _hand_target() -> Marker2D: return _target_for(_hand) func _target_for(hand: int) -> Marker2D: if _rig == null: return null return _rig.get_node_or_null("IK_Targets/Left_Hand" if hand == PreviewHand.LEFT else "IK_Targets/Right_Hand") as Marker2D func _lower_arm() -> Bone2D: if _rig == null: return null return _rig.get_node_or_null("Skeleton2D/Torso/LeftUpperArm/LeftLowerArm" if _hand == PreviewHand.LEFT else "Skeleton2D/Torso/RightUpperArm/RightLowerArm") as Bone2D func _solved_wrist_transform() -> Transform2D: var lower_visual := _rig.get_node_or_null("Body/LeftLowerArm" if _hand == PreviewHand.LEFT else "Body/RightLowerArm") as Node2D var lower := _lower_arm() if lower == null or lower_visual == null: return Transform2D.IDENTITY var elbow := lower_visual.to_global(Vector2.ZERO) var wrist := lower_visual.to_global(Vector2(0.0, lower.length)) var direction := (wrist - elbow).normalized() if direction.length_squared() < 0.0001: return Transform2D.IDENTITY return Transform2D(direction.angle(), wrist) func _rebuild_prop() -> void: if _prop == null: return for child: Node in _prop.get_children(): child.queue_free() var anchor: Dictionary = _anchors.get(_selected, {}) var grip_ok := str(anchor.get("role", "")) == "grip" and _grip_allows_hand(anchor) _update_hand_choices(anchor) _prop.visible = grip_ok if _hint != null: _hint.text = "Drag the cyan target to pose the arm." if grip_ok else "Select a grip anchor compatible with this hand to preview holding." if not grip_ok: return for shape: Dictionary in _shapes: _add_shape_visual(shape) _update_prop_transform() func _add_shape_visual(shape: Dictionary) -> void: # PropCanvas exports JSON-safe {x,y} dictionaries. Assigning that Array # directly to PackedVector2Array silently turns every point into zero. var points := _coerce_points(shape.get("points", PackedVector2Array())) if points.is_empty() and str(shape.get("shape_type", "polygon")) == "circle": var radius := maxf(float(shape.get("radius", 24.0)), 0.001) var center := Vector2.ZERO for index: int in 24: points.append(center + Vector2.from_angle(TAU * float(index) / 24.0) * radius) if points.is_empty(): return var color := Color.from_string(str(shape.get("fill_color", "#999999")), Color.GRAY) if int(shape.get("shape_type", 0)) == 2 or not bool(shape.get("closed", true)): var line := Line2D.new() line.points = points line.width = maxf(float(shape.get("outline_width", 2.0)), 1.0) line.default_color = Color.from_string(str(shape.get("outline_color", "#eeeeee")), Color.WHITE) _prop.add_child(line) else: var polygon := Polygon2D.new() polygon.polygon = points polygon.color = color _prop.add_child(polygon) func _coerce_points(raw: Variant) -> PackedVector2Array: if raw is PackedVector2Array: return raw as PackedVector2Array var result := PackedVector2Array() if raw is Array: for item: Variant in raw: if item is Vector2: result.append(item as Vector2) elif item is Dictionary: var point: Dictionary = item as Dictionary result.append(Vector2(float(point.get("x", 0.0)), float(point.get("y", 0.0)))) return result func _update_prop_transform() -> void: if _prop == null or not _prop.visible: return var anchor: Dictionary = _anchors.get(_selected, {}) var mirror := _motion == PreviewMotion.WALK_LEFT _prop.global_transform = PropAnchorDataScript.solve_attachment_transform(_solved_wrist_transform(), anchor, Vector2.ONE, mirror) # PreviewProp follows PreviewRigRoot in tree order, so zero remains visible # above the stickman; negative puts it behind and positive remains in front. _prop.z_index = int(anchor.get("z_index_offset", 0)) func _grip_allows_hand(anchor: Dictionary) -> bool: var allowed := str(anchor.get("allowed_hands", "both")) return allowed == "both" or (allowed == "left_only" and _hand == PreviewHand.LEFT) or (allowed == "right_only" and _hand == PreviewHand.RIGHT) func _update_target_marker() -> void: var target := _hand_target() if target != null and _target_marker != null: _target_marker.global_position = target.global_position _target_marker.visible = _prop == null or _prop.visible func _resize_viewport() -> void: if _viewport != null: # SubViewportContainer.stretch owns the viewport size. Resizing it here # fights that ownership and produces an engine warning. _reframe_camera() func _on_viewport_gui_input(event: InputEvent) -> void: if _camera == null: return if event is InputEventMouseButton: var mouse := event as InputEventMouseButton if mouse.button_index == MOUSE_BUTTON_WHEEL_UP or mouse.button_index == MOUSE_BUTTON_WHEEL_DOWN: var world_before := _screen_to_world(mouse.position) _camera_zoom = clampf(_camera_zoom * (1.1 if mouse.button_index == MOUSE_BUTTON_WHEEL_UP else 1.0 / 1.1), _fitted_zoom * 0.3, _fitted_zoom * 3.0) _camera.zoom = Vector2(_camera_zoom, _camera_zoom) _camera.position += world_before - _screen_to_world(mouse.position) return var world_pos := _screen_to_world(mouse.position) if mouse.button_index == MOUSE_BUTTON_LEFT: if mouse.pressed and world_pos.distance_to(_target_marker.global_position) <= TARGET_RADIUS / _camera_zoom: _dragging_target = true if _motion != PreviewMotion.FRONT_POSE: set_walk_paused(true) _manual_arm_override = true elif not mouse.pressed: _dragging_target = false if mouse.button_index == MOUSE_BUTTON_MIDDLE: _camera_dragging = mouse.pressed _last_pointer = mouse.position elif event is InputEventMouseMotion: var motion := event as InputEventMouseMotion if _dragging_target: _set_target_clamped(_screen_to_world(motion.position)) elif _camera_dragging: _camera.position -= (motion.position - _last_pointer) / _camera_zoom _last_pointer = motion.position func _screen_to_world(screen: Vector2) -> Vector2: return _viewport.get_canvas_transform().affine_inverse() * screen func _set_target_clamped(requested: Vector2) -> void: var target := _hand_target() var lower := _lower_arm() if target == null or lower == null: return target.global_position = _clamp_target(requested) func _clamp_target(requested: Vector2) -> Vector2: var target := _hand_target() var lower := _lower_arm() var upper := lower.get_parent() as Bone2D if lower != null else null if target == null or lower == null or upper == null: return requested var origin := upper.global_position var offset := requested - origin var rest: Vector2 = target.to_global(_hand_target_rests_local.get(_hand, Vector2.RIGHT)) - origin if offset.length_squared() < 0.0001: offset = rest.normalized() if rest.length_squared() > 0.0001 else Vector2.RIGHT var min_reach := absf(upper.length - lower.length) + 8.0 var max_reach := maxf(min_reach, upper.length + lower.length - 8.0) return origin + offset.normalized() * clampf(offset.length(), min_reach, max_reach) func _update_hand_choices(anchor: Dictionary) -> void: var allowed := str(anchor.get("allowed_hands", "both")) if allowed == "left_only" and _hand != PreviewHand.LEFT: _hand = PreviewHand.LEFT reset_arm_pose() if allowed == "right_only" and _hand != PreviewHand.RIGHT: _hand = PreviewHand.RIGHT reset_arm_pose() if _hand_option == null: return _hand_option.set_item_disabled(PreviewHand.LEFT, allowed == "right_only") _hand_option.set_item_disabled(PreviewHand.RIGHT, allowed == "left_only") _hand_option.select(_hand) func _enable_arm_ik() -> void: var skeleton := _rig.get_node_or_null("Skeleton2D") as Skeleton2D if skeleton == null: return var stack := skeleton.get_modification_stack() if stack == null: return for index: int in 2: var modification := stack.get_modification(index) if modification is SkeletonModification2DTwoBoneIK: (modification as SkeletonModification2DTwoBoneIK).enabled = true func _reframe_camera() -> void: if _camera == null or _viewport == null or _viewport.size.x < 1: return var bounds := _visual_bounds(true) if bounds.size.length_squared() < 0.001: return bounds = bounds.grow(32.0) _camera.position = bounds.get_center() _fitted_zoom = minf(float(_viewport.size.x) / bounds.size.x, float(_viewport.size.y) / bounds.size.y) _fitted_zoom = maxf(_fitted_zoom, 0.001) _camera_zoom = _fitted_zoom _camera.zoom = Vector2(_camera_zoom, _camera_zoom) func _visual_bounds(full_figure: bool) -> Rect2: var points := PackedVector2Array() _collect_visual_points(_rig.get_node_or_null("Body"), points, full_figure) _collect_visual_points(_prop, points, true) if points.is_empty(): return Rect2(RIG_CENTER - Vector2(200.0, 200.0), Vector2(400.0, 400.0)) var rect := Rect2(points[0], Vector2.ZERO) for point: Vector2 in points: rect = rect.expand(point) return rect func get_prop_global_bounds() -> Rect2: return _visual_bounds_for_node(_prop) func get_preview_world_rect() -> Rect2: if _viewport == null: return Rect2() var transform := _viewport.get_canvas_transform().affine_inverse() var top_left := transform * Vector2.ZERO var bottom_right := transform * Vector2(_viewport.size) return Rect2(top_left, bottom_right - top_left).abs() func get_solved_wrist_transform() -> Transform2D: return _solved_wrist_transform() func _visual_bounds_for_node(node: Node) -> Rect2: var points := PackedVector2Array() _collect_visual_points(node, points, true) if points.is_empty(): return Rect2() var rect := Rect2(points[0], Vector2.ZERO) for point: Vector2 in points: rect = rect.expand(point) return rect func _collect_visual_points(node: Node, out: PackedVector2Array, full_figure: bool) -> void: if node == null or not (node as CanvasItem).visible: return var include := full_figure or not (node.name.contains("Leg")) if include and node is Polygon2D: for point: Vector2 in (node as Polygon2D).polygon: out.append((node as Polygon2D).to_global(point)) if include and node is Line2D: for point: Vector2 in (node as Line2D).points: out.append((node as Line2D).to_global(point)) for child: Node in node.get_children(): _collect_visual_points(child, out, full_figure) func _disable_collisions(node: Node) -> void: if node is CollisionObject2D: var body := node as CollisionObject2D body.collision_layer = 0 body.collision_mask = 0 for child: Node in node.get_children(): _disable_collisions(child)