diff --git a/AGENTS.md b/AGENTS.md index bd96c27..88ec2e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,21 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and Writes `FILE_VERSION "1.2"`; auto-migrates `"1.0"`/`"1.1"` files on load. Coordinates cross-panel selection so only one shape is selected at a time (`shape_selected` → deselect others). Collects per-part `{shapes[], position, rotation, scale}` for save/load. + - **Phase 6 recent colors:** stores `_recent_colors: Array[String]` (max 8, + most-recent-first), loads from `settings.json` (`recent_colors` key) in + `_load_settings()`, saves on each color selection via `_save_settings()`, and + broadcasts to all 10 panels via `_broadcast_recent_colors()`. + - `_on_color_selected(color, part_name)`: deduplicates, inserts the hex string at the + front, trims to 8 entries, saves settings, then broadcasts to all panels. + - `_broadcast_recent_colors()`: pushes `_recent_colors` to every panel via + `BodyPartPanel.set_recent_colors(_recent_colors)`. + - **Phase 6 status bar (cursor coords):** `_process()` polls + `get_global_mouse_position()` each frame, checks each panel via + `is_cursor_over_drawing(global_pos)` and the preview via + `is_cursor_over_preview(global_pos)`, converts to world space via + `global_to_world(global_pos)`, and writes `"X: ### Y: ###"` to `_status_cursor_coords`. + - **Phase 6 snap status:** `_status_snap_status` displays `"SNAP: ON"` / `"SNAP: OFF"`, + set in `_ready()` and re-synced when snap is toggled (`_on_edit_menu_id_pressed`). - `scripts/body_part_panel.gd` — `class_name BodyPartPanel`, `extends PanelContainer`. Reusable per-part editor. Public API: - `set_shape_data(data: Variant)` — import shape data (Array or single Dictionary; used on Load/Clear) @@ -31,6 +46,10 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and - `deselect()` — clear selection and cancel any in-progress vertex drag - `signal shape_changed(shapes: Array)` — emitted when any shape is created, modified, deleted, or reordered - `signal shape_selected()` — emitted on left-click; the editor deselects all other panels + - `signal color_selected(color: Color)` — emitted when the color is confirmed (OK button) + - `set_recent_colors(colors_hex: Array)` — clears existing ColorPicker presets and populates with the given hex colors + - `is_cursor_over_drawing(global_pos: Vector2) -> bool` — true if `global_pos` is over the drawing surface + - `global_to_world(global_pos: Vector2) -> Vector2` — maps a global position to drawing ("world") space - **Phase 2 vertex editing:** left-click on a shape selects it; drag a vertex handle to reshape in real time; with a shape selected, right-click near an outline edge offers "Create Point" (inserts a vertex flagged `1` at the edge midpoint). Original vertices @@ -41,27 +60,23 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and is active for vertex editing. - **Per-panel zoom:** mouse wheel multiplies `_zoom` by 1.10, clamped to `[0.3, 3.0]`; drawing and input hit-testing both run in world space via `draw_set_transform`. - - **Drawing approach:** closedness is read from the `closed` flag (Phase 2), not - `shape_type`. Closed shapes are filled via `draw_colored_polygon(pts, color)` before a - 2 px outline is drawn with `_draw_polyline(..., closed=true)`. Open shapes render - outline-only (`closed=false`). Shapes draw in array order (z-order). -- `scripts/whole_stickman_preview.gd` — `class_name WholeStickmanPreview`, - `extends PanelContainer`. Assembles all parts and handles drag-to-reposition, rotation, scale. - - `set_body_parts(parts_data)`, `set_all_part_positions(pos)`, `get_part_position(name)` - - `get_part_rotation(name) -> float`, `set_part_rotation(name, degrees)` - - `get_part_scale(name) -> Vector2`, `set_part_scale(name, scale)` - - `_selected_part` (String) — part with white bounding box selected - - `_interaction` (enum: NONE, TRANSLATE, ROTATE, SCALE) — current gizmo interaction - - Rotation gizmo: filled circle centered below bounding box; Ctrl = 15° snap - - Scale gizmo: crosses at 4 corners; Ctrl = aspect ratio lock - - `signal part_moved(part_name, new_position)` - - Per-panel zoom via mouse wheel (×1.10, clamped to `[0.3, 3.0]`); drag hit-tests and - rendering run in world space. + - **Phase 6 touchpad:** `_gui_input` handles `InputEventMagnifyGesture` (pinch zoom) + and `InputEventPanGesture` (2-finger drag panning), both checked before + `InputEventMouseButton`. Pan gesture delta is multiplied by 3.0 for speed + parity with mouse panning. + - **Phase 6 cursor-centered zoom:** both mouse wheel and pinch zoom adjust + `_pan_offset` so the world point under the cursor stays fixed during zoom. + - **Selection gizmos always on top:** bounding box, rotation circle, and scale + crosses for the selected part are drawn in a second pass after all parts, + via `_selected_gizmo_bounds`, so they always render in front. - Scenes: - `scenes/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used for typed `@onready` access: `%MenuBar`, `%StickmanNameEdit`, `%LeftColumn`, `%CenterColumn`, `%WholeStickmanPreview`, `%SaveDialog`, `%LoadDialog`, - `%ClearConfirmDialog`, `%ErrorDialog`. + `%ClearConfirmDialog`, `%ErrorDialog`, `%StatusBar`, `%CursorCoords`, `%SnapStatus`. + - **Phase 6 status bar:** `StatusBar` is an `HBoxContainer` bottom-anchored at 28 px + height containing `CursorCoords` (left) and `SnapStatus` (right); `MainLayout` + `offset_bottom` is `-28.0` to leave room for it. - `scenes/body_part_panel.tscn` — instantiated 10× at runtime (5 per column). Each panel sets `size_flags_vertical = SIZE_EXPAND_FILL` so the panels expand to fill the column height in their parent VBoxContainer. @@ -83,6 +98,13 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and - The JSON `.stk` format is defined in `README.md` (versioned `"1.2"`, extensible; `"1.0"`/`"1.1"` files auto-migrate on load). +### settings.json (Phase 6) +- Persisted editor preferences written to `settings.json` via `_save_settings()` and + loaded in `_load_settings()`. +- Keys: `version`, `grid_size`, `snap_to_grid`, `recent_colors`. +- `recent_colors: Array[String]` — the last up-to-8 selected hex colors, most-recent-first. + Populated on load and flushed on every color selection. + ### Legacy scene (do not delete) - `stick.tscn` — the original rigged/animated figure using `Skeleton2D` + IK targets + `RemoteTransform2D` + `Line2D` limbs, plus an embedded `@tool` script drawing the head diff --git a/BUGS.md b/BUGS.md index 9f7f558..5f09427 100644 --- a/BUGS.md +++ b/BUGS.md @@ -35,3 +35,14 @@ ### Snap to grid when moving an object 1. Currently snapping to the grid snaps the mouse cursor to the grid, however, that can lead to object still not lining up properly. The object's bounding box should be what snaps to the grid. If a reference point is needed for snapping, use the object's upper left point of the bounding box. + +## Stickman editor (Phase 6 Round 1) + +### Seletion bounding box as well as rotation 'dot' should stay in front of all other objects. +If an object is selected, it's bounding box and rotation dot should be in front of all other objects. + +### Touchpad panning is too slow +Panning using the touchpad does not moves too slowly. We need to increase the distance the panning moves through touchpad. + +### Zooming in/out +When zooming in, we should use the mouse cursor as the zoom in point. If the cursor is in the window should determine where the zoom should happen. \ No newline at end of file diff --git a/PROJECT.md b/PROJECT.md index 54b5f0a..d69128a 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -265,6 +265,27 @@ The .stk file should account for the new attributes introduced in this phase. - Object's mirror status? - Shape's mirror status? +## Stickman editor (Phase 6) + +### Touchpad controls +Currently the mouse handles most of the controls. We need to adapt the controls to a touchpad as well. We should make the following adaptions: +- Panning in each window is handled by holding down the middle mouse button and moving the mouse. This should be done on a touchpad by using a 2 finger drag. +- Zoom in handled by the scroll wheel on the mouse. It should also be handled by pinch zooming on the touchpad. + +### Color picker recent colors +The color picker currently is not storing recent colors. It should keep track of at least 8 colors that were used previously in the project so that the user can reuse colors. + +### Bottom of screen status bar +Just like the menu bar at the top of the screen, the bottom of the screen should have a status bar that will hold information about the project, current operation, etc. It should be easily readable + +### Pixel (x, y) display on the status bar +In the status bar described above, we should display the current coordinates of the cursor in the current window in pixels. The coordinates should be based off of which window the cursor is currently over. Make sure to take into account panning and zooming, so the windows should probably have a set coordinate limit. Maybe (0, 0) to (large x, large y) This coordinate display should be on the left side of the status bar. + +### Snap status +In the status bar mentioned above, there should a display that shows if snapping is on or off. Label it as "SNAP: OFF" or "SNAP: ON". That display should be on the right side of the status bar. + + + ## Rules 1. Try to use standard Godot controls when applicable diff --git a/master_rig.tscn b/master_rig.tscn new file mode 100644 index 0000000..b4421ef --- /dev/null +++ b/master_rig.tscn @@ -0,0 +1,182 @@ +[gd_scene format=3 uid="uid://dr5ef3l2var2s"] + +[sub_resource type="GDScript" id="GDScript_f0s26"] +script/source = "@tool +extends Node2D +@export var radius : float = 100.0: + set(value): + radius = value + queue_redraw() + +@export var color : Color = Color.WHITE: + set(value): + color = value + queue_redraw() + + +func _draw(): + draw_circle(Vector2(0,0), radius, color) +" + +[sub_resource type="SkeletonModification2DTwoBoneIK" id="SkeletonModification2DTwoBoneIK_yvxej"] +target_nodepath = NodePath("../IK_Targets/Right_Hand") +flip_bend_direction = true +joint_one_bone_idx = 4 +joint_one_bone2d_node = NodePath("Torso/RightUpperArm") +joint_two_bone_idx = 5 +joint_two_bone2d_node = NodePath("Torso/RightUpperArm/RightLowerArm") + +[sub_resource type="SkeletonModification2DTwoBoneIK" id="SkeletonModification2DTwoBoneIK_f0s26"] +target_nodepath = NodePath("../IK_Targets/Left_Hand") +joint_one_bone_idx = 2 +joint_one_bone2d_node = NodePath("Torso/LeftUpperArm") +joint_two_bone_idx = 3 +joint_two_bone2d_node = NodePath("Torso/LeftUpperArm/LeftLowerArm") + +[sub_resource type="SkeletonModificationStack2D" id="SkeletonModificationStack2D_j4hao"] +enabled = true +modification_count = 2 +modifications/0 = SubResource("SkeletonModification2DTwoBoneIK_yvxej") +modifications/1 = SubResource("SkeletonModification2DTwoBoneIK_f0s26") + +[node name="Master" type="Node2D" unique_id=319103476] + +[node name="Body" type="Node2D" parent="." unique_id=327811712] + +[node name="Head" type="Node2D" parent="Body" unique_id=864822355] +position = Vector2(704, 104) +scale = Vector2(0.99999964, 0.99999964) +script = SubResource("GDScript_f0s26") + +[node name="Body" type="Line2D" parent="Body" unique_id=1290443463] +position = Vector2(704, 184) +points = PackedVector2Array(0, 0, 0, 400) +width = 16.0 +default_color = Color(0.445488, 0.445488, 0.445488, 1) + +[node name="LeftUpperLeg" type="Line2D" parent="Body" unique_id=199373156] +position = Vector2(703.99994, 576) +rotation = 0.5235988 +points = PackedVector2Array(0, 0, 0, 200) +width = 16.0 +default_color = Color(0.445488, 0.445488, 0.445488, 1) + +[node name="RightUpperLeg" type="Line2D" parent="Body" unique_id=1396556308] +position = Vector2(703.99994, 576) +rotation = -0.5235988 +points = PackedVector2Array(0, 0, 0, 200) +width = 16.0 +default_color = Color(0.445488, 0.445488, 0.445488, 1) + +[node name="LeftLowerLeg" type="Line2D" parent="Body" unique_id=1590485904] +position = Vector2(608, 744) +points = PackedVector2Array(0, 0, 0, 200) +width = 16.0 +default_color = Color(0.445488, 0.445488, 0.445488, 1) + +[node name="RightLowerLeg" type="Line2D" parent="Body" unique_id=29203948] +position = Vector2(800, 744) +points = PackedVector2Array(0, 0, 0, 200) +width = 16.0 +default_color = Color(0.445488, 0.445488, 0.445488, 1) + +[node name="LeftUpperArm" type="Line2D" parent="Body" unique_id=744524157] +position = Vector2(704, 328) +rotation = 1.4909883 +scale = Vector2(0.9999922, 0.9999922) +points = PackedVector2Array(0, 0, 0, 175) +width = 16.0 +default_color = Color(0.445488, 0.445488, 0.445488, 1) + +[node name="RightUpperArm" type="Line2D" parent="Body" unique_id=1593646765] +position = Vector2(704, 328) +rotation = -1.4639877 +scale = Vector2(0.99998343, 0.99998343) +points = PackedVector2Array(0, 0, 0, 175) +width = 16.0 +default_color = Color(0.445488, 0.445488, 0.445488, 1) + +[node name="LeftLowerArm" type="Line2D" parent="Body" unique_id=2142117097] +position = Vector2(536.536, 341.3934) +rotation = 3.0600982 +scale = Vector2(0.99998534, 0.99998534) +points = PackedVector2Array(0, 0, 0, 200) +width = 16.0 +default_color = Color(0.445488, 0.445488, 0.445488, 1) + +[node name="RightLowerArm" type="Line2D" parent="Body" unique_id=1200980480] +position = Vector2(871.03986, 345.90945) +rotation = -2.8942726 +scale = Vector2(0.9999745, 0.9999745) +points = PackedVector2Array(0, 0, 0, 200) +width = 16.0 +default_color = Color(0.445488, 0.445488, 0.445488, 1) + +[node name="Skeleton2D" type="Skeleton2D" parent="." unique_id=1854735445] +modification_stack = SubResource("SkeletonModificationStack2D_j4hao") + +[node name="Torso" type="Bone2D" parent="Skeleton2D" unique_id=744339938] +position = Vector2(704, 576) +rest = Transform2D(1, 0, 0, 1, 704, 576) + +[node name="Head" type="Bone2D" parent="Skeleton2D/Torso" unique_id=1487357366] +position = Vector2(0, -392) +rest = Transform2D(1, 0, 0, 1, 0, -392) + +[node name="LeftUpperArm" type="Bone2D" parent="Skeleton2D/Torso" unique_id=1840957808] +position = Vector2(0, -248) +rotation = -0.7853982 +scale = Vector2(0.9999999, 0.9999999) +rest = Transform2D(1, 0, 0, 1, -168, -248) +metadata/_local_pose_override_enabled_ = true + +[node name="LeftLowerArm" type="Bone2D" parent="Skeleton2D/Torso/LeftUpperArm" unique_id=721380545] +position = Vector2(-118.79398, -118.793945) +rotation = -1.5707964 +scale = Vector2(0.9999999, 0.9999999) +rest = Transform2D(1, 0, 0, 1, -136, -136) +auto_calculate_length_and_angle = false +length = 200.0 +bone_angle = 0.0 +metadata/_local_pose_override_enabled_ = true + +[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/LeftUpperArm/LeftLowerArm" unique_id=1189277122] +position = Vector2(3.0517578e-05, 1.9073486e-06) +rotation = 4.712389 +remote_path = NodePath("../../../../../Body/LeftLowerArm") + +[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/LeftUpperArm" unique_id=1110605862] +rotation = 2.3561945 +remote_path = NodePath("../../../../Body/LeftUpperArm") + +[node name="RightUpperArm" type="Bone2D" parent="Skeleton2D/Torso" unique_id=36616186] +position = Vector2(0, -248) +scale = Vector2(0.9999998, 0.9999998) +rest = Transform2D(0.9999999, 0, 0, 0.9999999, 0, -248) +metadata/_local_pose_override_enabled_ = true + +[node name="RightLowerArm" type="Bone2D" parent="Skeleton2D/Torso/RightUpperArm" unique_id=1018820563] +position = Vector2(168, 0) +rotation = -0.7853978 +scale = Vector2(0.9999999, 0.9999999) +rest = Transform2D(-0.9999999, 1.5099579e-07, -1.5099579e-07, -0.9999999, 168, 0) +auto_calculate_length_and_angle = false +length = 200.0 +bone_angle = 0.0 +metadata/_local_pose_override_enabled_ = true + +[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/RightUpperArm/RightLowerArm" unique_id=1282597655] +rotation = -1.5707964 +remote_path = NodePath("../../../../../Body/RightLowerArm") + +[node name="RemoteTransform2D" type="RemoteTransform2D" parent="Skeleton2D/Torso/RightUpperArm" unique_id=975221467] +rotation = -1.5707964 +remote_path = NodePath("../../../../Body/RightUpperArm") + +[node name="IK_Targets" type="Node2D" parent="." unique_id=1089334064] + +[node name="Right_Hand" type="Marker2D" parent="IK_Targets" unique_id=1785254130] +position = Vector2(920, 152) + +[node name="Left_Hand" type="Marker2D" parent="IK_Targets" unique_id=1085197042] +position = Vector2(496, 144) diff --git a/project.godot b/project.godot index 8cc656f..d1f740d 100644 --- a/project.godot +++ b/project.godot @@ -8,9 +8,13 @@ config_version=5 +[animation] + +compatibility/default_parent_skeleton_in_mesh_instance_3d=true + [application] config/name="stickman" run/main_scene="uid://c5jkoyu1ik6fo" -config/features=PackedStringArray("4.4", "Forward Plus") +config/features=PackedStringArray("4.7", "Forward Plus") config/icon="res://icon.svg" diff --git a/scenes/stickman_editor.tscn b/scenes/stickman_editor.tscn index 99231e4..c0a649c 100644 --- a/scenes/stickman_editor.tscn +++ b/scenes/stickman_editor.tscn @@ -58,6 +58,7 @@ anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 offset_top = 68.0 +offset_bottom = -28.0 grow_horizontal = 2 grow_vertical = 2 theme_override_constants/separation = 6 @@ -149,3 +150,29 @@ max_value = 100.0 value = 15.0 step = 1.0 rounded = true + +[node name="StatusBar" type="HBoxContainer" parent="."] +unique_name_in_owner = true +layout_mode = 1 +anchor_left = 0.0 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_top = -28.0 +grow_horizontal = 2 +grow_vertical = 0 + +[node name="CursorCoords" type="Label" parent="StatusBar"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +text = "" +horizontal_alignment = 0 +vertical_alignment = 1 + +[node name="SnapStatus" type="Label" parent="StatusBar"] +unique_name_in_owner = true +layout_mode = 2 +text = "SNAP: OFF" +horizontal_alignment = 2 +vertical_alignment = 1 diff --git a/scripts/body_part_panel.gd b/scripts/body_part_panel.gd index 04dd617..41d74ab 100644 --- a/scripts/body_part_panel.gd +++ b/scripts/body_part_panel.gd @@ -19,6 +19,7 @@ signal shape_changed(shape_data: Array) signal shape_selected() signal shape_copy_requested(shape_dict: Dictionary) signal shape_paste_requested(world_pos: Vector2) +signal color_selected(color: Color) # --------------------------------------------------------------------------- # Exported / public properties @@ -269,6 +270,13 @@ func set_has_clipboard(state: bool) -> void: _has_clipboard = state +func set_recent_colors(colors_hex: Array) -> void: + for preset in color_picker.get_presets(): + color_picker.erase_preset(preset) + for hex_str in colors_hex: + color_picker.add_preset(Color.from_string(str(hex_str), Color.BLACK)) + + func paste_shape(source: Dictionary, target_pos: Vector2) -> void: ## Insert a deep copy of the clipboard shape at the given world position. ## The shape's points are translated so its center is at target_pos. @@ -293,6 +301,14 @@ func paste_shape(source: Dictionary, target_pos: Vector2) -> void: drawing_area.queue_redraw() _emit_changed() + +func is_cursor_over_drawing(global_pos: Vector2) -> bool: + return drawing_area.get_global_rect().has_point(global_pos) + + +func global_to_world(global_pos: Vector2) -> Vector2: + return _screen_to_world(global_pos - drawing_area.global_position) + # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- @@ -427,6 +443,22 @@ func _draw_polyline(pts: PackedVector2Array, color: Color, closed: bool, width: # --------------------------------------------------------------------------- func _on_drawing_area_gui_input(event: InputEvent) -> void: + if event is InputEventMagnifyGesture: + var mag := event as InputEventMagnifyGesture + var cursor_local := drawing_area.get_local_mouse_position() + var world := _screen_to_world(cursor_local) + _zoom = clampf(_zoom * mag.factor, MIN_ZOOM, MAX_ZOOM) + _pan_offset = cursor_local - world * _zoom + drawing_area.queue_redraw() + return + + if event is InputEventPanGesture: + var pan := event as InputEventPanGesture + _pan_offset -= pan.delta * 3.0 + _dragging_vertex = -1 + drawing_area.queue_redraw() + return + if event is InputEventMouseButton: var mb := event as InputEventMouseButton @@ -442,11 +474,17 @@ func _on_drawing_area_gui_input(event: InputEvent) -> void: # 2. Mouse wheel -> zoom if mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed: + var cursor_local := mb.position + var world := _screen_to_world(cursor_local) _zoom = clampf(_zoom * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) + _pan_offset = cursor_local - world * _zoom drawing_area.queue_redraw() return if mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed: + var cursor_local := mb.position + var world := _screen_to_world(cursor_local) _zoom = clampf(_zoom / ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) + _pan_offset = cursor_local - world * _zoom drawing_area.queue_redraw() return @@ -468,7 +506,7 @@ func _on_drawing_area_gui_input(event: InputEvent) -> void: var mm := event as InputEventMouseMotion if _is_panning: - _pan_offset += (mm.position - _pan_start) / _zoom + _pan_offset += (mm.position - _pan_start) _pan_start = mm.position drawing_area.queue_redraw() return @@ -878,6 +916,7 @@ func _on_color_picker_changed(new_color: Color) -> void: func _on_color_picker_ok() -> void: color_picker_popup.hide() + color_selected.emit(color_picker.color) _emit_changed() diff --git a/scripts/master_rig_builder.gd b/scripts/master_rig_builder.gd new file mode 100644 index 0000000..2cea778 --- /dev/null +++ b/scripts/master_rig_builder.gd @@ -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) diff --git a/scripts/master_rig_builder.gd.uid b/scripts/master_rig_builder.gd.uid new file mode 100644 index 0000000..0ad91e8 --- /dev/null +++ b/scripts/master_rig_builder.gd.uid @@ -0,0 +1 @@ +uid://6bcl1j1yvpv4 diff --git a/scripts/stickman_editor.gd b/scripts/stickman_editor.gd index 4224210..1629b62 100644 --- a/scripts/stickman_editor.gd +++ b/scripts/stickman_editor.gd @@ -73,6 +73,8 @@ const MAX_GRID_SIZE := 100 @onready var _error_dialog: AcceptDialog = %ErrorDialog @onready var _grid_config_dialog: ConfirmationDialog = %GridConfigDialog @onready var _grid_size_spin_box: SpinBox = %GridSizeSpinBox +@onready var _status_cursor_coords: Label = %CursorCoords +@onready var _status_snap_status: Label = %SnapStatus # --------------------------------------------------------------------------- # Grid / snap state @@ -84,6 +86,9 @@ var _snap_enabled: bool = false # Phase 5: shape clipboard var _shape_clipboard: Dictionary = {} +# Phase 6: recent colors +var _recent_colors: Array[String] = [] + var _edit_menu: PopupMenu # --------------------------------------------------------------------------- @@ -98,11 +103,31 @@ func _ready() -> void: _whole_preview.set_body_parts(_collect_all_shape_data()) _load_settings() + _update_snap_status_label() _broadcast_settings() + _broadcast_recent_colors() if not _grid_config_dialog.confirmed.is_connected(_on_grid_config_confirmed): _grid_config_dialog.confirmed.connect(_on_grid_config_confirmed) + +func _process(_delta: float) -> void: + var mouse_pos := get_global_mouse_position() + + for part_name: String in BODY_PART_NAMES: + var panel: BodyPartPanel = _body_part_panels.get(part_name) as BodyPartPanel + if panel and panel.is_cursor_over_drawing(mouse_pos): + var world_pos := panel.global_to_world(mouse_pos) + _status_cursor_coords.text = "X: %d Y: %d" % [int(world_pos.x), int(world_pos.y)] + return + + if _whole_preview.is_cursor_over_preview(mouse_pos): + var world_pos := _whole_preview.global_to_world(mouse_pos) + _status_cursor_coords.text = "X: %d Y: %d" % [int(world_pos.x), int(world_pos.y)] + return + + _status_cursor_coords.text = "" + # --------------------------------------------------------------------------- # Menu & Dialog setup # --------------------------------------------------------------------------- @@ -162,6 +187,7 @@ func _populate_body_part_panels() -> void: panel.shape_selected.connect(_on_body_part_shape_selected.bind(part_name)) panel.shape_copy_requested.connect(_on_shape_copy_requested.bind(part_name)) panel.shape_paste_requested.connect(_on_shape_paste_requested.bind(part_name)) + panel.color_selected.connect(_on_color_selected.bind(part_name)) _body_part_panels[part_name] = panel left_col.add_child(panel) @@ -173,6 +199,7 @@ func _populate_body_part_panels() -> void: panel.shape_selected.connect(_on_body_part_shape_selected.bind(part_name)) panel.shape_copy_requested.connect(_on_shape_copy_requested.bind(part_name)) panel.shape_paste_requested.connect(_on_shape_paste_requested.bind(part_name)) + panel.color_selected.connect(_on_color_selected.bind(part_name)) _body_part_panels[part_name] = panel center_col.add_child(panel) @@ -201,6 +228,7 @@ func _on_edit_menu_id_pressed(id: int) -> void: _edit_menu.set_item_text(1, _snap_menu_label()) _save_settings() _broadcast_settings() + _update_snap_status_label() func _on_edit_menu_about_to_popup() -> void: @@ -448,6 +476,12 @@ func _broadcast_clipboard_state() -> void: (panel as BodyPartPanel).set_has_clipboard(not _shape_clipboard.is_empty()) +func _broadcast_recent_colors() -> void: + for panel in _body_part_panels.values(): + if panel is BodyPartPanel: + (panel as BodyPartPanel).set_recent_colors(_recent_colors) + + func _paste_shape_into_panel(part_name: String, world_pos: Vector2) -> void: if _shape_clipboard.is_empty(): return @@ -459,6 +493,18 @@ func _paste_shape_into_panel(part_name: String, world_pos: Vector2) -> void: func _on_shape_paste_requested(world_pos: Vector2, part_name: String) -> void: _paste_shape_into_panel(part_name, world_pos) + +func _on_color_selected(color: Color, _part_name: String) -> void: + var hex_str := color.to_html() + var idx := _recent_colors.find(hex_str) + if idx >= 0: + _recent_colors.remove_at(idx) + _recent_colors.insert(0, hex_str) + while _recent_colors.size() > 8: + _recent_colors.pop_back() + _save_settings() + _broadcast_recent_colors() + # --------------------------------------------------------------------------- # Phase 3: Settings persistence # --------------------------------------------------------------------------- @@ -483,6 +529,15 @@ func _load_settings() -> void: _snap_enabled = bool(d.get("snap_to_grid", false)) _grid_size = clampi(_grid_size, MIN_GRID_SIZE, MAX_GRID_SIZE) + var raw_colors: Variant = d.get("recent_colors", []) + if raw_colors is Array: + var arr: Array = raw_colors as Array + for c in arr: + if c is String: + _recent_colors.append(c as String) + while _recent_colors.size() > 8: + _recent_colors.pop_back() + if _edit_menu: _edit_menu.set_item_text(1, _snap_menu_label()) @@ -492,6 +547,7 @@ func _save_settings() -> void: "version": SETTINGS_VERSION, "grid_size": _grid_size, "snap_to_grid": _snap_enabled, + "recent_colors": _recent_colors, } var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE) if file: @@ -511,3 +567,7 @@ func _broadcast_settings() -> void: func _snap_menu_label() -> String: return "[√] Snap to Grid" if _snap_enabled else "Snap to Grid" + + +func _update_snap_status_label() -> void: + _status_snap_status.text = "SNAP: ON" if _snap_enabled else "SNAP: OFF" diff --git a/scripts/whole_stickman_preview.gd b/scripts/whole_stickman_preview.gd index 83ed204..199a743 100644 --- a/scripts/whole_stickman_preview.gd +++ b/scripts/whole_stickman_preview.gd @@ -92,6 +92,9 @@ var _pan_start: Vector2 = Vector2.ZERO var _part_order: Array[String] = [] var _context_menu: PopupMenu +# Phase 6: selection gizmos rendered on top +var _selected_gizmo_bounds: Rect2 = Rect2() + # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- @@ -215,6 +218,14 @@ func reset_view() -> void: preview_area.queue_redraw() +func is_cursor_over_preview(global_pos: Vector2) -> bool: + return preview_area.get_global_rect().has_point(global_pos) + + +func global_to_world(global_pos: Vector2) -> Vector2: + return _screen_to_world(global_pos - preview_area.global_position) + + func get_part_order() -> Array: return _part_order.duplicate() @@ -249,6 +260,7 @@ func _on_preview_draw() -> void: preview_area.draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom)) _draw_grid() + _selected_gizmo_bounds = Rect2() for part_name: String in _part_order: if not _part_shapes.has(part_name): @@ -305,31 +317,11 @@ func _on_preview_draw() -> void: var label_pos := Vector2(bounds.position.x, bounds.position.y - 14) preview_area.draw_string(font, label_pos, label, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color.WHITE) - # Phase 4: Draw gizmos for selected part + # Store bounds for selected part (gizmos drawn on top later) if part_name == _selected_part: var bounds := _compute_transformed_bounds_from_points(all_raw, center, rot_deg, scl) if bounds.has_area(): - # White bounding box - preview_area.draw_rect(bounds, Color.WHITE, false, 1.5 / _zoom) - - # Rotation circle - var rot_center := Vector2(bounds.position.x + bounds.size.x * 0.5, bounds.end.y + ROTATION_CIRCLE_OFFSET) - preview_area.draw_circle(rot_center, ROTATION_CIRCLE_RADIUS / _zoom, Color.WHITE) - - # Scale crosses at 4 corners - var cross_half := SCALE_CROSS_SIZE / _zoom - for i: int in range(4): - var corner := _get_corner_position(bounds, i) - # Horizontal line - preview_area.draw_line( - corner + Vector2(-cross_half, 0), - corner + Vector2(cross_half, 0), - Color.WHITE, 1.5 / _zoom) - # Vertical line - preview_area.draw_line( - corner + Vector2(0, -cross_half), - corner + Vector2(0, cross_half), - Color.WHITE, 1.5 / _zoom) + _selected_gizmo_bounds = bounds # Highlight dragged part if not _dragging_part.is_empty() and _interaction == Interaction.TRANSLATE: @@ -337,6 +329,24 @@ func _on_preview_draw() -> void: if bounds is Rect2: preview_area.draw_rect(bounds as Rect2, Color(1.0, 0.8, 0.0, 0.35), false, 1.5) + # Draw selection gizmos on top of all parts + if _selected_gizmo_bounds.has_area(): + var b: Rect2 = _selected_gizmo_bounds + preview_area.draw_rect(b, Color.WHITE, false, 1.5 / _zoom) + var rot_center := Vector2(b.position.x + b.size.x * 0.5, b.end.y + ROTATION_CIRCLE_OFFSET) + preview_area.draw_circle(rot_center, ROTATION_CIRCLE_RADIUS / _zoom, Color.WHITE) + var cross_half := SCALE_CROSS_SIZE / _zoom + for i: int in range(4): + var corner := _get_corner_position(b, i) + preview_area.draw_line( + corner + Vector2(-cross_half, 0), + corner + Vector2(cross_half, 0), + Color.WHITE, 1.5 / _zoom) + preview_area.draw_line( + corner + Vector2(0, -cross_half), + corner + Vector2(0, cross_half), + Color.WHITE, 1.5 / _zoom) + func _draw_grid() -> void: var gs := float(_grid_size) @@ -455,6 +465,21 @@ func _compute_original_bounds_size(part_name: String) -> Vector2: # --------------------------------------------------------------------------- func _on_preview_gui_input(event: InputEvent) -> void: + if event is InputEventMagnifyGesture: + var mag := event as InputEventMagnifyGesture + var cursor_local := preview_area.get_local_mouse_position() + var world := _screen_to_world(cursor_local) + _zoom = clampf(_zoom * mag.factor, MIN_ZOOM, MAX_ZOOM) + _pan_offset = cursor_local - world * _zoom + preview_area.queue_redraw() + return + + if event is InputEventPanGesture: + var pan := event as InputEventPanGesture + _pan_offset -= pan.delta * 3.0 + preview_area.queue_redraw() + return + if event is InputEventMouseButton: var mb := event as InputEventMouseButton @@ -470,11 +495,17 @@ func _on_preview_gui_input(event: InputEvent) -> void: # Mouse wheel -> zoom if mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed: + var cursor_local := mb.position + var world := _screen_to_world(cursor_local) _zoom = clampf(_zoom * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) + _pan_offset = cursor_local - world * _zoom preview_area.queue_redraw() return if mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed: + var cursor_local := mb.position + var world := _screen_to_world(cursor_local) _zoom = clampf(_zoom / ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) + _pan_offset = cursor_local - world * _zoom preview_area.queue_redraw() return @@ -495,7 +526,7 @@ func _on_preview_gui_input(event: InputEvent) -> void: var mm := event as InputEventMouseMotion if _is_panning: - _pan_offset += (mm.position - _pan_start) / _zoom + _pan_offset += (mm.position - _pan_start) _pan_start = mm.position preview_area.queue_redraw() return