first commit
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
# Phase 2 — Architectural Specification
|
||||
|
||||
## Overview
|
||||
|
||||
Phase 2 adds **interactive vertex editing** (custom shapes), **per-panel zoom**, and an **expanded .stk save format** to the Stickman Studio editor. Phase 1 (shape creation, save/load, whole-stickman assembly) is complete and functional; Phase 2 builds on it without breaking existing behavior.
|
||||
|
||||
---
|
||||
|
||||
## 1. Data Model Changes
|
||||
|
||||
### 1a. Shape Data Dictionary (updated)
|
||||
|
||||
```gdscript
|
||||
# body_part_panel.gd — internal shape_data extends to:
|
||||
{
|
||||
"shape_type": String, # "line", "rectangle", "circle", "" (unchanged)
|
||||
"points": PackedVector2Array, # vertex positions (unchanged)
|
||||
"color": String, # hex color (unchanged)
|
||||
"closed": bool, # NEW — true for polygon fill, false for open line
|
||||
"vertex_flags": PackedInt32Array # NEW — 0=original, 1=user_created; same length as points
|
||||
}
|
||||
```
|
||||
|
||||
- `closed` replaces the implicit closed-ness that was derived from `shape_type`. A line has `closed=false`; a rectangle and circle have `closed=true`. The rendering logic switches on `closed` instead of `shape_type`.
|
||||
- `vertex_flags` tracks which vertices are "original" (filled circle handle) vs "user-created via Create Point" (hollow rectangle handle). Default all-zero for starter shapes.
|
||||
- `shape_type` is retained as a descriptive tag but no longer drives rendering behavior.
|
||||
|
||||
### 1b. .stk File Format (version "1.1")
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.1",
|
||||
"stickman_name": "Bob",
|
||||
"body_parts": {
|
||||
"head": {
|
||||
"shape_type": "circle",
|
||||
"closed": true,
|
||||
"points": [{"x": 120, "y": 40}, ...],
|
||||
"color": "#000000",
|
||||
"position": {"x": 150, "y": 40},
|
||||
"vertex_flags": [0, 0, 0, 1, 0, ...]
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"created_at": "...",
|
||||
"modified_at": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Version bumped to `"1.1"`.** Loading: if version is `"1.0"`, auto-migrate (set `closed` based on `shape_type`, set `vertex_flags` as all-zeros). Save always writes `"1.1"`.
|
||||
- New per-shape keys: `"closed"` (bool) and `"vertex_flags"` (array of ints, length matching `points`).
|
||||
- Backward compatibility: loading a 1.0 file infers `closed` from `shape_type` (`"line"`→false, `"rectangle"`/`"circle"`→true, `""`→false). vertex_flags defaults to all zeros for any shape loaded from 1.0.
|
||||
|
||||
### 1c. Circle Refactor
|
||||
|
||||
- `_create_circle()` generates **12 vertices** (down from 32). The shape_type remains `"circle"`, `closed=true`, `vertex_flags` all zeros.
|
||||
- Existing 32-vertex circles in saved `.stk` files still load correctly — `set_shape_data()` accepts arbitrary point arrays.
|
||||
|
||||
---
|
||||
|
||||
## 2. BodyPartPanel — Vertex Editing
|
||||
|
||||
### 2a. Selection State
|
||||
|
||||
New internal state variables in `body_part_panel.gd`:
|
||||
|
||||
```gdscript
|
||||
var _selected: bool = false # is this panel's shape currently selected?
|
||||
var _dragging_vertex: int = -1 # index of vertex being dragged, -1 if none
|
||||
var _drag_offset: Vector2 # offset from vertex to mouse during drag
|
||||
var _zoom: float = 1.0 # zoom factor clamped to [0.3, 3.0]
|
||||
```
|
||||
|
||||
### 2b. Interaction Model
|
||||
|
||||
| Action | Trigger | Behavior |
|
||||
|---|---|---|
|
||||
| **Select shape** | Left-click inside shape bounds | Sets `_selected=true`, emits `shape_selected` signal. Deselects all other panels (coordinated by `stickman_editor.gd`). |
|
||||
| **Deselect** | Left-click outside shape OR clicking another panel | `_selected=false`. Any in-progress drag is cancelled. |
|
||||
| **Drag vertex** | Left-click press on a vertex handle, then drag | Sets `_dragging_vertex` to that index. On mouse motion, updates vertex position. On release, clears drag state. Emits `shape_changed`. |
|
||||
| **Open context menu** | Right-click on the outline path of a **selected** shape | Shows a `PopupMenu` with one item: "Create Point". The click position is stored for vertex insertion. |
|
||||
| **Create initial shape** | Right-click on an **empty** drawing area (no shape OR shape not selected) | Shows the existing 3-item context menu (Line, Rectangle, Circle). *Unchanged from Phase 1.* |
|
||||
|
||||
### 2c. Hit Testing
|
||||
|
||||
- **Shape selection hit-test**: Point-in-polygon check against the full shape polygon. For open shapes (lines), check if the click is within a threshold distance (12px) of any edge.
|
||||
- **Vertex hit-test**: For each vertex, check if the mouse position is within `HANDLE_RADIUS_SELECTION` (8px) of the vertex position (in screen space, accounting for zoom).
|
||||
- **Edge hit-test for Create Point**: Find the nearest edge to the right-click position (in screen space). The new vertex is inserted at the midpoint of that edge.
|
||||
|
||||
### 2d. Rendering Changes (in `_on_drawing_area_draw()`)
|
||||
|
||||
Rendering order (back to front):
|
||||
|
||||
1. **Fill** (if `closed`): `draw_colored_polygon(pts, color)` — same as Phase 1.
|
||||
2. **Outline**: `_draw_polyline(pts, color, ...)` — same as Phase 1.
|
||||
3. **Selection highlight** (if `_selected`): Redraw the polyline in white (`Color.WHITE`), 2px wide, on top of the color outline. This makes the selected shape stand out.
|
||||
4. **Vertex handles**: For each vertex:
|
||||
- If `vertex_flags[i] == 0` (original): filled circle, radius `HANDLE_RADIUS / zoom` (so it stays constant screen size).
|
||||
- If `vertex_flags[i] == 1` (user-created): hollow rectangle, size `(6/zoom)×(6/zoom)`, centered on the vertex.
|
||||
- If `i == _dragging_vertex`: draw in yellow highlight.
|
||||
|
||||
### 2e. Context Menu Refactor
|
||||
|
||||
The existing `ContextMenu` (ids 0, 1, 2 — Line, Rectangle, Circle) is repurposed for initial shape creation only.
|
||||
|
||||
A **second** `PopupMenu` is needed for vertex editing. Two options:
|
||||
- **Option A (chosen):** Single PopupMenu, dynamically repopulated based on context. When right-clicking on empty area → show "Line", "Rectangle", "Circle". When right-clicking on outline of selected shape → show "Create Point" only. This requires clearing/rebuilding the menu items programmatically.
|
||||
- **Option B:** Two separate PopupMenu nodes.
|
||||
|
||||
**Decision: Option A** — less scene overhead, single menu node reused.
|
||||
|
||||
Menu id: 0=Line, 1=Rectangle, 2=Circle, 3=Create Point (only shown in vertex context).
|
||||
|
||||
### 2f. Vertex Insertion (Create Point)
|
||||
|
||||
1. Right-click on the outline of a selected shape.
|
||||
2. Find the nearest edge (line segment) to the click position.
|
||||
3. Insert a new vertex at the **midpoint** of that edge (not at the click position — this matches the user story where the vertex "appear[s] in the outline where the user clicked" at the nearest edge midpoint, not an arbitrary point).
|
||||
4. Append `1` to `vertex_flags` at the corresponding position.
|
||||
5. `queue_redraw()` and `emit shape_changed`.
|
||||
|
||||
*Note: PROJECT.md says "a small hollow rectangle will appear in the outline where the user clicked — this is not a point(vertex)". I interpret this to mean: the vertex is placed on the outline at the nearest edge midpoint. The "not a point" language means it's a vertex, just drawn differently (hollow rectangle).*
|
||||
|
||||
### 2g. Vertex Dragging
|
||||
|
||||
1. Left-click press on a vertex handle (within `HANDLE_RADIUS_SELECTION`).
|
||||
2. Set `_dragging_vertex = index`, `_drag_offset = position - vertex_position`.
|
||||
3. On mouse motion (`InputEventMouseMotion`):
|
||||
- Compute new world-space position: `new_pos = mouse_position / zoom` (undo zoom) or keep everything in world space. **Design decision:** Store points in world space (unscaled). Apply `draw_set_transform(Vector2.ZERO, 0, Vector2(zoom, zoom))` in `_draw()`. Mouse positions are divided by zoom to get world coordinates.
|
||||
- Update `shape_data.points[_dragging_vertex] = world_pos`.
|
||||
- `queue_redraw()`.
|
||||
4. On mouse release: clear `_dragging_vertex`, emit `shape_changed`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Window Zoom
|
||||
|
||||
### 3a. BodyPartPanel Zoom
|
||||
|
||||
- Add `_zoom: float = 1.0` (range `[0.3, 3.0]`).
|
||||
- Mouse wheel handling in `_on_drawing_area_gui_input()`:
|
||||
- `MOUSE_BUTTON_WHEEL_UP`: `_zoom = clamp(_zoom * 1.10, 0.3, 3.0)`
|
||||
- `MOUSE_BUTTON_WHEEL_DOWN`: `_zoom = clamp(_zoom / 1.10, 0.3, 3.0)`
|
||||
- In `_on_drawing_area_draw()`, apply: `drawing_area.draw_set_transform(Vector2.ZERO, 0.0, Vector2(_zoom, _zoom))` before all draw calls.
|
||||
- Mouse-to-world conversion: All input event positions must be divided by `_zoom` before using as world coordinates (hit-testing, vertex creation, dragging).
|
||||
- Vertex handles drawn with sizes divided by `_zoom` (e.g., `HANDLE_RADIUS / _zoom`), so they appear the same screen size regardless of zoom level.
|
||||
|
||||
### 3b. WholeStickmanPreview Zoom
|
||||
|
||||
- Add `_zoom: float = 1.0` (same range).
|
||||
- Same wheel handling, same `draw_set_transform` application.
|
||||
- No vertex handles drawn here, so the handle-size concern doesn't apply.
|
||||
- Drag hit-testing (`_try_start_drag`): Divide `at_position` by `_zoom` to convert to world coordinates. The drag offset and part positions remain in world space.
|
||||
|
||||
---
|
||||
|
||||
## 4. StickmanEditor — Coordination Changes
|
||||
|
||||
### 4a. Selection Coordination
|
||||
|
||||
When a `BodyPartPanel` is selected, all other panels must be deselected. Add:
|
||||
|
||||
```gdscript
|
||||
signal shape_selected(part_name: String)
|
||||
|
||||
# In _on_body_part_shape_selected(part_name):
|
||||
# for each panel in _body_part_panels where key != part_name:
|
||||
# panel.deselect()
|
||||
```
|
||||
|
||||
Add a `deselect()` method to `BodyPartPanel` that sets `_selected=false` and `queue_redraw()`.
|
||||
|
||||
### 4b. Save Format Updates
|
||||
|
||||
- `FILE_VERSION` → `"1.1"`.
|
||||
- `_build_json_data()` now includes `"closed"` and `"vertex_flags"` in each shape.
|
||||
- `_collect_all_shape_data()` updated accordingly — `BodyPartPanel.get_shape_data()` returns the updated dictionary with `closed` and `vertex_flags`.
|
||||
- `_apply_json_data()` handles both `"1.0"` and `"1.1"`:
|
||||
- 1.0: auto-migrate (infer `closed` from `shape_type`, set `vertex_flags` to all-zeros).
|
||||
- 1.1: read `closed` and `vertex_flags` directly.
|
||||
|
||||
### 4c. New Signal Wiring
|
||||
|
||||
- `BodyPartPanel.shape_changed` — existing, now also emitted on vertex drag end and Create Point.
|
||||
- `BodyPartPanel.shape_selected` — new, for cross-panel deselection coordination.
|
||||
- Each panel's `shape_selected` connects to `stickman_editor._on_body_part_shape_selected`.
|
||||
|
||||
---
|
||||
|
||||
## 5. WholeStickmanPreview — Changes
|
||||
|
||||
### 5a. Zoom Support
|
||||
|
||||
- Add `_zoom` with same wheel handling and range.
|
||||
- Apply `draw_set_transform` in `_on_preview_draw()`.
|
||||
- Convert mouse positions in `_try_start_drag()` and `_on_preview_gui_input()`.
|
||||
|
||||
### 5b. Closed Shape Rendering
|
||||
|
||||
- The preview currently switches on `shape_type` to decide fill+close. Update to check `closed: bool` from the shape data instead.
|
||||
- Add `_draw_polyline_preview()` helper for clean line drawing (consistent with BodyPartPanel).
|
||||
|
||||
---
|
||||
|
||||
## 6. Files Modified
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| `scripts/body_part_panel.gd` | Major: vertex editing state, selection, zoom, context menu refactor, rendering with closed flag & vertex_flags, circle→12 vertices, mouse handling expanded |
|
||||
| `scripts/stickman_editor.gd` | Version bump to 1.1, save format includes closed/vertex_flags, load handles 1.0+1.1, selection coordination signal |
|
||||
| `scripts/whole_stickman_preview.gd` | Zoom support, closed-based rendering instead of shape_type-based, input coordinate transform |
|
||||
| `scenes/body_part_panel.tscn` | Minimal — ContextMenu items may be reduced or left as-is (dynamic repopulation in script). *Consider adding a 4th static item "Create Point" hidden by default.* |
|
||||
| `README.md` | Document Phase 2 features, updated .stk format, vertex editing, zoom |
|
||||
| `docs/phase2_spec.md` | This file |
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation Order (Recommended)
|
||||
|
||||
1. **Data model + circle refactor** (shape_data adds `closed` + `vertex_flags`, circle→12 vertices, rendering uses `closed`)
|
||||
2. **Zoom** (mouse wheel, draw_set_transform, coordinate conversion, handle-size scaling)
|
||||
3. **Shape selection** (left-click hit-test, white outline, cross-panel deselection)
|
||||
4. **Vertex hit-testing & dragging** (left-click on handles, drag motion, release)
|
||||
5. **Create Point** (right-click context menu on selected outline, edge finding, vertex insertion)
|
||||
6. **Save/load update** (version 1.1, closed/vertex_flags in JSON, backward compatible loading)
|
||||
7. **WholeStickmanPreview zoom + closed rendering**
|
||||
8. **README update**
|
||||
|
||||
---
|
||||
|
||||
## 8. Edge Cases & Constraints
|
||||
|
||||
- **Empty panel**: Right-click shows shape creation menu (unchanged). Left-click does nothing. Zoom still works.
|
||||
- **Single-vertex shapes**: If a shape somehow has only 1 vertex, skip rendering (guard already exists at `count < 2`).
|
||||
- **Min zoom (30%)**: Vertex handles drawn at `HANDLE_RADIUS / 0.3 ≈ 10px` — still visible.
|
||||
- **Max zoom (300%)**: Vertex handles at `HANDLE_RADIUS / 3.0 ≈ 1px` — still visible but small.
|
||||
- **Zoom state is NOT persisted** in the .stk file. Each panel resets to 1.0 on load/clear (editor-only transient state).
|
||||
- **Vertex drag performance**: Emit `shape_changed` on drag end (not every frame) to avoid excessive WholeStickmanPreview rebuilds. Redraws during drag only touch the local panel.
|
||||
- **Multiple selected shapes**: Only one shape can be selected at a time across the entire editor.
|
||||
- **Right-click on empty area when shape exists but not selected**: Shows shape creation menu (existing behavior), which replaces the current shape.
|
||||
@@ -0,0 +1,577 @@
|
||||
# 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:
|
||||
|
||||
```json
|
||||
{
|
||||
"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
|
||||
|
||||
```gdscript
|
||||
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
|
||||
|
||||
```gdscript
|
||||
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:
|
||||
|
||||
```gdscript
|
||||
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:
|
||||
```gdscript
|
||||
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:
|
||||
|
||||
```gdscript
|
||||
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:
|
||||
```gdscript
|
||||
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.color` → `queue_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:
|
||||
```gdscript
|
||||
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:
|
||||
```gdscript
|
||||
_pan_offset = Vector2.ZERO
|
||||
_zoom = 1.0
|
||||
```
|
||||
|
||||
### 2l. Updated `set_shape_data()`
|
||||
|
||||
Reset pan offset on data load:
|
||||
```gdscript
|
||||
_pan_offset = Vector2.ZERO
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. WholeStickmanPreview — Phase 3 Changes
|
||||
|
||||
### 3a. New State Variables
|
||||
|
||||
```gdscript
|
||||
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
|
||||
|
||||
```gdscript
|
||||
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:
|
||||
```gdscript
|
||||
preview_area.draw_set_transform(Vector2.ZERO, 0.0, Vector2(_zoom, _zoom))
|
||||
```
|
||||
to:
|
||||
```gdscript
|
||||
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:
|
||||
```gdscript
|
||||
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:
|
||||
```gdscript
|
||||
_pan_offset = Vector2.ZERO
|
||||
_zoom = 1.0
|
||||
```
|
||||
|
||||
### 3g. Updated `set_body_parts()`
|
||||
|
||||
Reset pan offset on body part set (full refresh from editor):
|
||||
```gdscript
|
||||
_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
|
||||
|
||||
```gdscript
|
||||
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
|
||||
|
||||
```gdscript
|
||||
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:
|
||||
```gdscript
|
||||
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
|
||||
|
||||
```gdscript
|
||||
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.
|
||||
Reference in New Issue
Block a user