Files
stickman/docs/phase3_spec.md
T
2026-08-08 00:00:50 -04:00

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 load user://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():

  1. MOUSE_BUTTON_MIDDLE pressed: Set _is_panning = true, record _pan_start = mb.position.
  2. MOUSE_BUTTON_MIDDLE released: _is_panning = false.
  3. InputEventMouseMotion while _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:

  1. Middle mouse button (if press or panning in progress)
  2. Mouse wheel (zoom)
  3. Left mouse button (vertex drag / shape selection)
  4. 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):

  1. Grid lines (thin, low alpha)
  2. Shape fill (if closed)
  3. Shape outline
  4. Selection highlight (if selected)
  5. 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:

  1. Save the current color: _restore_color = Color.from_string(shape_data.color, Color.BLACK).
  2. Create or show a ColorPicker dialog.
  3. When the user changes the color in the picker (preview): update shape_data.color to the hex string and queue_redraw(). This gives live preview.
  4. On OK: commit the color change, emit shape_changed.
  5. On Cancel: restore shape_data.color = _restore_color.to_html(), queue_redraw().

Implementation via ColorPickerButton:

  • Add a ColorPickerButton node to the BodyPartPanel scene (hidden by default, visible = false).
  • When "Color..." is clicked: set the picker's color to the current shape color, set visible = true, and programmatically trigger popup().
  • Connect the color_changed signal: update shape_data.colorqueue_redraw() (live preview).
  • Connect the popup_closed signal: this is where we distinguish OK vs Cancel. If the user clicked the "OK" or selected a swatch, the color was already committed through color_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 ColorPicker node inside a Popup or AcceptDialog with OK/Cancel buttons in the BodyPartPanel scene.
  • 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):

  1. Call clear_shape() (which resets shape_data but does NOT emit shape_changed).
  2. Emit shape_changed manually 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:

  1. Show context menu with only "Remove Point" (id 6).
  2. Store the vertex index as metadata on the context menu.
  3. On "Remove Point" selected: a. Remove the point at the stored index from shape_data.points. b. Remove the corresponding entry from shape_data.vertex_flags. c. If shape_data.points.size() == 2:
    • Set shape_data.closed = false (becomes a line). d. If shape_data.points.size() < 2:
    • Call clear_shape() (removes the shape entirely). e. queue_redraw() and emit shape_changed.

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 — a ConfirmationDialog or AcceptDialog with 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, write settings.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

  1. Menus + Settings — Add Edit/View menus, settings.json load/save, broadcast infrastructure
  2. GridConfigDialog — Dialog + SpinBox in editor scene, wire up to settings
  3. Panning — Middle-mouse drag in BodyPartPanel + WholeStickmanPreview
  4. Grid drawing — Background grid in both panels, accounting for zoom + pan offset
  5. Snap to Grid — Vertex drag snap + Whole Stickman part drag snap
  6. Reset Views — View menu item → reset zoom + pan on all panels
  7. ColorPicker — Add ColorPickerPopup, context menu "Color..." entry, preview + commit/cancel
  8. Shape Delete — Context menu "Delete" entry, conditional visibility
  9. Vertex Delete — Right-click on vertex → "Remove Point", edge cases (2→line, <2→clear)
  10. Scene files — Update both .tscn files with new nodes
  11. 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 < 3 at 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.color directly; 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, emit shape_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.