242 lines
13 KiB
Markdown
242 lines
13 KiB
Markdown
# 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.
|