Implement Phase 6 features for Stickman Editor

- Updated BUGS.md with new issues related to selection bounding box, touchpad panning speed, and zooming behavior.
- Enhanced PROJECT.md with detailed specifications for touchpad controls, recent colors in the color picker, and a status bar for project information.
- Modified project.godot to update compatibility version to 4.7.
- Added a status bar in stickman_editor.tscn to display cursor coordinates and snap status.
- Updated body_part_panel.gd to handle recent colors and cursor detection over drawing areas.
- Enhanced stickman_editor.gd to manage cursor coordinates display and recent colors tracking.
- Improved whole_stickman_preview.gd to render selection gizmos on top of other parts.
- Created master_rig.tscn for the stickman rig structure with bones and IK targets.
- Developed master_rig_builder.gd to automate the rig building process with remote transforms and IK modifications.
This commit is contained in:
2026-08-14 21:49:06 -04:00
parent b97bc145e4
commit 28e8798021
11 changed files with 647 additions and 42 deletions
+39 -17
View File
@@ -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
+11
View File
@@ -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.
+21
View File
@@ -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
+182
View File
@@ -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)
+5 -1
View File
@@ -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"
+27
View File
@@ -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
+40 -1
View File
@@ -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()
+207
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://6bcl1j1yvpv4
+60
View File
@@ -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"
+54 -23
View File
@@ -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