21 KiB
Phase 3 — Architectural Specification
Overview
Phase 3 adds a grid system (configurable, with snap-to-grid for vertices and whole-stickman assembly), panning (middle-mouse-button drag), global settings persistence (settings.json), color changing (ColorPicker integration), shape delete, and vertex delete to the Stickman Studio editor. Phases 1-2 (shape creation, vertex editing, zoom, save/load) are complete and functional; Phase 3 builds on them without breaking existing behavior.
1. Data Model Changes
1a. Color is already in the model
The shape data dictionary already includes "color": "#hex". The .stk format already serializes/deserializes color. Phase 3 adds a ColorPicker UI to change it interactively.
1b. Global Settings (user://settings.json)
New persistent settings file stored in Godot's user data directory:
{
"grid_size": 5,
"snap_to_grid": false,
"version": "1.0"
}
| Key | Type | Default | Description |
|---|---|---|---|
version |
string |
"1.0" |
Settings file version (for future extensibility) |
grid_size |
int |
5 |
Grid interval in pixels (applied equally to width and height) |
snap_to_grid |
bool |
false |
Whether snap-to-grid is active |
- Load: On editor startup (
_ready()), attempt to loaduser://settings.json. If the file doesn't exist or fails to parse, use defaults. - Save: Whenever the user changes grid size or toggles snap-to-grid, write the updated settings.
- Scope: Global — all panels share the same grid size and snap setting.
1c. Pan offset (transient, not persisted)
Both BodyPartPanel and WholeStickmanPreview gain a _pan_offset: Vector2 variable, default (0,0). This is not persisted in any file — it resets on load/clear and when the user clicks "Reset Views".
2. BodyPartPanel — Phase 3 Changes
2a. New State Variables
var _pan_offset: Vector2 = Vector2.ZERO # camera pan, modified by middle-mouse drag
var _grid_size: int = 5 # received from editor
var _snap_enabled: bool = false # received from editor
var _is_panning: bool = false # true while middle mouse is held
var _pan_start: Vector2 = Vector2.ZERO # screen position where pan started
var _restore_color: Color # saved color for Cancel support
2b. New Public Methods
func set_grid_size(size: int) -> void:
_grid_size = size
drawing_area.queue_redraw()
func set_snap_enabled(enabled: bool) -> void:
_snap_enabled = enabled
func reset_view() -> void:
_zoom = 1.0
_pan_offset = Vector2.ZERO
drawing_area.queue_redraw()
2c. Panning (Middle Mouse Button)
In _on_drawing_area_gui_input():
MOUSE_BUTTON_MIDDLEpressed: Set_is_panning = true, record_pan_start = mb.position.MOUSE_BUTTON_MIDDLEreleased:_is_panning = false.InputEventMouseMotionwhile_is_panning:_pan_offset += (mm.position - _pan_start) / _zoom. Then_pan_start = mm.position.queue_redraw(). Note: pan offset moves inversely to drag direction (drag right → camera moves right, content appears to move left). The offset is added to the transform, so positive offset shifts drawing right.
Z-order input priority:
- Middle mouse button (if press or panning in progress)
- Mouse wheel (zoom)
- Left mouse button (vertex drag / shape selection)
- Right mouse button (context menu)
2d. Grid Drawing
In _on_drawing_area_draw(), after draw_set_transform but before shape rendering, draw the grid:
func _draw_grid() -> void:
var gs := float(_grid_size)
if gs <= 0: return
var area_size := drawing_area.size
var world_origin := (-_pan_offset - Vector2(area_size) * 0.5) / _zoom
var world_size := area_size / _zoom
var world_end := world_origin + world_size
var start_x := floor(world_origin.x / gs) * gs
var start_y := floor(world_origin.y / gs) * gs
var grid_color := Color(1.0, 1.0, 1.0, 0.15)
var x := start_x
while x <= world_end.x:
drawing_area.draw_line(Vector2(x, world_origin.y), Vector2(x, world_end.y), grid_color, 1.0 / _zoom)
x += gs
var y := start_y
while y <= world_end.y:
drawing_area.draw_line(Vector2(world_origin.x, y), Vector2(world_end.x, y), grid_color, 1.0 / _zoom)
y += gs
The grid is drawn before the shape fill/outline so it appears behind the shapes.
Rendering order (updated):
- Grid lines (thin, low alpha)
- Shape fill (if closed)
- Shape outline
- Selection highlight (if selected)
- Vertex handles
Important: The grid must account for both zoom AND pan offset. The transform applied is:
drawing_area.draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom))
This replaces the current draw_set_transform(Vector2.ZERO, 0.0, Vector2(_zoom, _zoom)).
2e. Snap to Grid (Vertex Drag)
In the InputEventMouseMotion handler for vertex dragging, after computing the world position, apply snap:
var world_pos := _screen_to_world(mm.position)
if _snap_enabled:
world_pos = _snap_to_grid(world_pos)
shape_data.points[_dragging_vertex] = world_pos - _drag_offset
Snap helper:
func _snap_to_grid(pos: Vector2) -> Vector2:
var gs := float(_grid_size)
return Vector2(
round(pos.x / gs) * gs,
round(pos.y / gs) * gs
)
Snap is applied before the drag offset is subtracted, so the user sees the vertex jump to the nearest grid intersection while dragging.
2f. Context Menu — Color Entry
The right-click context menu for a selected shape is updated:
[existing: "Create Point"] (id 3)
[separator]
"Color..." (id 4)
[separator]
"Delete" (id 5)
"Color..." behavior:
- Save the current color:
_restore_color = Color.from_string(shape_data.color, Color.BLACK). - Create or show a
ColorPickerdialog. - When the user changes the color in the picker (preview): update
shape_data.colorto the hex string andqueue_redraw(). This gives live preview. - On OK: commit the color change, emit
shape_changed. - On Cancel: restore
shape_data.color = _restore_color.to_html(),queue_redraw().
Implementation via ColorPickerButton:
- Add a
ColorPickerButtonnode to theBodyPartPanelscene (hidden by default,visible = false). - When "Color..." is clicked: set the picker's color to the current shape color, set
visible = true, and programmatically triggerpopup(). - Connect the
color_changedsignal: updateshape_data.color→queue_redraw()(live preview). - Connect the
popup_closedsignal: this is where we distinguish OK vs Cancel. If the user clicked the "OK" or selected a swatch, the color was already committed throughcolor_changed. If they cancelled, restore_restore_color.
Actually, ColorPickerButton in Godot 4.4 has a color_changed signal for preview but no built-in way to detect Cancel. A cleaner approach:
Implementation via custom dialog:
- Add a
ColorPickernode inside aPopuporAcceptDialogwith OK/Cancel buttons in theBodyPartPanelscene. - On "Color...": set the picker color, show the popup.
- On Cancel: restore previous color.
- On OK: commit.
Since ColorPickerButton doesn't provide a clean Cancel detection, use a simple AcceptDialog-equivalent with a ColorPicker. Actually, the cleanest approach in Godot 4:
Use the built-in ColorPicker node (not ColorPickerButton) in a new scene or directly in body_part_panel.tscn. When "Color..." is clicked, we can also just use PopupPanel with a ColorPicker inside, or use AcceptDialog with a custom child.
Simplest reliable approach: Use ColorPicker node, detect color changes for preview, and use the color_picker's own Popup behavior. Actually, let me reconsider. The ColorPicker extends VBoxContainer - it needs to be in a popup.
Decision: Add a PopupPanel → VBoxContainer → ColorPicker → HBoxContainer (OK, Cancel buttons) to body_part_panel.tscn. On "Color...": set the picker's color, show popup. On color_changed: preview. On OK: commit, hide popup, emit shape_changed. On Cancel: restore, hide popup.
2g. Context Menu — Delete Entry
"Delete" behavior (id 5):
- Call
clear_shape()(which resets shape_data but does NOT emit shape_changed). - Emit
shape_changedmanually so the Whole Stickman preview updates.
2h. Context Menu Refactor — Right-Click Logic
The current _on_drawing_area_gui_input() right-click logic is:
if _selected AND near outline → show "Create Point"
else → show "Line", "Rectangle", "Circle"
Phase 3 changes this to:
1. Check if right-click is on a VERTEX (hit-test vertex handles).
If YES:
→ show "Remove Point" context menu (id 6)
return
2. If a shape EXISTS (not empty) AND (_selected OR mouse is over the shape):
→ show context menu with [Create Point / Color... / Delete] entries
return
3. Else (no shape or shape not selected):
→ show [Line / Rectangle / Circle] shape creation menu
New context menu item IDs:
| ID | Label | Context |
|---|---|---|
| 0 | Line | No shape / shape not selected |
| 1 | Rectangle | No shape / shape not selected |
| 2 | Circle | No shape / shape not selected |
| 3 | Create Point | Shape selected, near outline |
| 4 | Color... | Shape selected |
| 5 | Delete | Shape selected |
| 6 | Remove Point | Right-click on vertex handle |
Rule for "Delete" visibility: Show Delete only when a shape exists AND (_selected == true OR mouse is over the shape). This means:
- If the shape is selected but the mouse is not over it, Delete still shows.
- If the shape is not selected but the mouse is over it, Delete still shows.
2i. Vertex Delete (Remove Point)
When the user right-clicks on a vertex handle:
- Show context menu with only "Remove Point" (id 6).
- Store the vertex index as metadata on the context menu.
- On "Remove Point" selected:
a. Remove the point at the stored index from
shape_data.points. b. Remove the corresponding entry fromshape_data.vertex_flags. c. Ifshape_data.points.size() == 2:- Set
shape_data.closed = false(becomes a line). d. Ifshape_data.points.size() < 2: - Call
clear_shape()(removes the shape entirely). e.queue_redraw()and emitshape_changed.
- Set
2j. Updated _on_context_menu_id_pressed()
New match cases:
4: # Color...
_show_color_picker()
5: # Delete
_delete_shape()
6: # Remove Point
_remove_vertex(context_menu.get_meta("vertex_index", -1))
2k. Updated clear_shape()
clear_shape() MUST also reset pan and zoom since we're starting fresh:
_pan_offset = Vector2.ZERO
_zoom = 1.0
2l. Updated set_shape_data()
Reset pan offset on data load:
_pan_offset = Vector2.ZERO
3. WholeStickmanPreview — Phase 3 Changes
3a. New State Variables
var _pan_offset: Vector2 = Vector2.ZERO
var _grid_size: int = 5
var _snap_enabled: bool = false
var _is_panning: bool = false
var _pan_start: Vector2 = Vector2.ZERO
3b. New Public Methods
func set_grid_size(size: int) -> void:
_grid_size = size
preview_area.queue_redraw()
func set_snap_enabled(enabled: bool) -> void:
_snap_enabled = enabled
func reset_view() -> void:
_zoom = 1.0
_pan_offset = Vector2.ZERO
preview_area.queue_redraw()
3c. Panning
Same pattern as BodyPartPanel: middle-mouse press/release tracks _is_panning, mouse motion updates _pan_offset inversely.
3d. Grid Drawing
Same grid drawing code as BodyPartPanel, adapted for preview_area. Uses the same _grid_size. The grid is drawn behind the parts.
The draw transform is updated from:
preview_area.draw_set_transform(Vector2.ZERO, 0.0, Vector2(_zoom, _zoom))
to:
preview_area.draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom))
3e. Snap to Grid (Part Drag)
In the drag motion handler, after computing the mouse world position, snap it:
var world_pos: Vector2 = mm.position / _zoom
if _snap_enabled:
var gs := float(_grid_size)
world_pos.x = round(world_pos.x / gs) * gs
world_pos.y = round(world_pos.y / gs) * gs
var new_pos := world_pos - _drag_offset
_part_positions[_dragging_part] = new_pos
This uses the mouse pointer position as the snap reference, as specified: the mouse position snaps to grid, and the part follows.
3f. Updated clear_all()
Reset pan offset on clear:
_pan_offset = Vector2.ZERO
_zoom = 1.0
3g. Updated set_body_parts()
Reset pan offset on body part set (full refresh from editor):
_pan_offset = Vector2.ZERO
4. StickmanEditor — Phase 3 Changes
4a. New Menu Bar Structure
┌──────────────────────────────────────────┐
│ File │ Edit │ View │ │
│──────────────────────────────────────────│
│ Save │ Configure Grid... │ Reset Views │
│ Load │ Snap to Grid │ │
│ ───── │ │ │
│ Clear │ │ │
└──────────────────────────────────────────┘
4b. Menu Setup in _setup_menu_bar()
After the existing "File" menu, add:
Edit menu (PopupMenu):
- "Configure Grid..." (id 0) — opens a dialog.
- Separator.
- "Snap to Grid" (id 1) — checkable item; checked = snap enabled.
View menu (PopupMenu):
- "Reset Views" (id 0) — resets zoom + pan on all panels.
4c. New State Variables
var _grid_size: int = 5
var _snap_enabled: bool = false
4d. New On-Ready Nodes
Add to the scene or create programmatically:
%GridConfigDialog— aConfirmationDialogorAcceptDialogwith a SpinBox for grid size.%ColorPickerPopup— kept in body_part_panel.tscn, not here.
Actually, the grid config dialog should be in the editor scene since it's a top-level dialog. Add it to stickman_editor.tscn. The ColorPicker stays in body_part_panel.tscn since it's per-panel.
4e. Grid Config Dialog
Option A: Add a ConfirmationDialog with a SpinBox child to stickman_editor.tscn.
- Title: "Configure Grid"
- Label: "Grid Size (pixels):"
- SpinBox: min 1, max 100, step 1, value = current
_grid_size. - On confirmed: read SpinBox value, set
_grid_size, broadcast to all panels + WholeStickmanPreview, writesettings.json.
4f. Settings Load/Save
func _load_settings() -> void:
if not FileAccess.file_exists("user://settings.json"):
return # use defaults
var file := FileAccess.open("user://settings.json", FileAccess.READ)
if file == null:
return
var json_text := file.get_as_text()
file.close()
var json: Variant = JSON.parse_string(json_text)
if json is Dictionary:
var d := json as Dictionary
_grid_size = int(d.get("grid_size", 5))
_snap_enabled = bool(d.get("snap_to_grid", false))
# Clamp to valid range
_grid_size = clampi(_grid_size, 1, 100)
func _save_settings() -> void:
var data := {
"version": "1.0",
"grid_size": _grid_size,
"snap_to_grid": _snap_enabled,
}
var file := FileAccess.open("user://settings.json", FileAccess.WRITE)
if file:
file.store_string(JSON.stringify(data, "\t", false))
file.close()
Called in _ready(): load settings, then broadcast to panels.
4g. Broadcasting Settings to Panels
New helper:
func _broadcast_settings() -> void:
for panel in _body_part_panels.values():
panel.set_grid_size(_grid_size)
panel.set_snap_enabled(_snap_enabled)
_whole_preview.set_grid_size(_grid_size)
_whole_preview.set_snap_enabled(_snap_enabled)
Called after loading settings in _ready() and whenever grid/snap changes.
4h. Save Flow Update
Color is already included in _build_json_data() via panel.get_shape_data() which returns color. No .stk format changes needed for Phase 3.
4i. Reset Views Handler
func _on_view_menu_id_pressed(id: int) -> void:
if id == 0: # Reset Views
for panel in _body_part_panels.values():
panel.reset_view()
_whole_preview.reset_view()
5. Scene File Changes
5a. scenes/stickman_editor.tscn
Add new nodes under the root StickmanEditor:
[node name="GridConfigDialog" type="ConfirmationDialog" parent="."]
unique_name_in_owner = true
title = "Configure Grid"
ok_button_text = "OK"
[node name="VBoxContainer" type="VBoxContainer" parent="GridConfigDialog"]
layout_mode = 2
[node name="GridSizeLabel" type="Label" parent="GridConfigDialog/VBoxContainer"]
layout_mode = 2
text = "Grid Size (pixels):"
[node name="GridSizeSpinBox" type="SpinBox" parent="GridConfigDialog/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
min_value = 1.0
max_value = 100.0
value = 5.0
step = 1.0
rounded = true
5b. scenes/body_part_panel.tscn
Add a ColorPicker popup:
[node name="ColorPickerPopup" type="PopupPanel" parent="."]
unique_name_in_owner = true
visible = false
[node name="VBoxContainer" type="VBoxContainer" parent="ColorPickerPopup"]
layout_mode = 2
[node name="ColorPicker" type="ColorPicker" parent="ColorPickerPopup/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(300, 300)
[node name="ButtonRow" type="HBoxContainer" parent="ColorPickerPopup/VBoxContainer"]
layout_mode = 2
alignment = 2 # END
[node name="CancelButton" type="Button" parent="ColorPickerPopup/VBoxContainer/ButtonRow"]
layout_mode = 2
text = "Cancel"
[node name="OKButton" type="Button" parent="ColorPickerPopup/VBoxContainer/ButtonRow"]
layout_mode = 2
text = "OK"
6. Files Modified
| File | Changes |
|---|---|
scripts/body_part_panel.gd |
Major: panning (middle mouse), grid drawing, snap-to-grid in vertex drag, ColorPicker integration, "Delete" shape, "Remove Point" vertex delete, context menu refactor, reset_view(), set_grid_size(), set_snap_enabled() |
scripts/stickman_editor.gd |
Modular: Edit/View menus, GridConfigDialog, settings.json load/save, broadcast grid/snap settings, Reset Views handler |
scripts/whole_stickman_preview.gd |
Major: panning, grid drawing, snap-to-grid in part drag, reset_view(), set_grid_size(), set_snap_enabled() |
scenes/stickman_editor.tscn |
Add GridConfigDialog + children |
scenes/body_part_panel.tscn |
Add ColorPickerPopup + children |
docs/phase3_spec.md |
This file |
7. Implementation Order
- Menus + Settings — Add Edit/View menus, settings.json load/save, broadcast infrastructure
- GridConfigDialog — Dialog + SpinBox in editor scene, wire up to settings
- Panning — Middle-mouse drag in BodyPartPanel + WholeStickmanPreview
- Grid drawing — Background grid in both panels, accounting for zoom + pan offset
- Snap to Grid — Vertex drag snap + Whole Stickman part drag snap
- Reset Views — View menu item → reset zoom + pan on all panels
- ColorPicker — Add ColorPickerPopup, context menu "Color..." entry, preview + commit/cancel
- Shape Delete — Context menu "Delete" entry, conditional visibility
- Vertex Delete — Right-click on vertex → "Remove Point", edge cases (2→line, <2→clear)
- Scene files — Update both .tscn files with new nodes
- README update — Document Phase 3 features
8. Edge Cases & Constraints
- Grid size = 1: Grid lines become very dense. Still functional; performance acceptable for ~50-100 lines per panel at typical panel sizes (~230x160px @ 1px grid = massive density, but max panel size is ~500x300 visible → 500 lines). If performance issues arise, skip drawing when
_grid_size < 3at high zoom. - Pan + zoom interaction: Pan offset is in screen space, applied via
draw_set_transform. The offset is NOT divided by zoom — it's added before the scale, so pan feels natural at any zoom level. At 2x zoom, dragging 100px in screen space moves the view by 100px world units. - Middle mouse + any other button: If the user presses middle mouse while dragging a vertex, the vertex drag is cancelled and panning takes over. This prevents conflicts.
- ColorPicker Cancel: If the user opens the color picker and clicks Cancel, the shape color MUST revert to the pre-dialog value. The preview during color picker interaction updates
shape_data.colordirectly; on cancel we restore from_restore_color. - Delete last remaining vertex: If Remove Point is called on a shape with 2 vertices, it becomes an open line (
closed=false). If called on a shape with 1 vertex, the shape is cleared entirely. - Delete shape while selected: When shape is deleted, clear
_selected, clear_dragging_vertex, emitshape_changed. - Settings file missing: Gracefully use defaults (grid_size=5, snap_to_grid=false). No error dialog.
- Settings file corrupted: Gracefully use defaults. No error dialog.
- Snap to Grid toggle: When toggling snap ON, vertices don't immediately snap — they only snap when dragged. This matches user expectation (existing vertex positions aren't retroactively modified).
- Context menu positioning: All context menus use
popup_on_parent(Rect2(mb.global_position, Vector2.ONE))for proper screen-space positioning.