1067 lines
146 KiB
Markdown
1067 lines
146 KiB
Markdown
# Stickman Studio
|
||
|
||
A Godot 4.4 editor tool for creating and assembling stick figures. Stickman Studio lets you draw individual body-part shapes, edit vertices, assemble them into a whole stickman, and save/load your figure to a JSON file (`.stk`) for later use or animation scripting.
|
||
|
||
## Overview
|
||
|
||
The editor is organized as **11 sub-windows** in a 3-column layout:
|
||
|
||
- **Left column (5)** — Head, Left Upper Arm, Left Lower Arm, Left Upper Leg, Left Lower Leg
|
||
- **Center column (5)** — Torso, Right Upper Arm, Right Lower Arm, Right Upper Leg, Right Lower Leg
|
||
- **Right column (1)** — **Whole Stickman** preview
|
||
|
||
The editor is driven by three menus: **File** (Save / Load / Clear), **Edit** (Configure Grid... / Snap to Grid), and **View** (Reset Views / Hide or Show Pose Guide).
|
||
|
||
Each body-part panel is an independent vector drawing surface with per-panel zoom **and panning (middle-mouse drag)**. A configurable background grid helps align vertices, and snap-to-grid can be enabled for both vertex dragging and whole-figure assembly. **Each panel supports multiple shapes** with Z-ordering controls (Send Back / Bring Forward) and shape-level Copy/Paste and Mirror operations. The Whole Stickman panel assembles every part into one figure — treating all shapes in a panel as a single unit — and lets you reposition, rotate, and scale each part, reorder the parts (Z-order), and mirror them.
|
||
|
||
Drawing surfaces **clip** their content to the panel bounds: shapes and parts no longer render outside a panel's borders when dragged or panned. A selected shape can be **dragged to reposition** it within its panel.
|
||
|
||
## Requirements
|
||
|
||
| Item | Version |
|
||
|---|---|
|
||
| Engine | Godot 4.4 or newer |
|
||
| Addon | Scalable Vector Shapes 2D (`addons/curved_lines_2d/`) |
|
||
|
||
> The legacy `stick.tscn` scene (a rigged walk animation using `Skeleton2D` + IK) remains in the project root for reference but is **no longer the main scene**.
|
||
|
||
## Running the project
|
||
|
||
No CLI build, test, or lint commands are used. Run the project from the Godot editor:
|
||
|
||
1. Open the project folder in **Godot 4.4**.
|
||
2. Press **F5**, or open and run `res://scenes/stickman_editor.tscn`.
|
||
|
||
The main scene is configured as `run/main_scene="res://scenes/stickman_editor.tscn"` in `project.godot`.
|
||
|
||
## Quick Start
|
||
|
||
### 1. Create a body part
|
||
|
||
1. Locate one of the 10 body-part panels (e.g. **Head**).
|
||
2. **Right-click** anywhere inside its drawing area to open the context menu.
|
||
3. Choose a starter shape:
|
||
- **Line** — a 120 px horizontal segment (2 vertices, open shape).
|
||
- **Rectangle** — a 100 × 60 px rectangle (4 vertices, closed shape).
|
||
- **Circle** — a 12-segment polygon (12 vertices, closed shape, radius 40).
|
||
|
||
The shape draws immediately and syncs to the Whole Stickman panel.
|
||
|
||
Rendering behavior:
|
||
|
||
- **Closed shapes** (`closed: true`) fill their interior with the chosen color (`draw_colored_polygon`) and draw a 2 px outline.
|
||
- **Open shapes** (`closed: false`) render as an outline-only segment with no fill.
|
||
|
||
Body-part panels expand vertically to fill their column height.
|
||
|
||
### 2. Edit vertices
|
||
|
||
Each panel supports vertex-level editing:
|
||
|
||
1. **Select a shape** — **Left-click** anywhere inside a shape to select it. The outline turns white.
|
||
2. **Create a vertex** — With a shape selected, **right-click** on its outline and choose "Create Point". A new vertex (shown as a hollow rectangle) is added at the nearest edge midpoint.
|
||
3. **Drag vertices** — **Left-click** on any vertex handle (filled circle = original, hollow rectangle = user-created) and drag to reshape. The shape conforms in real time. Release to apply. With **Snap to Grid** enabled, the dragged vertex jumps to the nearest grid intersection.
|
||
4. **Delete a vertex** — **Right-click** on a vertex handle and choose **Remove Point** (see *Deleting* below).
|
||
5. **Deselect** — Left-click outside the shape or select a different panel. Only one shape is selected at a time.
|
||
|
||
**Move a whole shape (Phase 5):**
|
||
1. **Left-click** a shape **not on a vertex handle** and drag to reposition the entire shape within the panel.
|
||
2. Release to commit. All vertices translate together; the shape's Z-order is unchanged.
|
||
3. With **Snap to Grid** enabled, the dragged shape jumps to the nearest grid intersection on release.
|
||
|
||
### 3. Zoom & Pan
|
||
|
||
Scroll the mouse wheel inside any body-part panel or the Whole Stickman preview to zoom. Pan the view with the middle-mouse button:
|
||
|
||
- **Wheel up** — zoom in by 10%
|
||
- **Wheel down** — zoom out by 10%
|
||
- **Middle-mouse drag** — pan the view in any direction
|
||
- **Range** — 30% (minimum) to 300% (maximum)
|
||
- Vertex handles remain visible at all zoom levels.
|
||
- Each panel has independent zoom and pan offset.
|
||
- Panning takes priority over vertex dragging (middle-mouse press cancels any in-progress drag).
|
||
- **Content is clipped to the panel bounds (Phase 5)** — shapes, grid, and gizmos cannot render outside a panel's drawing area, even when panned or dragged beyond its edge. This keeps each panel self-contained.
|
||
- Use **View → Reset Views** to restore all panels to 100% zoom and the origin pan offset.
|
||
|
||
### 4. Assemble a stickman
|
||
|
||
1. Create shapes for every part you want in the figure.
|
||
2. In the **Whole Stickman** panel, **drag any part** to reposition it in the figure.
|
||
- Parts get a generous hit area and show a yellow highlight while being dragged.
|
||
- A short label (`H`, `T`, `LUA`, `RUA`, `LUL`, `RLL`, …) marks each part.
|
||
- Use the mouse wheel to zoom in/out and middle-mouse to pan for precise positioning.
|
||
- With **Snap to Grid** enabled, the mouse pointer snaps to the nearest grid intersection and the part follows.
|
||
3. Optionally type a name in the **Stickman Name** field at the top.
|
||
4. **Right-click a selected part (Phase 5)** to reorder or mirror it:
|
||
- **Send Back / Bring Forward** — change the part's draw order in the preview.
|
||
- **Mirror X / Mirror Y** — flip the part around its bounding-box center.
|
||
|
||
### 5. Pose Silhouette Guide
|
||
|
||
The Whole Stickman preview can render a **pose silhouette guide**: a semi-transparent, color-coded stick figure that marks the rest-pose pivot joints of the future rig defined by `master_rig.tscn`. Its 13 joint anchors (Head, Neck, Left/Right Shoulder, Left/Right Elbow, Left/Right Wrist, Hips, Left/Right Knee, Left/Right Ankle) correspond to the exact pivot locations a future `Skeleton2D` rig will use, so you can align your body-part shapes to the same pivots.
|
||
|
||
The guide is a **view aid only**: it is drawn above the grid **and in front of user parts** (ghosting over them), moves with pan/zoom, is **not** hit-testable (it never intercepts part dragging or selection), and is **not** saved to the `.stk` file.
|
||
|
||
**Color coding** (both lines and joint dots use these color groups):
|
||
|
||
| Group | Joints | Color | Line alpha | Joint alpha |
|
||
|---|---|---|---|---|
|
||
| Left (cyan/blue) | LeftShoulder, LeftElbow, LeftWrist, LeftKnee, LeftAnkle | `(0.35, 0.70, 1.00)` | `0.45` | `0.65` |
|
||
| Right (orange/red) | RightShoulder, RightElbow, RightWrist, RightKnee, RightAnkle | `(1.00, 0.50, 0.20)` | `0.45` | `0.65` |
|
||
| Central (white) | Hips, Neck, Head | `(1.00, 1.00, 1.00)` | `0.45` | `0.65` |
|
||
|
||
- **Lines** (limbs + spine) render semi-transparent with line alpha `0.45`; the **head outline** is a white semi-transparent circle of radius 100 (1:1 `master_rig.tscn` scale) centered on the Head joint.
|
||
- **Joint dots** are constant **6 px** on screen at every zoom level (`GUIDE_JOINT_RADIUS / _zoom`).
|
||
- The guide is **ON by default**. Toggle it via **View → Hide Pose Guide** / **View → Show Pose Guide** (the menu item is a dynamic, text-only label — no checkmark — reading "Hide Pose Guide" while the guide is visible and "Show Pose Guide" while it is hidden); the setting persists to `user://settings.json` (`show_pose_guide`).
|
||
|
||
**Centered at the default view:** the guide's figure bounding box is centered at the preview-area center, computed at draw time from the live preview size (`GUIDE_FIGURE_CENTER = (0, -93.75)`; `_guide_to_preview()` maps `master_pos` to `(master_pos - GUIDE_FIGURE_CENTER) * GUIDE_SCALE + preview_area.size * 0.5`). It is centered at startup and after **Reset Views** (zoom 1 / pan 0), and re-centers automatically when the window is resized. Because it remains a **world-space fixture**, panning/zooming moves it with the grid and parts.
|
||
|
||
**1:1 master-rig scale (deliberate):** the guide matches `master_rig.tscn` proportions exactly (a figure of ≈ 336 × 940 px, roughly x ∈ [-168, 168], y ∈ [-563.5, 376]). Because the default starter parts are small, the guide intentionally **dwarfs** them. At 1:1 zoom the ~940 px figure is taller than a typical panel, so the head and feet clip equally above/below the fold when centered — accepted. `GUIDE_SCALE`/`GUIDE_FIGURE_CENTER` are the tunables if a different size or placement is wanted later.
|
||
|
||
### 6. Grid & Panning
|
||
|
||
All 10 body-part panels and the Whole Stickman preview share a global background grid (default cell size **15 px**, Phase 5; previously 5 px):
|
||
|
||
1. Click **Edit → Configure Grid...** and set the grid size.
|
||
- **SpinBox** accepts 1–100 px per cell.
|
||
- The setting is applied to every panel simultaneously and persists to `user://settings.json`.
|
||
2. Toggle **Edit → Snap to Grid** to enable snapping (Phase 5: the checkable menu item now reliably shows its ✓ checkmark when toggled):
|
||
- On **vertices** — while dragging a vertex handle, it jumps to the nearest grid intersection.
|
||
- On **assembly** — while dragging a part in the Whole Stickman preview, the pointer snaps and the part follows.
|
||
- On **shape drag** — while dragging a whole shape (Phase 5), it snaps on release.
|
||
- Existing vertices are *not* retroactively snapped; snapping only applies to new drags.
|
||
3. **Pan** — hold the **middle-mouse button** and drag anywhere in a panel to pan the view.
|
||
- Panning uses the same transform as zoom, and each panel keeps an independent offset.
|
||
- **View → Reset Views** restores 100% zoom and the origin offset on all panels.
|
||
4. Grid lines are drawn behind shapes and scale with pan/zoom, so they stay aligned to world coordinates.
|
||
|
||
### 7. Changing Colors
|
||
|
||
Colors are easy to change on any existing shape:
|
||
|
||
1. **Right-click** on a shape (either selected or with the cursor over it) and choose **Color...**.
|
||
2. A **ColorPicker** popup appears with the shape's current color pre-loaded.
|
||
3. Adjust the color — the shape updates live as a preview.
|
||
4. Click **OK** to commit the change or **Cancel** to revert to the original color.
|
||
|
||
The chosen color is stored in the shape's `color` field and is saved/loaded with the `.stk` file.
|
||
|
||
### 8. Deleting
|
||
|
||
Two levels of deletion are available:
|
||
|
||
**Delete an entire shape:**
|
||
1. **Right-click** on a shape (either selected or with the cursor over it) and choose **Delete**.
|
||
- Delete is only offered when a shape exists and the mouse is over it.
|
||
2. The shape is removed from the panel and the Whole Stickman preview.
|
||
|
||
**Remove a single vertex:**
|
||
1. **Right-click** on a vertex handle and choose **Remove Point**.
|
||
2. If 2 vertices remain, the shape becomes an open **line** (`closed = false`).
|
||
3. If 1 vertex or fewer remains, the entire shape is cleared.
|
||
|
||
### 9. Multiple Shapes & Z-Ordering
|
||
|
||
Each body-part panel can contain **more than one shape**. New shapes are created via the standard right-click context menu and are drawn on top of existing shapes. Shapes are drawn in Z-order (last in the list = frontmost).
|
||
|
||
**Z-ordering controls:**
|
||
|
||
- **Right-click** on a shape and choose **Send Back** to move it one step behind (earlier in draw order).
|
||
- **Right-click** on a shape and choose **Bring Forward** to move it one step ahead (later in draw order).
|
||
|
||
Z-order is preserved in the `.stk` save file (shapes are stored in draw order within the `shapes` array).
|
||
|
||
Only one shape within a panel can be selected at a time for vertex editing. Left-click a shape to select it; right-click also selects the shape under the mouse for context menu operations.
|
||
|
||
**Shape Copy & Paste (Phase 5):**
|
||
1. **Right-click** on a shape and choose **Copy** to place a deep copy of the shape on the editor-wide clipboard.
|
||
2. **Right-click** (on empty space or a shape) and choose **Paste** to insert the clipboard shape at the click position.
|
||
- Paste only appears while the clipboard is populated.
|
||
- The clipboard **persists after pasting** and is shared across **all 10 body-part panels** — a shape copied in one panel can be pasted into any other.
|
||
- The pasted shape becomes the newly selected (topmost) shape.
|
||
- **Copy → Clear → Paste** semantics: the clipboard survives figure clear.
|
||
|
||
**Shape Mirroring (Phase 5):**
|
||
1. **Right-click** on a selected shape and choose **Mirror X** or **Mirror Y**.
|
||
2. The shape's vertices are mirrored around the shape's bounding-box center.
|
||
3. Mirroring recomputes the `points` array in place — no extra fields are stored; the result is saved as normal vertex data.
|
||
|
||
### 10. Whole Stickman Manipulation (Selection, Rotation, Scale)
|
||
|
||
The Whole Stickman preview treats all shapes in a body-part panel as **one combined object**. Each part can be independently translated, rotated, and scaled.
|
||
|
||
**Selection:**
|
||
- **Left-click** on any part in the Whole Stickman preview to select it.
|
||
- A **white bounding box** appears around the selected part, along with manipulation gizmos.
|
||
- Click empty space to deselect.
|
||
|
||
**Translation:**
|
||
- **Left-click and drag** a selected part to reposition it. Snap-to-grid applies to the pointer position when enabled.
|
||
|
||
**Rotation:**
|
||
- When a part is selected, a **filled circle** appears centered below the bounding box.
|
||
- **Click and drag** the rotation circle to rotate the part around its center.
|
||
- Hold **Ctrl** while rotating to snap to **15-degree increments** (relative to the original 0° position).
|
||
- Rotation is **absolute**: 0° always means the original orientation the object was created with.
|
||
- The accumulated rotation value is saved in the `.stk` file.
|
||
|
||
**Scale:**
|
||
- When a part is selected, **cross/plus markers** appear at all four corners of the bounding box.
|
||
- **Click and drag** any corner cross to freely scale the part.
|
||
- Hold **Ctrl** while scaling to **lock the aspect ratio** (scale uniformly).
|
||
- Grid snapping snaps the corner position to the nearest grid intersection.
|
||
- Scale is **relative to the created size**: (1.0, 1.0) = original size.
|
||
- The scale value is saved in the `.stk` file. Scale may be **negative** for mirrored parts.
|
||
- **Part mirroring (Phase 5)** — right-click a selected part and choose **Mirror X** or **Mirror Y** to mirror it around its bounding-box center. This is implemented by negating the part's scale factor along the chosen axis (no vertex data is changed).
|
||
|
||
**Part Z-ordering (Phase 5):**
|
||
- Right-click a selected part and choose **Send Back** or **Bring Forward** to move it one step within the preview's draw order.
|
||
- Selection respects Z-order: hit-testing runs **front-to-back**, so the frontmost part under the pointer is selected.
|
||
- Part Z-order is saved in the `.stk` file as the top-level `part_order` array.
|
||
|
||
### 11. Save
|
||
|
||
1. Click **File → Save**.
|
||
2. Choose a location and name. The default extension is `.stk` (appended automatically if omitted).
|
||
3. Click **Save**. The figure is written as JSON.
|
||
|
||
### 12. Load
|
||
|
||
1. Click **File → Load**.
|
||
2. Select a `.stk` file.
|
||
3. On success, all body-part panels and the Whole Stickman preview are populated. On failure, an error dialog reports the problem (missing file, parse error, or unsupported version).
|
||
|
||
> v1.0, v1.1, v1.2, v1.3, and v1.4 files are automatically migrated to v1.5 on load (v1.0/v1.1 single shapes wrapped in a `shapes` array, rotation defaults to 0, scale defaults to (1,1); files without `part_order` fall back to the default part order). `pivot`, `length`, `proportions`, and `guide_offset` are **write-only** metadata recomputed from live shapes on every save — never read back — so old files load unchanged and gain these keys on their next save.
|
||
|
||
### 13. Clear
|
||
|
||
1. Click **File → Clear**.
|
||
2. A confirmation dialog warns that the current stickman will be cleared.
|
||
3. Confirm to reset all panels and the preview (zoom resets to 100%, pan offset to origin).
|
||
|
||
### 14. Test Harness & Stickman Factory (Phase 9)
|
||
|
||
Phase 9 adds the **runtime pipeline** that turns a saved `.stk` file into a live, rigged `master_rig.tscn` instance — plus a standalone staging scene for debugging it in isolation. **Neither is wired into the editor.**
|
||
|
||
**Stickman Factory (`res://scripts/stickman_factory.gd`)** — the runtime entry point:
|
||
|
||
| Method | Signature | Behavior |
|
||
|---|---|---|
|
||
| `load_stk` | `static func load_stk(path: String) -> Dictionary` | Reads a `.stk` file via `FileAccess` + `JSON.parse_string`. Returns `{}` and `push_warning` on failure. |
|
||
| `spawn_from_data` | `static func spawn_from_data(stk_data: Dictionary) -> Node2D` | Instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, returns the rig root. |
|
||
| `spawn` | `static func spawn(path: String) -> Node2D` | Chains `load_stk()` → `spawn_from_data()`. Returns `null` on empty data. |
|
||
|
||
The factory is the intended runtime API: `StickmanFactory.spawn("res://stickmen/basic.stk")` yields a rigged `StickmanRig` (the root of `master_rig.tscn`) ready to add to the scene tree. `StkRigAdapter` (Phase 8, extended by Phase 9) now also fits the head bone (`Head.position.y = -proportions.torso_length`) and mounts the head as **full geometry** like every other part (clearing its inline `@tool` circle script); the mounted head is dropped `HEAD_CHIN_DROP` (28 px) below the neck so its chin aligns with the pose-guide circle's bottom and overlaps the torso (Phase 9 Round 4).
|
||
|
||
**Test Harness (`res://scenes/test_harness.tscn`)** — run via **F6** on the scene (NOT via the main editor scene). Controls:
|
||
|
||
- **Open .stk…** — `FileDialog` filtered to `*.stk`; quick-select buttons for `res://stickmen/break.stk`, `res://stickmen/basic.stk`, `res://stickmen/test.stk`.
|
||
- **Show Bones / Show IK Handles** — checkboxes toggling the debug overlay.
|
||
- **Loaded filename** — status label showing the currently loaded file.
|
||
- **Pan / zoom** — middle-mouse drag to pan, mouse-wheel to zoom the `Camera2D`; the camera recenters on each spawn.
|
||
- **IK drag** — click and drag any of the **6** `Marker2D` IK handles (`IK_Targets/Left_Hand`, `Right_Hand`, `Left_Leg`, `Right_Leg` flex the limb via TwoBoneIK; `IK_Targets/Torso` translates the whole rig rigidly via its `RemoteTransform2D`; `IK_Targets/Head` drives the head's `SkeletonModification2DLookAt` aim rotation) (Phase 9 Round 7). The rig self-enables its modification stack in `_ready()`.
|
||
- **Facing** — a `MenuButton` (leftmost in the top bar) applying a preset to the rig's **`StickmanRig`** exported `facing_profile`, which sets the rig's TwoBoneIK **Flip Bend Direction** flags: **Left** (arms normal, legs inverted), **Right** (arms inverted, legs normal), **Forward** (RightArm / LeftLeg inverted — the rig's default). Facing **Left** now applies a **whole-rig Y-axis mirror** — `Master.scale.x = -1` (RIGHT/FORWARD → `(1,1)`) — so the head **and** body mirror together and face the correct direction (this replaces the old head-only `Body/Head.scale.x = -1` mirror; `_apply_head_flip()` is removed). Two head-related fixes make the mirror stable: the head `RemoteTransform2D` (`Skeleton2D/Torso/Head/Pivot`) pushes the **full transform** (no `update_scale = false`), so `Body/Head.scale` stays identity under the mirrored root (the old partial-channel push re-canonicalized the scale and caused per-frame Y-flips/wrap-jumps); and when facing LEFT `_apply_head_lookat_mirror_mode()` **disables** the head `SkeletonModification2DLookAt` and pins the head bone to the FORWARD canonical aim (π), re-asserted each physics frame by `_pin_mirrored_head_rotation()` while ANIMATED/RECOVERING (RIGHT/FORWARD re-enable the LookAt) — so interactive head-aiming is intentionally static while facing LEFT. The current profile is prefixed `[√] ` on the menu labels and persists across rig loads (Phase 9 Task 1, Task 4).
|
||
- **Bend-direction toggle** — right-click an elbow or knee joint in the viewport to pop a context menu that inverts that joint's TwoBoneIK bend direction ("Invert Bend" → "Normal Bend" and back). Only the 4 elbows/knees are targets (Phase 9 Task 1).
|
||
- **Body-part z-order** — the Facing profile also reorders the rig's `Body/*` visual part nodes (tree order = draw order), now owned by the rig's `StickmanRig._apply_body_z_order()`: **Forward** draws all limbs in front of the torso, **Left** tucks the left arm/leg pairs behind the torso (right pairs in front), **Right** tucks the right pairs behind; upper limbs sit behind lower limbs, far-side (behind-torso) arms draw behind the legs while near-side arms draw in front of them, and the **head is always frontmost** (Phase 9 Task 2, Task 4). The per-profile z-order tables are **unchanged** and kept provisionally — an X-mirror does not affect depth (draw order).
|
||
- **Animation** — a dropdown (populated per spawn from the rig's `AnimationPlayer.get_animation_list()`, `walk_right` pre-selected) plus **Play/Pause/Resume** (label swaps with playback state), **Stop**, and **Loop** (default ON, persists across loads) controls. The harness drives the rig's `AnimationPlayer` directly by node path (`ANIMATION_PLAYER_PATH`); loop writes `Animation.loop_mode` before play, and playback state is tracked via the button handlers + the `animation_finished` signal (no polling). The `AnimationTree` node remains an untouched placeholder. The animation `.:facing_profile` tracks are **neutralized/removed** — facing is now set explicitly by `walk_to()`/`set_facing_profile()` (LEFT root-mirrors the rig and plays the same canonical `walk_right` clip mirrored; `walk_left` is no longer used at runtime), so playing `walk_right` no longer flips the profile through an animation track (Phase 9 Task 5).
|
||
|
||
Debug overlay (a world-space `Node2D` `_draw()`): true bone **segments** drawn between each `Bone2D` origin and its Bone2D children (color-coded left cyan / right orange / central white, with a joint dot per bone), with limb leaf bones drawn out to their IK targets so the forearm/shin segments and wrist/ankle joints are visible (Phase 9 Round 2) and the **Head** leaf drawn along the bone's own direction (~90 px, since its IK target is a LookAt aim point, not a joint) (Phase 9 Round 3), when **Show Bones** is on; colored markers at the six IK targets — hands green, feet blue, head **yellow**, torso **magenta** (Phase 9 Round 7) — plus a semi-transparent yellow aim line from the Head bone to the head marker, when **Show IK Handles** is on. Each load frees the previous rig and spawns a fresh one.
|
||
|
||
### 15. Vector Terrain System
|
||
|
||
The **Vector Terrain System** is a standalone, reusable component for building **crisp, resolution-independent vector terrain** (flat ground, angled ramps, stepped platforms) — with matching 2D collision. It is **not wired into the editor**; it is exercised through a dedicated staging scene run via **F6**.
|
||
|
||
**`TerrainBlock` (`res://scripts/terrain_block.gd`)** — `@tool` `class_name TerrainBlock`, `extends StaticBody2D`. A single terrain segment that builds its three children in code:
|
||
|
||
| Child | Node type | Purpose |
|
||
|---|---|---|
|
||
| Interior fill | `Polygon2D` | Fills the block's interior with `fill_color`. |
|
||
| Vector border | `Line2D` | Crisp outline using `outline_color` / `outline_width`; auto-closes the loop by appending the first vertex to the end, with `LINE_JOINT_ROUND` and round caps. |
|
||
| Collision | `CollisionPolygon2D` | Physical body; `BUILD_SOLIDS` solid decomposition (supports **concave** blocks). |
|
||
|
||
**Exported properties** (`polygon_points: PackedVector2Array`, `fill_color`, `outline_color`, `outline_width`) are driven by a single unified setter that pushes vertex changes to all three children live — no manual rebuilds.
|
||
|
||
**`TerrainUtils` (`res://scripts/terrain_utils.gd`)** — `class_name TerrainUtils`, `extends RefCounted`, static utility.
|
||
|
||
- `sanitize_points(points: PackedVector2Array, grid_size: float = 16.0) -> PackedVector2Array` — sanitizes a raw vertex list through a fixed pipeline, in order:
|
||
1. **Grid snap** — rounds each vertex to the `grid_size` grid (default **16.0**).
|
||
2. **Redundancy removal** — a local `_simplify_polyline()` (Godot 4.7 has **no** `Geometry2D.simplify_polyline()`), which drops consecutive duplicates, a closing duplicate when `last == first`, and collinear vertices.
|
||
3. **Clockwise enforcement** — `Geometry2D.is_polygon_clockwise()` reverses the winding if it is not already clockwise, **guaranteeing clockwise output**.
|
||
- `spawn_block(...)` — factory that sanitizes the raw input vectors (`sanitize_points`), creates a `TerrainBlock`, applies the cleaned points, and adds it to the target container.
|
||
|
||
**`physics_test_harness.tscn` / `scripts/physics_test_harness.gd`** — `class_name PhysicsTestHarness`, `extends Node2D`; a **standalone staging scene** (run via **F6**; not wired into the editor). It builds flat ground, angled ramps, and stepped `TerrainBlock` instances via `TerrainUtils`, then instantiates `res://master_rig.tscn` standing on the flat ground. The scene root is a `Node2D` + script with a `Camera2D` at position `(0, -400)` zoom `0.5` and an empty `Environment` container. The camera wheel-zoom scales between **0.25x and 3.0x** (to test resolution independence / vector outline thickness); middle-drag pans. It also hosts the **Dynamic Vector Props** spawner (see below): press **1/2/3** to drop physics props above the angled ramp, plus a best-effort `StaticBody2D` collision proxy for the rig (which has no physics bodies of its own). A toggle-mode **Button** (label "Stickman" ↔ "Ragdoll") flips the rig's **kinematic-to-ragdoll** mode (see §17); a **Rest** `SpinBox` (0.1–10 s) writes `_rig.rest_timeout` (runtime-only) and a **"Recover Now"** button calls `_rig.request_recovery()`. The harness connects the rig's `state_changed` signal, which removes the `RigCollisionProxy` on `RAGDOLL` and re-adds it (idempotently) on `ANIMATED`/`RECOVERING`.
|
||
|
||
### 16. Dynamic Vector Props
|
||
|
||
The **Dynamic Vector Props** system is a standalone, reusable component for building **physical, dynamic props** (crates, balls, planks) as `RigidBody2D` bodies with crisp vector rendering and matching collision. It is **not wired into the editor**; it is exercised through the physics test harness staging scene via **F6**.
|
||
|
||
**`PropBlock` (`res://scripts/prop_block.gd`)** — `@tool` `class_name PropBlock`, `extends RigidBody2D`. A single physical prop whose children are built in code, `@tool`-safe (null-guarded for editor safety):
|
||
|
||
| Child | Node type | Purpose |
|
||
|---|---|---|
|
||
| Interior fill | `Polygon2D` | Fills the prop's interior with `fill_color`. |
|
||
| Vector outline | `Line2D` | Crisp outline using `outline_color` / `outline_width`; auto-closes the loop by appending the first vertex to the end, with `LINE_JOINT_ROUND` / `LINE_CAP_ROUND`. |
|
||
| Collision (polygon mode) | `CollisionPolygon2D` | `BUILD_SOLIDS` solid decomposition; used for `POLYGON` shape type. |
|
||
| Collision (circle mode) | `CollisionShape2D` + `CircleShape2D` | Radial 48-segment loop (`CIRCLE_SEGMENTS = 48`); used for `CIRCLE` shape type. |
|
||
|
||
**Exported properties:**
|
||
|
||
| Property | Type | Default | Behavior |
|
||
|---|---|---|---|
|
||
| `shape_type` | `@export_enum("Polygon","Circle")` | `POLYGON` | Selects which geometry + which collision node is enabled (polygon ↔ circle). |
|
||
| `polygon_points` | `PackedVector2Array` | empty | Polygon geometry; applies only when `shape_type == POLYGON`. |
|
||
| `radius` | `float` | `32.0` | Circle radius; applies only when `shape_type == CIRCLE`. |
|
||
| `fill_color` | `Color` | `(0.55, 0.35, 0.15)` | Interior fill. |
|
||
| `outline_color` | `Color` | `(0.15, 0.08, 0.02)` | Vector outline. |
|
||
| `outline_width` | `float` | `2.0` | Vector outline thickness. |
|
||
| `material_preset` | `@export_enum("None","Wood","Rubber","Cardboard","Metal")` | `None` | Sets `mass` + `physics_material_override` (`PhysicsMaterial.friction` / `.bounce`) and themed colors. |
|
||
|
||
**Material presets** (`mass_for` / `physics_material` / `tint_for` static factories):
|
||
|
||
| Preset | mass | friction | bounce | Fill tint |
|
||
|---|---|---|---|---|
|
||
| Wood | `3.0` | `0.6` | `0.1` | `(0.55, 0.38, 0.2)` |
|
||
| Rubber | `0.5` | `0.9` | `0.85` | `(0.9, 0.2, 0.2)` |
|
||
| Cardboard | `0.4` | `0.3` | `0.05` | `(0.85, 0.72, 0.45)` |
|
||
| Metal | `8.0` | `0.9` | `0.0` | `(0.5, 0.55, 0.6)` |
|
||
| None | `1.0` | `0.5` | `0.05` | default fill |
|
||
|
||
A **unified live-update setter** drives geometry to all children live — a change to `polygon_points` / `radius` / `shape_type` pushes to the `Polygon2D`, `Line2D`, and the active collision node with no manual rebuilds. Non-`NONE` presets also recolor `fill_color` and `outline_color` (outline = `tint_for(preset).darkened(0.55)`).
|
||
|
||
**`PropUtils` (`res://scripts/prop_utils.gd`)** — `class_name PropUtils`, `extends RefCounted`, static factory.
|
||
|
||
- **Primitive generators** return shape-payload dictionaries with default dimensions + color themes:
|
||
- `create_box(size := Vector2(48,48), ...)` — a 4-point `POLYGON` (wood theme).
|
||
- `create_ball(radius := 24.0, ...)` — a `CIRCLE` payload (rubber theme).
|
||
- `create_plank(length := 160.0, thickness := 16.0, ...)` — a 4-point `POLYGON` (metal theme).
|
||
- `create_triangle(base := 56.0, height := 48.0, ...)` — a 3-point `POLYGON` (cardboard theme).
|
||
- `spawn_prop(container, position, shape_payload, material_preset := WOOD, initial_velocity := Vector2.ZERO) -> PropBlock` — factory that instantiates a `PropBlock`, applies the payload (`shape_type` + geometry + colors), sets the material preset, sets `linear_velocity` after `add_child` (when non-zero), and returns the spawned prop. Polygon `points` are sanitized through `TerrainUtils.sanitize_points()`.
|
||
|
||
**Physics test harness controls (extended):** the `physics_test_harness.gd` scene now accepts **1 / 2 / 3** key presses to spawn props above the angled ramp (spawn point `(300, -300)`), tumbling them down the terrain:
|
||
|
||
| Key | Prop | Primitive | Preset | Initial velocity |
|
||
|---|---|---|---|---|
|
||
| **1** | Wood Crate | `create_box()` | `WOOD` | `(60, 0)` |
|
||
| **2** | Bouncy Ball | `create_ball()` | `RUBBER` | `(-80, 0)` |
|
||
| **3** | Heavy Plank | `create_plank()` | `METAL` | `(30, -40)` |
|
||
|
||
**Rig collision proxy caveat:** the standing `master_rig.tscn` figure has **no physics bodies of its own**, so a best-effort code-only `StaticBody2D` proxy (`RigCollisionProxy`) provides a static collision surface matching the figure's world bounds — a 240×1000 px `RectangleShape2D` box centered at `(0, -500)`. Props bounce/rest against it. The proxy is a stand-in for the rig's eventual physics bodies and is not part of the rig itself. In ragdoll mode (see §17) the proxy is removed on entry so the physical ragdoll collides directly with the terrain, and re-added on exit.
|
||
|
||
### 17. Kinematic-to-Ragdoll State System
|
||
|
||
`StickmanRig` (the runtime root of `master_rig.tscn`) can switch between physics modes via a reversible state machine with three states:
|
||
|
||
- **`ANIMATED`** (default) — the kinematic `Skeleton2D` + IK puppet is visible and driven by the `AnimationPlayer` / `SkeletonModificationStack2D`. The rig root's momentum is cached each physics frame (`_physics_process` → `_track_momentum()`) so it can be handed off on transition.
|
||
- **`RAGDOLL`** — the kinematic rig is frozen (`AnimationPlayer` stopped with `keep_state`) and the `Body/*` visuals hidden **immediately**; a procedural network of `RigidBody2D` + `PinJoint2D` nodes spawned in code takes over, letting the figure fall/tumble against the terrain and props. The ragdoll is built from the **current solved bone positions** (the IK stack is disabled only after the build), so the handoff is instant and seamless — the ragdoll appears in exactly the pose the figure was in, no crossfade.
|
||
- **`RECOVERING`** — the ragdoll has been destroyed and the skeleton is being snapped back to the captured rest pose, then tweened upright (IK targets captured → standing) before returning to `ANIMATED`.
|
||
|
||
**Public API (`StickmanRig`):**
|
||
|
||
| Member | Signature | Behavior |
|
||
|---|---|---|
|
||
| `state` | `var state: RigState` | Current mode — `RigState.ANIMATED`, `RigState.RAGDOLL`, or `RigState.RECOVERING`. |
|
||
| `state_changed` | `signal state_changed(new_state: int)` | Emitted on every transition with the `RigState` enum value. |
|
||
| `is_in_ragdoll()` | `func is_in_ragdoll() -> bool` | `state == RigState.RAGDOLL` (so `RECOVERING` reads as "Stickman"). |
|
||
| `set_ragdoll(enabled)` | `func set_ragdoll(enabled: bool) -> void` | Enters/exits ragdoll; idempotent (no-op when already in the target state). |
|
||
| `toggle_ragdoll()` | `func toggle_ragdoll() -> void` | `set_ragdoll(not is_in_ragdoll())`. |
|
||
| `request_recovery()` | `func request_recovery() -> void` | Forces the stand-up recovery path; no-op unless in `RAGDOLL`. |
|
||
|
||
**Momentum handoff:** `_track_momentum(delta)` computes the rig root's linear/angular velocity from per-frame `global_position` / `global_rotation` deltas and caches them. On entering ragdoll, the cached velocities are applied directly to the ragdoll **Torso** body (`linear_velocity` + `angular_velocity`), so the figure continues its current motion seamlessly.
|
||
|
||
**Ragdoll construction (`_build_ragdoll()`):** bodies and joints are built in code and reparented into a `Node2D` container named `"RagdollBodyContainer"` added as a child of the rig's **parent** (the world root) — not the rig, which carries its spawn offset — so all bodies/joints are placed in world coordinates.
|
||
|
||
| Property | Value |
|
||
|---|---|
|
||
| Body count | **10** `RigidBody2D` |
|
||
| Torso | `CapsuleShape2D` (radius 12, length = distance between the Torso and Head bone origins), mass **8.0** |
|
||
| Head | `CircleShape2D` (radius 100), mass **2.0** |
|
||
| 8 limbs | `CapsuleShape2D` (radius 8, length = `bone.length`), masses 1.0–2.0 |
|
||
| Joint count | **9** `PinJoint2D` (one per non-root body, pinned at the child bone's origin), `softness` = `RAGDOLL_TARGET_SOFTNESS` (0.2) at build |
|
||
| Collision | `collision_layer` / `collision_mask` = **1** (matches `TerrainBlock` / `PropBlock`) |
|
||
|
||
Ragdoll bodies spawn fully visible — the entry handoff is instant (the ragdoll is built at the current solved pose, so there is nothing to fade).
|
||
|
||
**Angular limits** (`_apply_ragdoll_joint_limits()`): elbows/knees fold **only** — the natural bend goes toward +CW (`-5°..+150°`) or its mirrored CCW variant (`-150°..+5°`) depending on limb side/facing, so limbs never hyperextend; shoulders/hips allow ±160°; the neck is free (`angular_limit_enabled = false`).
|
||
|
||
**Instant handoff (Phase 11):** `_enter_ragdoll()` stops the player with `keep_state` (no pose reset), builds the ragdoll from the **current solved bone positions** while the IK stack is still enabled, then hides `Body/*` and disables the IK stack in the same call. There is deliberately **no crossfade** — the ragdoll spawns at exactly the same pose, so a fade would only read as ghosting (an earlier blended transition was removed on director feedback).
|
||
|
||
**Rest detection (auto-recovery):** while `state == RAGDOLL`, `_update_rest_detection()` reads the **Torso** `RigidBody2D`. When it is sleeping **or** its linear velocity ≤ `REST_LINEAR_THRESHOLD` (**5.0 px/s** — tuned up from the original 0.1 because a soft-pinned ragdoll micro-jitters around ~0.5 px/s even when settled) and angular velocity ≤ `REST_ANGULAR_THRESHOLD` (0.1 rad/s), a `_rest_timer` accumulates; after `rest_timeout` (exported, default **2.0 s**) plus a `STABILIZATION_DELAY` (0.1 s) hold, and with `auto_recover` (exported, default **true**) enabled, recovery is triggered. `rest_timeout` and `auto_recover` are runtime-adjustable exports.
|
||
|
||
**Recovery (`_start_recovery()`, also `request_recovery()`):** `_capture_ragdoll_pose()` records the 10 bodies' rig-local `{pos, rot, half}` into `_captured_pose` (`half` = each capsule's half-length, stored as build-time metadata) **plus** a landing anchor — `_captured_landing_center` (the ragdoll torso's world center) and `_captured_ground_y` (the torso's ground-contact line, `center.y + RAGDOLL_TORSO_RADIUS`). Because the ragdoll bodies spawn under a world sibling, the rig root never moves while the figure falls, so before destroying the ragdoll `_start_recovery()` calls `_reanchor_root_to_landing()`, which translates the rig root so the standing figure's **feet** sit on the ground at the landing X — `feet = (_captured_landing_center.x, _captured_ground_y)`, `new_root = feet + FOOT_OFFSET` — and re-bases the captured rig-local positions by that root shift. The figure stands up **in place, on the ground**, where the ragdoll landed — not anchoring its hip to the lying hip (a spine-direction hip sits at ground level when the torso lies flat, which would bury the standing feet ~363 px into the ground), and not back at its pre-ragdoll position. The ragdoll is then destroyed and `state = RECOVERING` is set + emitted. `_snap_skeleton_to_pose()` writes the `IK_Targets/Torso` position + rotation, `IK_Targets/Head`, and the 4 limb markers (never the slaved Torso `Bone2D`), re-shows `Body/*`, then calls `_rearm_ik_stack()` — a defensive re-setup (re-runs `setup()` when the stack reports `!get_is_setup()`, re-asserts `enabled = true` and `Skeleton2D.set_process_internal(true)`, and re-asserts the Torso marker `RemoteTransform2D`'s update-position/rotation/scale flags) so TwoBoneIK reliably resumes solving toward the end-effectors after a disable→enable toggle; its runtime diagnosis is gated behind `const DEBUG_RECOVERY := false` (`_recovery_dbg()`, off by default). Snap geometry: the ragdoll capsules span joint origin→tip along their +X, so the **hip** is derived as `torso.pos − spine_dir·half` and the wrist/ankle targets as `lower_body.pos + dir·half`; the Torso marker rotation subtracts the Torso `Bone2D`'s `bone_angle` (bone world angle = marker rotation + bone_angle — copying the body rotation directly would slam the skeleton −90° and lay it flat). The snap therefore reproduces the ragdoll's exact final pose (a "sitting" rest stays sitting). `_play_stand_up()` then tweens the 6 markers **directly** from their captured values to `STAND_POSE` over `STAND_UP_DURATION` (**2.0 s**, sine ease-in-out, `_tween_markers_to()`). The baked `stand_up` animation is **not** played — a fixed first keyframe can never match an arbitrary ragdoll rest pose (the earlier bridge-into-the-animation approach caused a visible jump from the captured pose to the animation's first frame), so the tween starts from wherever the snap left the markers. On tween finish, `_on_stand_up_finished()` re-enables IK, re-shows `Body/*`, sets `state = ANIMATED`, and emits.
|
||
|
||
**Interruptibility:** `set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll; `set_ragdoll(false)` during `RAGDOLL` routes through `_start_recovery()`; repeated `set_ragdoll` calls are idempotent. All ragdoll nodes are spawned procedurally — `master_rig.tscn` is **not** modified (the `stand_up` / `walk_left` / `walk_right` animations are baked into the scene's `AnimationLibrary` by the `create_animations.gd` editor script, which runs manually in the editor; the baked `stand_up` is an authored reference and the recovery path does not play it). The ragdoll bodies spawn under a **world sibling** (not the rig root), so the whole-rig Y-axis mirror does not affect them; `walk_left` remains in the library but is **not used at runtime** — `walk_to()` plays the canonical `walk_right` clip for every direction (root-mirrored for LEFT).
|
||
|
||
### 18. Sandbox Stage Builder
|
||
|
||
The **Sandbox Stage Builder** (Phase 2) is a standalone, kid-friendly director sandbox: a visual stage where you place terrain, props, and stickmen from a palette, then switch between **Edit** (build), **Direct** (director/rule authoring), and **Play** (physics simulation). It is **not wired into the editor** — run via **F6** on `res://scenes/sandbox_stage.tscn`.
|
||
|
||
The stage is intentionally **extendable**: the spawn palette is registry-driven (adding an object type = appending one dictionary entry), selection hit-tests arbitrary `Node2D`s geometrically, gizmos drive `global_position`/`global_rotation`, and core events are exposed as signals for future phases (Action Queue, Triggers, Save/Load). Styling defaults (fonts/sizes/colors/grid) live in a hand-editable `res://sandbox_theme.json` (Phase 4b, §21).
|
||
|
||
| File | Purpose |
|
||
|---|---|
|
||
| `res://scenes/sandbox_stage.tscn` | The stage scene: root `Node2D` + `Camera2D` + empty `World` container. |
|
||
| `res://scripts/sandbox_stage.gd` | `class_name SandboxStage`, `extends Node2D` — root controller (3-mode state machine, placement + drag-painting, camera, deletion, bottom status bar, mode badge/frame/cursors, signals); **Phase 3b** instantiates the `AssetSelector` grid popup + the two thumbnail renderers, owns the selector open/close flow and the lazy per-frame thumbnail drain, and wires the Stickman/Prop palette buttons to the selector (§22). **Phase 3c** adds the editor-tool wiring: the `QueuePanel` / `RulePanel` / `ActionEditor` / `RuleEditor` / `WaypointContext` instantiation, the unified `CaptureKind` target-capture system, "Edit Queue…"/"Edit Rules…" entry points + right-click stickman/waypoint context menus, the shared confirmation dialog, and consequence-only rule editing (§23). |
|
||
| `res://scripts/stage_spawner.gd` | `class_name StageSpawner`, `extends RefCounted` — registry-driven factory reusing `TerrainUtils` / `PropUtils` / `StickmanFactory`; exposes `is_terrain_id()` / `get_template_aabb()` / `spawn_id` tagging. `get_template_aabb()` returns the **sanitized** template AABB (mirrors `_spawn_terrain()`'s 16-px grid pass), so it doubles as the block-unit paint stride. **Phase 3b:** registry ids `ground/ramp/step/prop/stickman/area` (separate `crate`/`ball` entries removed); holds the session state `selected_stickman_path` / `selected_prop_id` and a per-path `_stickman_cache`; `prop` and `stickman` spawn the **selected** asset. |
|
||
| `res://scripts/stickman_library.gd` | **Phase 3b** `class_name StickmanLibrary`, `extends RefCounted` — scans `res://stickmen/*.stk` into `{path, name, data}` entry models (corrupt/missing-`body_parts` files skipped, empty `stickman_name` → filename basename), with `make_entry(path)` for arbitrary Browse-chosen paths (§22). |
|
||
| `res://scripts/prop_library.gd` | **Phase 3b** `class_name PropLibrary`, `extends RefCounted` — static registry of the 4 prop templates (Crate/Wood, Ball/Rubber, Plank/Metal, Triangle/Cardboard) with their `PropUtils.create_*()` payloads + material presets; `get_default_id()` = `"crate"` (§22). |
|
||
| `res://scripts/asset_selector.gd` | **Phase 3b** `class_name AssetSelector`, `extends PopupPanel` — grid UI controller (root of `scenes/asset_selector.tscn`): pagination 12/page (4×3), Prev/Next/page label, empty-state label, per-cell thumbnail + name + (prop) material badge, hover styling (no pre-highlight on open), window-resize re-centering, Browse/Refresh/Close wiring, Esc handling; signals `item_selected` / `cancelled` / `browse_requested` / `refresh_requested` (§22). |
|
||
| `res://scripts/stage_selection.gd` | `class_name StageSelection`, `extends RefCounted` — hover/click/box selection via geometric AABB hit-testing. |
|
||
| `res://scripts/stage_gizmos.gd` | `class_name StageGizmos`, `extends Node2D` — hover highlight, selection outline, rotate ring handle. |
|
||
| `res://scripts/stage_grid.gd` | `class_name StageGrid`, `extends Node2D` — optional world-space grid overlay (major line every 5 cells). |
|
||
| `res://scripts/stage_placement_overlay.gd` | **Phase 4b** `class_name StagePlacementOverlay`, `extends Node2D` — world-space overlay drawing the terrain drag-painting guide line + the director action trajectory / ghost marker. |
|
||
| `res://scripts/thumbnails/stickman_thumbnail.gd` | **Phase 3b** `class_name StickmanThumbnail`, `extends Node` — renders a parsed `.stk` into a 200×200 `Texture2D` by spawning the **real rig** (`StickmanFactory.spawn_from_data`) inside an offscreen `SubViewport`, framing it and capturing after a double `frame_post_draw` (§22). |
|
||
| `res://scripts/thumbnails/prop_thumbnail.gd` | **Phase 3b** `class_name PropThumbnail`, `extends Node` — renders a prop template into a 200×200 `Texture2D` via a lightweight `Polygon2D` + `Line2D` visual (no `RigidBody2D`, so no gravity), tinted by the material preset (§22). |
|
||
| `res://scripts/thumbnails/thumbnail_cache.gd` | **Phase 3b** `class_name ThumbnailCache`, `extends RefCounted` — disk PNG cache under `user://thumbnails/`: stickman key = `basename_mtime`, prop key = `id_v<PROP_VERSION>`; `load_png`/`save_png`/`clean_stale_stickmen` (§22). |
|
||
| `res://scenes/asset_selector.tscn` | **Phase 3b** `PopupPanel` root + `asset_selector.gd` — minimal shell/layout skeleton (title bar, empty `GridContainer`, footer Prev/Next/Browse/Refresh/Close); all dynamic per-cell content is built in code at runtime (§22). |
|
||
| `res://sandbox_theme.json` | **Phase 4b** hand-editable styling defaults (font paths/sizes, grid snap default, mode accent colors). **Phase 3c** extends the `fonts` block with style variants (`ui_font_bold`/`ui_font_italic`), per-widget size/style flags, and an optional per-widget `{size, bold, italic}` object form (§21.3 / spec §14). Loaded at `_ready()`; missing/malformed falls back to built-in constants. |
|
||
|
||
**Mode management** — a single **3-segment switcher** `[ ✏️ Edit | 🎬 Direct | ▶️ Play ]` sits at the far left of the top bar (`enum StageMode { EDIT, DIRECT, PLAY }`). Each mode shows a **contextual toolbar** and a **mode badge pill** in the viewport's top-left corner (`✏️ EDIT` cyan, `🎬 DIRECTING` amber, `▶️ SIMULATING` green), sourced from `sandbox_theme.json` `mode_colors`:
|
||
|
||
- **Edit** (default) — spawner palette + Grid/Snap/Size controls visible; the construction **grid** is shown. `RigidBody2D` props are frozen (`freeze = true` + `freeze_mode = FREEZE_MODE_KINEMATIC`), stickmen stand as `ANIMATED` puppets, gizmos are visible, and selection is active. Cursor is a crosshair.
|
||
- **Direct** — internally an **"edit-with-direct"** state: props frozen / stickmen standing / gizmos enabled (same as Edit) so a director can click stickmen and build action queues and rules, but the spawner/grid controls are hidden and the toolbar shows only a hint label (`Click a stickman to direct, or ⚡ When… for rules`). The **grid is hidden entirely**, and a thin **amber viewfinder frame** borders the viewport (a full-screen `MOUSE_FILTER_IGNORE` panel). Cursor is a crosshair.
|
||
- **Play** — layout tools and the grid hidden; props unfreeze and fall, and each stickman's action queue runs (`start_queue()`, `auto_recover = false`). Gizmos are hidden, selection is cleared, and the mode switcher is the only toolbar control. Cursor is the default arrow.
|
||
- The mode switcher no-ops when the mode is unchanged (`set_mode()` guard); a `mode_changed(mode: int)` signal carries the new value (`0`/`1`/`2`) on every real switch, and the camera view persists across switches. **Esc** exits Direct → Edit.
|
||
- **Per-mode cursor** — `_apply_cursor()` sets `CURSOR_CROSS` for Edit/Direct and `CURSOR_ARROW` for Play, and swaps to a runtime-generated amber **flag/reticle** custom cursor whenever a click-awaiting director step is active (§21); a generated `Image`/`ImageTexture` (no asset file needed).
|
||
- **Restart-the-sim:** each object's position/rotation is saved whenever you place, move, or rotate it; returning to Edit restores that authored state (and zeroes prop velocity), so every Play session starts from the same authored layout. Stickmen snap straight back to standing (no stand-up glide).
|
||
|
||
**Spawn palette** (text buttons built from the spawner registry — Ground / Ramp / Step / Prop / Stickman / Area):
|
||
|
||
- **Ground / Ramp / Step** — `TerrainBlock` terrain. Single **terrain** palette items are **drag-painted** (§21): left-click-and-drag paints a staircase run of blocks; a terrain drag commits on release. Non-terrain (props/stickman/area) palette items keep the Phase 2 **single-click repeated placement**.
|
||
- **Prop** (Phase 3b) — opens a **selector grid** (§22) of the 4 prop templates; selecting one sets the `selected_prop_id` and enters placement mode spawning that `PropBlock` (`PropUtils.create_*()` + the matching material preset). Replaces the earlier separate **Crate** / **Ball** buttons.
|
||
- **Stickman** (Phase 3b) — opens a **selector grid** (§22) of every `.stk` in `res://stickmen/`; selecting one sets the `selected_stickman_path` and enters placement mode spawning a `StickmanRig` from that file (offset `(0, -385)` so the feet land on the cursor). Replaces the previous hard-coded single-`test.stk` placement.
|
||
- **Area** (Phase 4) — a `TriggerArea` sensor.
|
||
|
||
Clicking a palette button enters **placement mode**, which shows a translucent **ghost** of the object under the cursor (snapped to the grid when Snap is on); the stickman ghost is a static standing figure. **Left-click** keeps the Phase 2 repeated-placement behavior (each click/drag commits one placement and the tool stays active); **right-click** (or **Esc**) is the explicit **"put the tool down"** gesture — it cancels any in-progress drag, leaves already-placed cells if a drag had committed, and un-toggles the palette button. Each placement emits `object_placed(node)`. The **Stickman** and **Prop** buttons open a selector grid first (a modal `PopupPanel`, §22) and only enter placement mode once an asset is chosen.
|
||
|
||
**Selection & gizmos (Edit only):**
|
||
|
||
- **Hover** highlights the object under the cursor (subtle outline).
|
||
- **Left-click** selects an object (deselects the previous); click empty space to deselect.
|
||
- **Direct drag** — click and drag an object to move it (no separate move handle); hold **Shift** while clicking to toggle its selection.
|
||
- **Box-select** — drag on empty space draws a marquee; release selects everything inside. Hold **Shift** to add to the selection.
|
||
- Every selected object shows a white bounding box. A blue **rotate** ring appears around a **single** selection (drag it to rotate about the center); it is hidden for multi-selections. Hold **Ctrl** while rotating to snap to 15° increments.
|
||
- **Multi-translate** — dragging any selected object moves the whole selection together. The ring is hit-tested first, so ring clicks rotate rather than move.
|
||
- `object_selected(nodes)` / `object_deselected()` are emitted on selection changes.
|
||
|
||
**Grid & snap:**
|
||
|
||
- A **Grid** checkbox toggles a world-space grid overlay (major line every 5 cells); a **Snap** checkbox rounds placement and dragging to the grid; a **Size** spinbox sets the cell size (1–100 px).
|
||
- The grid shows **only in Edit** mode — it is hidden entirely in **Direct** and **Play** (Phase 4b).
|
||
- Grid size, snap, and grid visibility persist to `user://sandbox_settings.json`. On first run the **initial default** grid size seeds from `sandbox_theme.json` `grid.snap_size` (15.0).
|
||
|
||
**Deletion & camera:**
|
||
|
||
- **Delete** / **Backspace** removes all selected objects (`queue_free()`) and emits `object_deleted(nodes)`. Deleting also clears the hover highlight (`StageSelection.clear_hover()`), so a queued-for-deletion node no longer leaves a stale yellow hover box behind.
|
||
- **Middle-mouse drag** pans; **mouse wheel** zooms within the exported `min_zoom` (0.1) / `max_zoom` (6.0) bounds. **Touchpad**: pinch to zoom, two-finger drag to pan. The view persists across mode toggles.
|
||
|
||
**Status bar (bottom):** a bottom-anchored 28 px bar mirrors the stickman editor's `StatusBar` pattern. The **left** label carries the live status text (`Objects: N | Selected: <name or count>`, plus any pending-walk / rule-builder / toast hints — the `Mode:` prefix moved to the badge pill). The **right** label shows live **mouse world coordinates** as `X: ### Y: ###`, polled each frame from `_camera.get_global_mouse_position()` (world-space, so the numbers pan/zoom with the view).
|
||
|
||
> **Extendability contract:** the spawner uses a `Dictionary` registry (no hard-coded `match` on ids), the `World` container accepts any `Node2D`, gizmos work on any object via `global_position`/`global_rotation`, and the root exposes `mode_changed` / `object_placed` / `object_selected` / `object_deselected` / `object_deleted` signals — all hooks for the future Action Queue, Trigger, and Save/Load phases. Ramps/stairs can be placed and props/ragdolls will slide on them, and (Phase 3a) stickmen now walk up/down them via `NavigationAgent2D` — see §19.
|
||
|
||
### 19. Director Tool (Phase 3a)
|
||
|
||
The **Director Tool** (Phase 3a) turns the Sandbox Stage into a mini director's workspace: click a stickman, choose actions from a popup, see the script as waypoints/badges in Edit mode, then press **Play** to run every stickman's action queue. It is **not wired into the editor** — run via **F6** on `res://scenes/sandbox_stage.tscn`.
|
||
|
||
| File | Purpose |
|
||
|---|---|
|
||
| `res://scripts/stickman_rig.gd` | Extended with navigation/walking, speech, an action queue, and the queue runner state machine (see API below). |
|
||
| `res://scripts/stickman_speech_bubble.gd` | `class_name SpeechBubble`, `extends Node2D` — a world-space speech bubble drawn via `_draw()` (`ThemeDB.fallback_font`), a child of the rig above the head. |
|
||
| `res://scripts/stage_director_visuals.gd` | `class_name StageDirectorVisuals`, `extends Node2D` — Edit-mode director overlay (waypoint dots, dashed connectors, action badges, order numbers); hidden in Play. **Phase 3c** adds `hit_test_waypoint_action()` (rig/index/pos), `set_edit_waypoint()`/`clear_edit_waypoint()` with the pulsing edit highlight for visual walk re-placement, and rule-label → editor routing (§23). |
|
||
| `res://scripts/sandbox_stage.gd` | Extended with the **Direct** palette button, the action popup + speak/wait dialogs, a code-built `NavigationRegion2D` re-baked on terrain edits, and Play mode now starting each stickman's queue. |
|
||
|
||
**Direct tool workflow (Edit):**
|
||
|
||
1. Press the **Direct** toggle button (mutually exclusive with palette placement). A status hint prompts "Click a stickman".
|
||
2. **Left-click a stickman** → an action popup opens to the **right of the clicked stickman** (its world position converted to screen, offset 24 px) with **Walk To / Speak / Wait / Ragdoll / Recover**.
|
||
3. Choose an action:
|
||
- **Walk To** → enters pending mode; the **next left-click on the stage** appends `{"type":"walk_to","target":click_pos}`. **Esc** cancels the pending target.
|
||
- **Speak** → a text dialog (`AcceptDialog` + `LineEdit`); confirms append `{"type":"speak","text":...,"duration":2.0}`.
|
||
- **Wait** → a duration dialog (`SpinBox`, 0.1–10 s); confirms append `{"type":"wait","duration":...}`.
|
||
- **Ragdoll / Recover** → append `{"type":"ragdoll"}` / `{"type":"recover"}` immediately.
|
||
4. Waypoints/badges update immediately (`queue_changed` → dirty flag). **Esc** exits Direct mode.
|
||
|
||
**Action queue semantics:**
|
||
|
||
- Each `StickmanRig` owns its own `action_queue: Array[Dictionary]`; index order = execution order = visual order (no separate ids). Unknown `type` → `push_warning` + the action is skipped (treated as completed).
|
||
- Action shapes (the `type` key is the discriminator):
|
||
|
||
| type | required keys | optional keys |
|
||
|---|---|---|
|
||
| `"walk_to"` | `"target": Vector2` (feet destination) | `"speed": float` |
|
||
| `"speak"` | `"text": String` | `"duration": float` |
|
||
| `"wait"` | `"duration": float` | — |
|
||
| `"ragdoll"` | — | — |
|
||
| `"recover"` | — | — |
|
||
|
||
- Queues are **in-memory only** in 3a (no serialization; lost on scene reload).
|
||
|
||
**Waypoint & badge visuals (Edit only):** `StageDirectorVisuals` reads each rig's queue and, per action in order, draws a **blue waypoint dot** (white outline, order number) at each `walk_to` target, **dashed connectors** between consecutive dots (and from the rig's current feet position to the first dot), and **badges** (speech bubble / clock / X / up-arrow glyph + order number) for non-walk actions anchored to the **stickman's position at that point in the sequence** — the position derived by simulating the queue (start at the rig's feet; each `walk_to` advances the anchor; a non-walk action anchors at the position when it is reached). Consecutive badges at the same point stack upward. All sizes divide by the camera zoom so markers stay screen-constant. Pure `_draw()` — no hit-testing. Hidden in Play.
|
||
|
||
**Play execution:**
|
||
|
||
- **Play mode now runs the director script** — stickmen stay **ANIMATED** and each rig's `start_queue()` is called (previously they auto-ragdolled on Play). `ragdoll` / `recover` are now **explicit queue actions**; a stickman only falls when directed. Props still unfreeze and tumble (and can knock a *directed* ragdoll). `auto_recover = false` in Play (the director owns recovery).
|
||
- On **return to Edit** (or Direct): each stickman `stop_queue()` then `snap_to_standing()`, and `clear_reactive_actions()` strips any rule-injected (`reactive`) actions appended during the Play session — so reactive waypoint/badge markers do not accumulate across runs, while the authored sequential queue still replays. The waypoint overlay is then re-enabled.
|
||
- Multiple stickmen act simultaneously and independently (per-rig queues + per-rig runners, no shared state).
|
||
|
||
**Nav-mesh behavior:**
|
||
|
||
- A code-built `NavigationRegion2D` (child of the stage, not `World`, so it is never hit-tested) carries a procedural `NavigationPolygon` generated by per-`TerrainBlock` convex decomposition (`Geometry2D.decompose_polygon_in_convex` + fan triangulation) of each block's world-space polygon — robust to concavity and rotation.
|
||
- **Auto re-bake** on any terrain edit: the nav mesh is marked dirty on terrain place / move / rotate (`transform_committed`) / delete and re-baked once per frame (coalescing bursts) in `_process`.
|
||
- Each stickman's `NavigationAgent2D` is a child of the rig **at the feet** (local `(0, +385)` = `-FOOT_OFFSET`), so it sits on the ground-level mesh; agent and region share the default navigation map, layer 1.
|
||
- `walk_to(target)` treats `target` as a **feet/ground destination**; the rig converts ground-level path points back to root positions with `FOOT_OFFSET := (0, -385)`. Walking is **hybrid**: an **on-mesh target** follows the nav path (`_walk_mode = "nav"`), while an **off-mesh / unreachable target** (empty path **or** `is_target_reachable() == false`) switches to **direct straight-line steering** toward the clicked waypoint (`_walk_mode = "direct"`, root target = waypoint + `FOOT_OFFSET`) — it is **not** rejected with a warning and the rig does **not** stand still. Nav reads are deferred until the map has synced (`map_get_iteration_id(...) != 0`) and the path query is forced via `get_next_path_position()` before any reachability/finished check, so a fresh target is never misjudged as finished/unreachable after a single step.
|
||
- Pathing is kinematic (`global_position.move_toward`); `avoidance_enabled = false` in 3a, so stickmen path through props and each other (deferred — see `docs/tech_debt_and_optimizations.md` #13).
|
||
- **Walk/nav debugging (off by default):** `const DEBUG_WALK` in `stickman_rig.gd` (per-frame walk trace with `mode=nav|direct`, plus one-shot `walk_to`/finish/cancel prints) and `const DEBUG_STAGE` in `sandbox_stage.gd` (`[stage]` target capture, `[nav]` baked verts/polys, `[stage] PLAY rigs`).
|
||
|
||
**StickmanRig director API surface:**
|
||
|
||
| Member | Signature | Behavior |
|
||
|---|---|---|
|
||
| `RunnerState` / `ActionPhase` | `enum { IDLE, EXECUTING }` / `enum { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }` | The queue-runner state machine advanced in `_physics_process`. |
|
||
| `FOOT_OFFSET` | `const := Vector2(0.0, -385.0)` | Feet → root (ground point → hips). |
|
||
| `walk_speed` | `@export var walk_speed: float = 300.0` | Kinematic walk speed. |
|
||
| `walk_to` | `func walk_to(target: Vector2, speed: float = -1.0) -> void` | Start walking so the feet land at `target`; no-op unless `state == ANIMATED`. |
|
||
| `is_walking` | `func is_walking() -> bool` | Whether a walk is in progress. |
|
||
| `speak` | `func speak(text: String, duration: float) -> void` | Show a `SpeechBubble` for `duration` s; auto-hides + emits `speech_finished`. |
|
||
| `queue_action` / `clear_queue` / `get_queue` / `remove_action` / `insert_action` / `queue_size` | — | The mutable action queue; all mutations emit `queue_changed`. |
|
||
| `clear_reactive_actions` | `func clear_reactive_actions() -> void` | Drops rule-injected (`reactive`-tagged) actions, restoring the authored sequential queue; no-op while the runner is `EXECUTING` (callers invoke it on mode exit after `stop_queue()`). |
|
||
| `start_queue` / `stop_queue` / `is_queue_running` | — | Runner control. `stop_queue()` aborts without emitting `queue_finished`. |
|
||
| `is_ragdoll_at_rest` | `func is_ragdoll_at_rest() -> bool` | Whether the ragdoll has rested (independent of `auto_recover`); the runner waits on it for the `ragdoll` action. |
|
||
| `arrived` | `signal arrived` | `walk_to` reached its destination. |
|
||
| `action_started` / `action_finished` | `signal(action: Dictionary, index: int)` | Emitted per action as the runner begins/completes it. |
|
||
| `queue_finished` | `signal queue_finished` | The queue ran to completion (not on stop). |
|
||
| `queue_changed` | `signal queue_changed` | Any queue mutation — drives the visuals dirty flag. |
|
||
| `speech_finished` | `signal speech_finished` | The speech bubble auto-hid after `speak()`. |
|
||
|
||
`recover` waits for `state == RigState.ANIMATED` via the existing `state_changed` signal; `_enter_ragdoll()` calls `_cancel_walking()` so a ragdolled rig has no stale walk/path state.
|
||
|
||
### 20. Triggers & Event System (Phase 4)
|
||
|
||
**Phase 4** adds **reactive storytelling** to the Sandbox Stage. While Phase 3a gave directors *sequential* control (actions in a fixed order per stickman), Phase 4 adds *reactive* control — **"When X happens, do Y"** — via event rules that span objects. It is **not wired into the editor** — run via **F6** on `res://scenes/sandbox_stage.tscn`.
|
||
|
||
The rule concept is **When → Then**: a *trigger* (an event on some object) fires a list of *actions* on a target object. Rules are stored as `_event_rules: Array[Dictionary]` of `{id, trigger, actions}` in `sandbox_stage.gd`, where `trigger` is `{type, target, ...}` and `actions` is an array of Phase 3a action dicts (`{"type":"walk_to","target":...}`, etc.).
|
||
|
||
| File | Purpose |
|
||
|---|---|
|
||
| `res://scripts/trigger_area.gd` | NEW `class_name TriggerArea`, `extends Node2D` — a placeable sensor (`@export size: Vector2`, default 96×96) with `get_area_rect() -> Rect2` and a translucent green `_draw()` fill + dashed border. **No physics, no signals** — it is a pure geometric region evaluated by the stage. |
|
||
| `res://scripts/sandbox_stage.gd` | Extended with the rule system: `_event_rules`, the geometric event engine (`_update_area_entry`, `_update_stickman_prop_collision`), the rule-builder UI state machine, and rule auto-cleanup. |
|
||
| `res://scripts/stickman_rig.gd` | `arrived` gained a `target: Vector2` payload; new `enqueue_reactive(actions)` appends reactive actions without replaying the queue. |
|
||
| `res://scripts/prop_block.gd` | NEW `signal collided(other: Node)` (physics-based, prop-vs-prop). |
|
||
| `res://scripts/stage_director_visuals.gd` | Extended with rule visualization + `set_rules()` / `hit_test_rule()` / `hit_test_waypoint()`. |
|
||
| `res://scripts/stage_spawner.gd` | New `"area"` palette registry entry (label **"Area"**); duck-typed `get_area_rect` AABB branch in `get_world_aabb`. |
|
||
| `res://scripts/stage_selection.gd` | Same duck-typed `get_area_rect` AABB branch for selecting/clicking trigger areas. |
|
||
|
||
**Trigger types:**
|
||
|
||
| Trigger type | Target | Fires when |
|
||
|---|---|---|
|
||
| `arrived_at_waypoint` | Stickman | The stickman completes a `walk_to` (the rig's `arrived` signal). |
|
||
| `action_finished` | Stickman | A queue action completes (the rig's `action_finished` signal). |
|
||
| `speech_finished` | Stickman | A `speak` bubble auto-hides (the rig's `speech_finished` signal). |
|
||
| `entered_area` | Trigger area | A movable (stickman/prop) enters the area's `get_area_rect()` (geometric, `_update_area_entry`). |
|
||
| `collided` | Stickman/Prop | A stickman's feet point enters another object's AABB (geometric, `_update_stickman_prop_collision`) **or** a prop physically collides with another prop (`PropBlock.collided` physics signal). |
|
||
|
||
**Action types** are the Phase 3a set, reused verbatim: `walk_to`, `speak`, `wait`, `ragdoll`, `recover`. Actions always target a **stickman**.
|
||
|
||
**Rule-building workflow (Edit mode):**
|
||
|
||
1. Press the **Direct** toggle button (mutually exclusive with palette placement) and click a stickman.
|
||
2. The Direct action popup now has a **"⚡ When..."** item (in addition to Walk To / Speak / Wait / Ragdoll / Recover).
|
||
3. Choose **"⚡ When..."** → a trigger sub-menu popup (arrived / action finished / speech finished / entered area / collided).
|
||
4. Pick the **trigger target** (click a stickman, waypoint, trigger area, or prop depending on trigger type).
|
||
5. Choose an **action** from a rule-action popup, then optionally its **target/position** (e.g. click a waypoint for `walk_to`).
|
||
6. A popup offers **"Add another action"** (repeat the action step) or **"Done"** to commit the rule.
|
||
7. **Esc** is the highest-priority cancel at any step. The rule-builder is a state machine (`RuleStep` enum: `IDLE`, `SELECT_TRIGGER`, `TRIGGER_TARGET`, `SELECT_ACTION`, `ACTION_TARGET`, `ACTION_POSITION`, `PARAMS`) with status-bar hints and toast messages.
|
||
8. Choosing **"⬅ Back to actions"** in the trigger sub-menu does **not** cancel the rule flow — it resets the rule builder and re-opens the stickman's action popup (the context rig is preserved), so you can back out of building a rule and pick a sequential action instead.
|
||
|
||
All rule-builder context menus are **session-anchored**: the first popup in a flow (the action popup right of the clicked stickman, or the rule-label edit entry at the click) records its screen position, and every child popup (trigger sub-menu, rule-action popup, "⬅ Back to actions", "Add another action") reopens at that same recorded position until the rule is confirmed or the flow is cancelled — so cycling menus never walks down the screen.
|
||
|
||
**Rule visualization (Edit only):** `StageDirectorVisuals` draws each rule as a **dashed white connector line** from the trigger object to the target, a **green ⚡ trigger badge**, an **orange → action badge**, a dark label with `rule_summary()` text, and a **✕ delete icon**. Clicking the **rule label** reopens the rule for **consequence-only editing** (replaces the rule, keeping the same `id`); clicking the **✕** deletes the rule. Rules are auto-cleaned (`_cleanup_rules_for_nodes`) when any referenced object is deleted.
|
||
|
||
**Trigger area placement:** the spawn palette gains an **"Area"** entry (from the `"area"` registry id). Placement works like any other palette item (translucent ghost, grid snap, click to place). Trigger areas are selectable/movable like other objects via the duck-typed `get_area_rect` AABB branch in `stage_selection.gd` / `stage_spawner.gd`.
|
||
|
||
**`enqueue_reactive` semantics:** `StickmanRig.enqueue_reactive(actions: Array[Dictionary]) -> void` appends reactive actions to the rig's action queue, **tagging each with `reactive = true`**. If the runner is `IDLE` it **resumes at the first newly-appended action** — the already-consumed prefix of the queue is not replayed. Sequential Phase 3a queues are untouched; reactive actions are a cross-object addition. Because they are tagged, `clear_reactive_actions()` can strip them on return to Edit/Direct (see §19), so reactive actions injected across multiple Play sessions never accumulate on top of the authored sequential queue.
|
||
|
||
**Prop collision detection (two mechanisms):**
|
||
|
||
- **Geometric (stage-driven):** `_update_stickman_prop_collision` tests whether a stickman's **feet point** lies inside another object's world AABB (and vice-versa), edge-triggered so `collided` fires once per entry.
|
||
- **Physics (self-driven):** `PropBlock` enables `contact_monitor = true`, `max_contacts_reported = 8`, and connects the guarded `body_entered` signal → `_on_body_entered` → emits `collided(other)`, so **prop-vs-prop** collisions are detected by the physics engine rather than geometry.
|
||
|
||
Edge-triggered dictionaries (which events already fired) are reset on Play/Edit mode entry so a fresh simulation run re-arms every rule.
|
||
|
||
**Persistence scope:** rules are **in-memory only** — they persist across Play/Edit mode toggles (the stage keeps `_event_rules` alive) but are **not** saved to disk. Disk save is deferred to a future phase.
|
||
|
||
**Deferred (Phase 5 and beyond):** `explode_prop` / `spawn_prop` action types, rule **conditions** and AND/OR combinators, rule **variables**, and **disk save** of rules are explicitly out of scope for Phase 4.
|
||
|
||
### 21. Phase 4b Polish — Mode Switcher, Theme, Terrain Painting, Director UX, Walk Jitter Fix
|
||
|
||
**Phase 4b** is a **polish + bugfix** pass over the Sandbox Stage Builder. It does **not** add new gameplay systems; it restructures the top bar into a **3-segment mode switcher** with contextual toolbars, adds a **bottom status bar** with live mouse coordinates, a **mode badge/frame** and per-mode cursors, upgrades **terrain placement** from single-click into **drag-to-paint** with a grid spatial dictionary, makes the **director's "pick a target" flows** kid-friendly (cursor-attached tooltip, rubber-band trajectory, reticle cursor), introduces a hand-editable **`res://sandbox_theme.json`**, and fixes the **walk-waypoint arrival jitter**. Run via **F6** on `res://scenes/sandbox_stage.tscn`; not wired into the editor. See §18 for the mode-switcher / status-bar / badge / cursor surface; this section details the placement, styling, director-UX, and bugfix internals.
|
||
|
||
#### 21.1 Terrain drag-painting (Edit mode)
|
||
|
||
Terrain palette items (Ground/Ramp/Step) replace single-click placement with a **drag-to-paint "drawing" workflow**. Drag-painting quantizes to **block units**, not 16-px cells. The block-unit stride is the active terrain template's **sanitized AABB extent** (`StageSpawner.get_template_aabb(id).size`, computed over the same 16-px terrain-grid sanitize pass `_spawn_terrain()` actually places):
|
||
|
||
| Template | Sanitized stride |
|
||
|---|---|
|
||
| Ground | **192 × 32** |
|
||
| Ramp | **192 × 128** |
|
||
| Step | **256 × 256** |
|
||
|
||
Block centers are `block_cell * stride`, and Bresenham runs over block units. A **cursor-following translucent placement ghost** (block-unit snapped) shows the block the next paint would stamp — it is **freed while a drag is in progress** and **re-armed after each commit** (preserving LMB repeated placement); **RMB** / **Esc** puts the tool down.
|
||
|
||
1. **Select** a terrain palette item. Pressing **LMB** on the stage sets a fixed **anchor block unit**; dragging updates a **target block unit** and computes the ordered run between them.
|
||
2. **Shift** locks the trajectory to a **cardinal axis** — if `|dx| >= |dy|` the y-delta is zeroed, else the x-delta (a pure 0°/90°/180°/270° run, no diagonals). Re-evaluated per motion event.
|
||
3. A high-contrast **dashed guide line** (color from `sandbox_theme.json` `mode_colors.guide_line`, default `#22c6ff`) draws from the anchor to the target block unit via the `StagePlacementOverlay` (a world-space `Node2D` sibling of the grid/gizmos). It appears **only during a real drag** (anchor ≠ target) and disappears **instantly** on release / **Esc** / **RMB** — a single-click placement never draws the guide line or guide circles.
|
||
4. The run uses **Bresenham's line algorithm** over block units. Along a horizontal/vertical run (including Shift-locked) blocks tile **edge-to-edge — no overlap, no gaps**; along a free diagonal they tile **corner-to-corner** (adjacent diagonal blocks share exactly a corner point — zero overlap, visually acceptable corner gaps). Each block unit is classified against the **grid spatial dictionary** into one of three states, and the per-block ghosts are tinted accordingly:
|
||
- **1 – empty** → **green** ghost → instantiated on release.
|
||
- **2 – occupied by the same block type** (same registry `spawn_id`, e.g. another `ground`) → neutral/transparent ghost → **skipped** on release (no double-create / no z-fight). Freshly painted in-drag blocks are marked so a drag crossing its own path skips re-stamping.
|
||
- **3 – occupied by a different/conflicting object** (prop/stickman/area/another terrain type) → **muted-red** ghost → **skipped** on release.
|
||
5. **Release** commits the batch **atomically**: only the "empty" block units spawn (all in one frame), the nav mesh is marked dirty **once** (`_nav_dirty = true`, not per node), the grid dictionary is rebuilt, and `object_placed(node)` fires per node. LMB keeps repeated placement active for the next drag (the placement ghost re-arms).
|
||
6. **RMB** (or **Esc**) ends draw/placement mode — it cancels an in-progress drag (nothing is placed until release), clears the guide line, and un-toggles the palette button and restores the cursor.
|
||
|
||
**Grid spatial dictionary** (`_grid_cells`): a runtime `Dictionary` on `SandboxStage` keyed by 16-px grid-cell `Vector2i` (`StageSpawner.TERRAIN_GRID_SIZE`) → `Array[Node2D]`. It is **advisory only** — it drives the 3-state occupancy query (green/neutral/red tint + skip) but is **never authoritative for physics** or for the block-unit paint stride; the `World` tree is the source of truth. Occupancy marks **all 16-px cells covered by the node's world AABB** via `_rasterize_aabb_to_cells()` (for props/stickmen/areas it rasterizes `StageSelection.get_world_aabb()`), so one Ground block (192×32) spans ≈ 12×2 dictionary cells even though it paints as a single block unit. It is populated on place, updated on move/rotate/delete, and rebuilt on grid-size change. Terrain nodes carry a `spawn_id` string (`TerrainBlock.spawn_id`, set by `StageSpawner.spawn_id`) so same-template overlaps are detectable; `StageSpawner.is_terrain_id()` distinguishes terrain registry entries and `get_template_aabb(id)` exposes a terrain template's **sanitized** local AABB for ghost sizing / block-unit stride / cell rasterization.
|
||
|
||
#### 21.2 Director targeting UX (Direct / rule-building)
|
||
|
||
While a click-awaiting director step is active — a pending **Walk To** target, or the **"When…"** rule steps (`TRIGGER_TARGET` / `ACTION_TARGET` / `ACTION_POSITION`) — the stage shows a combined workflow:
|
||
|
||
- **Reticle cursor** — `_apply_cursor()` swaps to a runtime-generated amber **flag/reticle** cursor (`Image.create` + `ImageTexture`, no asset file) whenever `_is_awaiting_click()` is true.
|
||
- **Cursor-attached floating tooltip pill** — a dark, rounded high-contrast label following the cursor (offset ~20 px, flipping near the screen edges) reading e.g. `🚩 Click to set walk target`, `🎯 Click the trigger area`, `💥 Click the prop`, etc. (`_action_hint_text()`). Esc or cancel clears it.
|
||
- **Rubber-band dashed trajectory** + **ghost marker** — drawn by `StagePlacementOverlay` from the action's origin (the stickman's feet for a walk/rule action, or the trigger/action anchor node for the "When…" flows) to the cursor; **green** when the target is valid and **red** when invalid (the point lies inside a `TerrainBlock` AABB, via the grid dictionary). A semi-transparent flag/ring + crosshair ghost marker sits at the target, grid-snapped when Snap is on.
|
||
|
||
#### 21.3 `res://sandbox_theme.json` (styling defaults)
|
||
|
||
A single committed, hand-editable JSON config drives sandbox font/size/color/grid defaults. It loads in `_ready()` before `_build_ui()`; a missing or malformed file logs one `push_warning` and uses all built-in constants (never crashes); unknown extra keys are ignored; a referenced font that does not exist falls back to `ThemeDB.fallback_font` with a warning. The **live** grid size/snap/grid-visibility remain persisted in `user://sandbox_settings.json` (runtime source of truth); the theme supplies the **initial default** grid size on first run. Schema:
|
||
|
||
```json
|
||
{
|
||
"version": "1.0",
|
||
"fonts": {
|
||
"ui_font": "",
|
||
"emoji_font": "",
|
||
"ui_font_bold": "",
|
||
"ui_font_italic": "",
|
||
"action_popup_font_size": 24,
|
||
"action_popup_emoji_size": 22,
|
||
"assignment_badge_font_size": 20,
|
||
"assignment_badge_radius": 9,
|
||
"rule_label_font_size": 16,
|
||
"status_pill_font_size": 16,
|
||
"tooltip_font_size": 18,
|
||
"queue_panel_font_size": 18,
|
||
"rule_panel_font_size": 18,
|
||
"action_editor_font_size": 18,
|
||
"rule_editor_font_size": 18,
|
||
"panel_row_font_size": 16,
|
||
"panel_title_font_size": 18,
|
||
"panel_title_bold": true,
|
||
"rule_label_bold": false,
|
||
"badge_bold": true,
|
||
"action_popup": { "size": 24, "bold": false, "italic": false }
|
||
},
|
||
"grid": {
|
||
"snap_size": 15.0
|
||
},
|
||
"mode_colors": {
|
||
"edit_accent": "#22c6ff",
|
||
"direct_accent": "#ffb300",
|
||
"play_accent": "#33dd77",
|
||
"guide_line": "#22c6ff"
|
||
}
|
||
}
|
||
```
|
||
|
||
| Key | Default | Consumed by |
|
||
|---|---|---|
|
||
| `fonts.ui_font` / `fonts.emoji_font` | `""` (fallback font) | `res://` font paths; empty/missing → `ThemeDB.fallback_font`. `emoji_font` is also pushed to `StageDirectorVisuals.emoji_font` and the director popups. |
|
||
| `fonts.ui_font_bold` / `fonts.ui_font_italic` | `""` (fallback to `ui_font`) | **Phase 3c** style-variant font paths for bold/italic; empty → `ui_font`. Bold/italic are realised via a dedicated `FontVariation` (e.g. `variation_embolden`, OpenType slant) when no separate file is configured. |
|
||
| `fonts.action_popup_font_size` / `action_popup_emoji_size` | 24 / 22 | Font size override on the director action/trigger/rule popups. **Phase 3c:** `action_popup_emoji_size` is now **consumed** — applied as the popup menu's emoji-glyph font size when a popup font is configured (`_apply_popup_theme()`). |
|
||
| `fonts.action_popup` (optional object) | `{ "size": 24, "bold": false, "italic": false }` | **Phase 3c** per-widget object form for the popups; when present, overrides the flat `action_popup_font_size` / bold keys for that widget. |
|
||
| `fonts.assignment_badge_font_size` / `assignment_badge_radius` | 20 / 9 | Replaces `StageDirectorVisuals` `ICON_SIZE_PX` / `RULE_BADGE_RADIUS_PX` (and order-number size) via `set_style(cfg)`. |
|
||
| `fonts.rule_label_font_size` | 16 | Replaces `StageDirectorVisuals.RULE_LABEL_FONT_SIZE_PX`. **Phase 3c** `fonts.rule_label_bold` (default `false`) adds the bold flag; `fonts.badge_bold` (default `true`) bolds the action/trigger badges. |
|
||
| `fonts.status_pill_font_size` / `tooltip_font_size` | 16 / 18 | The mode badge pill and the cursor-attached action tooltip. |
|
||
| `fonts.queue_panel_font_size` / `rule_panel_font_size` | 18 / 18 | **Phase 3c** font sizes for the Queue / Rule panel bodies; fall back to `action_popup_font_size`. |
|
||
| `fonts.action_editor_font_size` / `rule_editor_font_size` | 18 / 18 | **Phase 3c** font sizes for the Action / Rule editors; fall back to `action_popup_font_size`. |
|
||
| `fonts.panel_row_font_size` / `panel_title_font_size` | 16 / 18 | **Phase 3c** per-row summary/number label and panel title label sizes; `fonts.panel_title_bold` (default `true`) bolds panel titles. |
|
||
| `grid.snap_size` | 15.0 | Initial default grid size for the Size spinbox (first run). |
|
||
| `mode_colors.edit_accent` / `direct_accent` / `play_accent` | `#22c6ff` / `#ffb300` / `#33dd77` | Mode badge pill bg, the Direct viewfinder frame, the active mode-segment text, and tooltip border. |
|
||
| `mode_colors.guide_line` | `#22c6ff` | The terrain drag-painting dashed guide line (`StagePlacementOverlay.guide_line_color`). |
|
||
|
||
`StageDirectorVisuals.set_style(cfg)` applies the `fonts` keys onto instance vars (`badge_icon_size`, `badge_number_size`, `badge_radius`, `rule_label_font_size`) whose defaults equal the old constants, so behavior is unchanged when no theme is present.
|
||
|
||
**Phase 3c theming (font styles/sizes):** the Phase 3c widgets now accept an `apply_font(ui_font, emoji_font, sizes)` call — `QueuePanel`, `RulePanel`, `ActionEditor`, and `RuleEditor` (see §23) each expose it (mirroring `AssetSelector.apply_font`), and `SandboxStage._build_ui()` invokes it **after** `add_child(...)`. Per-widget sizes come from the `*_panel_font_size` / `*_editor_font_size` / `panel_row_font_size` / `panel_title_font_size` keys; bold/italic style flags (`panel_title_bold`, `rule_label_bold`, `badge_bold`) and a widget's optional `{size, bold, italic}` object form are applied via the `ui_font_bold` / `ui_font_italic` `FontVariation`-derived font. The `PopupMenu`s (rig/waypoint context) keep `_apply_popup_theme()`, extended to honour `action_popup_emoji_size` and the `action_popup` object form. Authoritative schema: `docs/phase_3c_editor_spec.md` §14.
|
||
|
||
**Director-context rule-connector refresh (bugfix):** `SandboxStage._on_transform_committed()` now calls `StageDirectorVisuals.mark_dirty()` after a move/rotate, so **translating a `TriggerArea` (or any rule-anchoring object) moves its dashed connector and ⚡/→ badges** to the new position on drag end. (Rule anchors were already computed live each `_draw()`; the missing `mark_dirty()` was leaving them stale because a `_draw()` never ran.) Deleting a referenced area already triggers `_cleanup_rules_for_nodes → set_rules → mark_dirty`.
|
||
|
||
#### 21.4 Walk-waypoint arrival jitter fix (`StickmanRig`)
|
||
|
||
The Phase 3a walk could **jitter up/down at a waypoint** instead of stopping. Root cause: `_update_walking()` re-computed `_walk_mode` (`"nav"` vs `"direct"`) from `is_target_reachable()` **every physics frame**, and a waypoint near the nav-mesh boundary could flip that reachability, swapping `root_target` between the nav path point and the raw waypoint — two targets with a small vertical offset. Fix (all in `stickman_rig.gd`):
|
||
|
||
1. **Mode latch once per walk.** After the map syncs, `_update_walking()` probes for up to `LATCH_PROBE_MAX_FRAMES` (**6**): it latches `"nav"` as soon as the agent reports the target reachable, or latches `"direct"` when the probe bound is reached (genuinely off-mesh). It is never re-evaluated mid-walk, so the rig cannot oscillate between two targets.
|
||
2. **Unified arrival radius against the FINAL target.** Both branches check `global_position.distance_to(_walk_target_feet + FOOT_OFFSET) <= ARRIVE_DISTANCE` (8 px root-space), then **snap** `global_position` onto the final target before `_finish_walk("arrive")` — removing any residual offset. The `"nav"` branch also terminates via **nav-finish**: when `is_navigation_finished()` reports the agent at the path's end **and** the rig is within `2 × ARRIVE_DISTANCE` of the final target, it snaps to the final target and finishes — so an on-mesh waypoint whose final path point sits just outside the 8 px radius still stops dead-on instead of drifting. (The Phase 3a *unconditional* `is_navigation_finished()`-at-12 px finish path is gone; nav-finish now fires only when already close.)
|
||
3. **Steer to the final target when close.** In the `"nav"` branch, when within `2 × ARRIVE_DISTANCE` of the final target the rig moves **straight at** it, ignoring a possibly behind-path `next_feet` point (prevents reversing).
|
||
4. **Atomic stop + marker re-assert.** `_finish_walk()` stops the player, restores the standing markers, then re-asserts them **one extra physics frame** (`_walk_settle_frames = 1` → `_settle_walk_markers()`), so a residual ±12.5 px walk body-bob keyframe does not pop on arrival.
|
||
|
||
The result: `mode` stays constant for the whole walk, exactly one `arrived` fires, the rig's `global_position` is unchanged after arrival, and the walk/body-bob animation stops cleanly. `DEBUG_WALK` / `DEBUG_STAGE` (off by default) can be enabled to capture the `mode`/`dist`/`next`/`final` trace at arrival.
|
||
|
||
#### 21.5 Head LookAt solver fix (`master_rig.tscn`)
|
||
|
||
`master_rig.tscn`'s Head `SkeletonModification2DLookAt` now uses a **full-range, non-inverted band solved in global space** (`constraint_angle_min = -180`, `constraint_angle_max = 180`, `constraint_invert = false`, `constraint_in_localspace = false`). Under the previous ~55°-clamped, inverted, local-space band, the aim solver could oscillate frame-to-frame as the look direction crossed the band boundary, causing a per-frame **mirror** of the head rather than smooth convergence. With the full-range global band there is no boundary to cross, so dragging the Head IK handle converges cleanly onto the aim point (see §14 Phase 9 Round 7).
|
||
|
||
### 22. Asset Library — Stickman & Prop Selector Grids (Phase 3b)
|
||
|
||
**Phase 3b** replaces the Sandbox Stage Builder's hard-coded single-stickman and single-prop palette buttons (**Crate** / **Ball** / always-`test.stk` **Stickman**) with **visual selector grids**: clicking **Stickman** or **Prop** opens a modal grid popup (`PopupPanel`) of selectable assets; picking one sets the spawner's session selection and enters placement mode with that asset's ghost. It is **not wired into the editor** — run via **F6** on `res://scenes/sandbox_stage.tscn`. **No `.stk` format change** (the `.stk` schema below is untouched this phase). Pixel rendering (thumbnails) is **manual/F6 verification only**; headless tests never assert on pixels.
|
||
|
||
**Workflow:** press the **Stickman** or **Prop** palette button → the grid opens (modal `PopupPanel`, ESC-closeable) → click a cell → the selection is cached session-only, the grid closes, and placement mode begins (ghost of the selected asset appears). Placing repeats the **selected** asset; switching selection re-opens the grid. `Ground`/`Ramp`/`Step` and `Area` remain direct placement buttons, unchanged.
|
||
|
||
**Two grids:**
|
||
|
||
| Grid | Backing | Cells | Details |
|
||
|---|---|---|---|
|
||
| **Stickman** | `StickmanLibrary.scan()` of `res://stickmen/*.stk` | one per `.stk` (name = `stickman_name` else filename basename) | Rig-rendered thumbnail per cell; **Browse…** (`*.stk` `FileDialog`) selects an arbitrary path; **Refresh** rescans; **empty-state** label when no files; **single-item skip** — exactly one file bypasses the grid and places directly |
|
||
| **Prop** | `PropLibrary` (4 static templates) | Crate/Wood, Ball/Rubber, Plank/Metal, Triangle/Cardboard | Thumbnail + name + **material badge**; always a single page |
|
||
|
||
**Selection is session-only:** the chosen `selected_stickman_path` / `selected_prop_id` live in memory on `StageSpawner`, persist across `EDIT ⇄ DIRECT ⇄ PLAY` toggles, and reset on scene reload. **No disk save** (`user://sandbox_settings.json` is not extended). If the currently selected stickman path is no longer in the scan, the first entry is selected instead on open/refresh.
|
||
|
||
**Selector grid (`AssetSelector` / `scenes/asset_selector.tscn`):** a `PopupPanel` root (`exclusive = true`) built as a minimal shell — authored title bar, empty `GridContainer`, footer (`Prev` / page label / `Next` / `Browse…` / `Refresh` / `Close`) — with **all dynamic per-cell content** (texture + name + material badge) built in code at runtime. `open(kind, entries)` titles the popup ("Choose Your Stickman" / "Choose a Prop"), hides Browse/Refresh for props, and `popup_centered()`. Pagination slices **12/page (4×3)** with Prev/Next hidden when a single page; a static pure `page_bounds(total, page, page_size)` helper backs the slicing. Empty state shows a "no stickmen found" label instead of an empty grid. No cell is pre-highlighted on open (the previous selection-highlight border was removed). The popup re-centers on window resize (`size_changed` → `popup_centered()` while visible). Signals: `item_selected(entry)`, `cancelled()`, `browse_requested()`, `refresh_requested()`.
|
||
|
||
**Thumbnails (rig-accurate, cached):** each 200×200 thumbnail is rendered **lazily, one per frame**, by a `Node` renderer owning a persistent offscreen `SubViewport` (`transparent_bg`, `UPDATE_ALWAYS`) with an enabled in-viewport `Camera2D`:
|
||
|
||
- `StickmanThumbnail.render(stk_data)` spawns the **real rig** (`StickmanFactory.spawn_from_data`), frames it via `StageSpawner.get_world_aabb` (degenerate-figure bbox falls back to a fixed rect), awaits `RenderingServer.frame_post_draw` **twice**, captures, and frees the rig. This produces a figure identical to what actually gets placed — not a re-implementation of the editor preview.
|
||
- `PropThumbnail.render(payload, material_preset)` builds a lightweight `Polygon2D` + `Line2D` visual (mirroring `PropBlock` geometry but **not** a `RigidBody2D`, so nothing falls under gravity), tinted via `PropBlock.tint_for` for non-`NONE` presets.
|
||
- Both return `null` (treated as a **placeholder** by the selector) when the capture is blank/empty — the headless-renderer degrade.
|
||
|
||
**Thumbnail cache (`ThumbnailCache`, `user://thumbnails/`):** rendered PNGs are written to disk and reused on later opens:
|
||
|
||
| Kind | Directory | Key | Invalidation |
|
||
|---|---|---|---|
|
||
| Stickmen | `user://thumbnails/stickmen/` | `"<basename>_<mtime>"` (`FileAccess.get_modified_time`) | A modified `.stk` yields a new key → PNG missing → regenerate; `clean_stale_stickmen` deletes superseded PNGs for the same basename |
|
||
| Props | `user://thumbnails/props/` | `"<id>_v<PROP_VERSION>"` (`PROP_VERSION = 1`) | Regenerate when `PROP_VERSION` bumps or the PNG is missing |
|
||
|
||
`StageSpawner` seeds the cache in `_init`; `SandboxStage._drain_thumbnail_queue()` (called from `_process`) pops one queued entry per frame, awaits its render, `save_png`s it, and hands the texture back via `_selector.set_thumbnail(entry, tex)` — so scanning 50+ files never stalls the UI (a placeholder texture shows until each cell's thumbnail arrives).
|
||
|
||
**Palette integration & Esc priority:** `_on_palette_toggled` routes `stickman`/`prop` presses to `_open_selector(id)` (not straight to `set_placement_mode`); un-pressing while the matching selector is open closes it. While the grid is open a **dim backdrop** (`_selector_dim`, a black `ColorRect` at `SELECTOR_DIM_ALPHA` = 0.5, mouse-ignoring, on the UI `CanvasLayer` behind the selector) is shown. `_open_selector` forces the palette button pressed, performs the single-item skip, and (for stickman) reselects the first entry when the selected path is absent. On `item_selected` the spawner's `selected_stickman_path`/`selected_prop_id` are set, the selector closes, and placement begins. `Browse…` builds a one-off entry via `StickmanLibrary.make_entry(path)` (toast on failure). Esc is handled in the `AssetSelector` (`_unhandled_input` → `cancelled`) and by the stage in its existing **Esc priority chain** — rule step → pending walk target → terrain drag → **selector** → DIRECT → placement → selection. An outside-click that closes the modal popup fires its `popup_hide` signal, which is routed to `_on_selector_cancelled()` (idempotency-guarded) so it likewise un-presses the palette button. While the selector is open, `_handle_world_click` / `_handle_mouse_motion` early-return (belt-and-suspenders on top of the modal `PopupPanel`).
|
||
|
||
**New scripts:** `stickman_library.gd` / `prop_library.gd` / `thumbnails/thumbnail_cache.gd` / `thumbnails/stickman_thumbnail.gd` / `thumbnails/prop_thumbnail.gd` / `asset_selector.gd` (+ `scenes/asset_selector.tscn`). **Modified:** `stage_spawner.gd` (registry `crate`/`ball` → `prop`; `selected_stickman_path`/`selected_prop_id` session state; per-path `_stickman_cache`; new `get_selected_stickman_path()`/`get_selected_prop_id()` getters), `sandbox_stage.gd` (selector integration), and `tests/test_phase4b1_fixes.gd` (`spawn("crate")` → `spawn("prop")`).
|
||
|
||
**Verification:** new headless suite `tests/test_phase3b_library.gd` (`extends SceneTree`, no pixel assertions) covering `StickmanLibrary.scan()`/corrupt-skip/`make_entry`, `PropLibrary.get_entries()`/`get_default_id()`, the `StageSpawner` registry ids (`ground/ramp/step/prop/stickman/area`), `_spawn_prop`/`_spawn_stickman` honoring `selected_prop_id`/`selected_stickman_path`, `ThumbnailCache` key/path formatting, `AssetSelector` pagination math (`PAGE_SIZE == 12`), and scene-load checks. Spec: `docs/phase_3b_asset_grid_spec.md`.
|
||
|
||
### 23. Editor Tools — Action & Rule Editing (Phase 3c)
|
||
|
||
**Phase 3c** makes the Sandbox Stage's **action queues and event rules fully editable**. Where Phase 3a let directors only *append* actions and Phase 4 only *create/delete* rules, Phase 3c adds edit / delete / drag-reorder for both, a **waypoint right-click context menu** with visual walk re-placement and insert-before/after, and both a **full** and a **consequence-only** rule editor. The entire system is **registry-driven** — the action and trigger templates live in two const registries, and the panels/editors generate their UI from them, so a future action/trigger type is a one-entry append. It is **not wired into the editor** — run via **F6** on `res://scenes/sandbox_stage.tscn`. **No `.stk` format change.**
|
||
|
||
**New scripts:**
|
||
|
||
| File | `class_name` / extends | Responsibility |
|
||
|---|---|---|
|
||
| `res://scripts/action_registry.gd` | `ActionRegistry` / `RefCounted` | Registry of the 5 action templates (`walk_to`/`speak`/`wait`/`ragdoll`/`recover`: label, icon, param spec). Static accessors `types()` / `has_type` / `label` / `icon`, plus `to_rule_action()` / `from_rule_action()` (flat queue-action ⇄ rule-action shape conversions) and `summarize()`. |
|
||
| `res://scripts/trigger_registry.gd` | `TriggerRegistry` / `RefCounted` | Registry of the 5 trigger templates (label, icon, `target_type`: `waypoint`/`action_type`/`none`/`area`/`prop`). Static `types()` / `has_type` / `label` / `icon` / `target_type` / `summarize()`. |
|
||
| `res://scripts/queue_panel.gd` | `QueuePanel` / `PopupPanel` | Action Queue panel (scrollable list of one stickman's actions, each with ✎ / ✕ / drag-to-reorder ≡). Mutations delegate to the stage via signals; reorders call the rig's `remove_action`/`insert_action`. Root of `scenes/queue_panel.tscn`. |
|
||
| `res://scripts/rule_panel.gd` | `RulePanel` / `PopupPanel` | Rule list panel filtered by the stage (source stickman **or** a waypoint), with ✎ / ✕ / drag-to-reorder and **Add Rule** / **Clear All**. Reordering emits the new order of the *displayed* rule ids; the stage maps them back onto its full `_event_rules`, preserving un-filtered rules' positions. Root of `scenes/rule_panel.tscn`. |
|
||
| `res://scripts/action_editor.gd` | `ActionEditor` / `PopupPanel` | Single-action property editor (add & edit). Type dropdown + param fields are generated from `ActionRegistry`. A `walk_to` target is captured on the stage (`target_requested()` → the stage hides the editor, captures a click, calls `set_walk_target()`). |
|
||
| `res://scripts/rule_editor.gd` | `RuleEditor` / `PopupPanel` | Rule editor in two modes — **`full`** (trigger type + target + actions all editable) and **`consequence`** (trigger read-only; only the actions editable). Trigger targets and action actors are captured on the stage via signals. |
|
||
| `res://scripts/waypoint_context.gd` | `WaypointContext` / `PopupMenu` | Right-click menu for a `walk_to` waypoint: **✎ Edit this Walk**, **✕ Delete this Walk**, **⬆ Insert action before**, **⬇ Insert action after**, and **⚡ Edit Trigger Rules** (enabled + shows a count when rules target this waypoint). Item ids: `EDIT_WALK`/`DELETE_WALK`/`INSERT_BEFORE`/`INSERT_AFTER`/`EDIT_TRIGGER_RULES`. |
|
||
| `res://scenes/queue_panel.tscn`, `rule_panel.tscn`, `action_editor.tscn`, `rule_editor.tscn` | minimal shells | Each `.tscn` is a bare `PopupPanel` + root script; **all UI is built in code** at `_ready()` (consistent with the `asset_selector.tscn` pattern). |
|
||
|
||
**Modified scripts:** `sandbox_stage.gd` (Phase 3c wiring) and `stage_director_visuals.gd` (waypoint action hit-testing + pulsing edit highlight + rule-label click → editor).
|
||
|
||
**Theming:** the four Phase 3c widgets above are themeable — each accepts an `apply_font(ui_font, emoji_font, sizes)` call driven by the extended `sandbox_theme.json` `fonts` block (style variants `ui_font_bold`/`ui_font_italic`, per-widget `queue/rule_panel_font_size`, `action/rule_editor_font_size`, `panel_row_font_size`/`panel_title_font_size`, style flags `panel_title_bold`/`rule_label_bold`/`badge_bold`, and the optional `action_popup` `{size, bold, italic}` object form; `action_popup_emoji_size` is now consumed). See §21.3 and `docs/phase_3c_editor_spec.md` §14.
|
||
|
||
**Entry points:**
|
||
|
||
| Entry point | Gesture | Opens |
|
||
|---|---|---|
|
||
| Edit Queue | **Direct** action popup → "📋 Edit Queue…" **or** right-click a stickman → "📋 Edit Queue…" | `QueuePanel` for that rig |
|
||
| Edit Rules | **Direct** action popup → "⚡ Edit Rules…" **or** right-click a stickman → "⚡ Edit Rules…" | `RulePanel` filtered to that stickman as trigger source |
|
||
| Edit Trigger Rules | Right-click a waypoint → "⚡ Edit Trigger Rules" | `RulePanel` filtered to rules whose `arrived_at_waypoint` trigger targets that waypoint |
|
||
| Edit a walk | Right-click a waypoint → "✎ Edit this Walk", or a walk action's ✎ in the Queue panel | **Visual walk edit** — enters a `POSITION` target capture with the waypoint highlighted by a pulsing amber ring; the next stage click moves the target |
|
||
| Edit a rule | Click a rule's dashed **label** on stage → consequence-only editor; or a rule's ✎ in the Rule panel → **full** editor | `RuleEditor` (consequence / full) |
|
||
| Delete / reorder / add | ✕ / drag ≡ / "➕ Add Action|Rule" in the panels; "Add Rule" only when a source stickman panel is open | Confirmation dialog (delete / clear) then mutation |
|
||
|
||
**Unified target capture (`CaptureKind`):** the ad-hoc "pending target" flows from Phase 3a/4 (walk target, rule trigger target, rule action actor) are unified into a single stage-click capture system on `SandboxStage`: `enum CaptureKind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION }`. `_begin_capture(kind, hint, on_resolve, on_cancel)` sets the current capture kind + status hint + cursor, routes the next left-click through `_resolve_capture()` (which hit-tests against the kind's expected target — a waypoint dot, `TriggerArea`, `PropBlock`, `StickmanRig`, or a snapped free position), then invokes the resolve callback; `Esc` runs the cancel callback (which re-pops the calling editor/panel). This replaces the previously scattered per-flow pending states.
|
||
|
||
**Data flow (typical edit-a-queue-action):** Queue panel row ✎ → stage `_on_queue_panel_edit_requested(index)` → for a non-walk action opens `ActionEditor.open_edit(action)` (pre-filled) with the `QueuePanel` hidden; on OK `committed(action)` → stage rewrites the rig queue via `remove_action`+`insert_action` → `queue_changed` → director visuals `mark_dirty()` → waypoints/badges redraw; `_restore_queue_panel()` re-pops the (refreshed) panel. For a `walk_to`, editing routes through the visual **walk-edit** capture instead. Rule editing follows the same hide-editor → capture (trigger target / actor) → re-pop pattern.
|
||
|
||
**Registry-driven extensibility (per plan §4/§10):** adding a new action type = appending one entry to `ActionRegistry.ACTION_TEMPLATES` (with a `params` spec); the `ActionEditor` type dropdown, param fields, and the panels' summaries all appear automatically. Adding a new trigger type = one entry to `TriggerRegistry.TRIGGER_TEMPLATES` (with a `target_type`); the `RuleEditor` trigger dropdown and the `RulePanel` trigger summaries pick it up. The action data model stays a `Dictionary`, so new keys are free (the editor shows editable fields for known `params` keys and ignores unknown ones gracefully).
|
||
|
||
**Backward compatibility:** editing preserves each action's `type`/params and each rule's `id`; pre-existing (Phase 3a/4-authored) queues and rules load and display through the new panels unchanged. No queue/rule persistence changes — both remain in-memory across `EDIT ⇄ DIRECT ⇄ PLAY` toggles and reset on scene reload.
|
||
|
||
**Verification:** new headless suite `tests/test_phase3c_editor.gd` (`extends SceneTree`, **253 assertions**) covering both registries (lookups, summaries, unknown-type handling), scene-shell instantiation, `ActionEditor` open_new/open_edit pre-fill + signal flow, `RuleEditor` full vs consequence modes + `get_action()` + id preservation, `QueuePanel`/`RulePanel` render + add/edit/delete/clear/reorder flows (mutating the rig queue via its API), `WaypointContext` item ids + trigger-rules enable/count, `StageDirectorVisuals.hit_test_waypoint_action()`, the `CaptureKind` begin/cancel/resolve + Esc priority, and backward compatibility of pre-existing queues/rules. Run:
|
||
|
||
```
|
||
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase3c_editor.gd --path .
|
||
```
|
||
|
||
Spec: `docs/phase_3c_editor_spec.md`.
|
||
|
||
## File format (`.stk`)
|
||
|
||
Files are UTF-8 JSON, pretty-printed with tab indentation. The format is versioned and designed to remain **backward/forward compatible** — new fields can be added without breaking older files.
|
||
|
||
```json
|
||
{
|
||
"version": "1.5",
|
||
"stickman_name": "Bob",
|
||
"part_order": [
|
||
"head",
|
||
"torso",
|
||
"left_upper_arm",
|
||
"left_lower_arm",
|
||
"right_upper_arm",
|
||
"right_lower_arm",
|
||
"left_upper_leg",
|
||
"left_lower_leg",
|
||
"right_upper_leg",
|
||
"right_lower_leg"
|
||
],
|
||
"proportions": {
|
||
"upper_arm_length": 168.0,
|
||
"lower_arm_length": 200.0,
|
||
"upper_leg_length": 200.0,
|
||
"lower_leg_length": 200.0,
|
||
"torso_length": 391.5
|
||
},
|
||
"body_parts": {
|
||
"head": {
|
||
"shapes": [
|
||
{
|
||
"shape_type": "circle",
|
||
"closed": true,
|
||
"points": [
|
||
{ "x": 120, "y": 40 },
|
||
{ "x": 124, "y": 39 }
|
||
],
|
||
"color": "#000000",
|
||
"vertex_flags": [0, 0, 1, 0]
|
||
}
|
||
],
|
||
"position": { "x": 150, "y": 40 },
|
||
"rotation": 0.0,
|
||
"scale": { "x": 1.0, "y": 1.0 },
|
||
"pivot": { "x": 122.0, "y": 39.5 },
|
||
"length": 10.0,
|
||
"guide_offset": { "x": 122.0, "y": 431.0 }
|
||
}
|
||
},
|
||
"metadata": {
|
||
"created_at": "2026-08-05T12:00:00",
|
||
"modified_at": "2026-08-05T12:00:00"
|
||
}
|
||
}
|
||
```
|
||
|
||
### Root object
|
||
|
||
| Key | Type | Description |
|
||
|---|---|---|
|
||
| `version` | `string` | Format version. Currently `"1.5"`. Loading supports `"1.0"`–`"1.5"` (auto-migrated). |
|
||
| `stickman_name` | `string` | Optional display name for the figure. |
|
||
| `part_order` | `array[string]` | **Phase 5.** Render/Z-order of parts in the Whole Stickman preview, front-to-back semantics per array position (first = back, last = front). Absent on v1.0–v1.2 files; defaults to the internal part-key order when missing. |
|
||
| `proportions` | `object` | **Phase 8.** Rig bone lengths used by the runtime `StkRigAdapter`. Object with 5 float keys: `upper_arm_length` (168.0), `lower_arm_length` (200.0), `upper_leg_length` (200.0), `lower_leg_length` (200.0), `torso_length` (391.5). Hardcoded **master-rig rest-pose constants** (from `master_rig.tscn`), not measured from the user's shapes. Write-only metadata — never read back on load. |
|
||
| `body_parts` | `object` | Map of `body_part_name → shape` objects. Keyed by the 10 internal part names below. |
|
||
| `metadata.created_at` | `string` | Timestamp written on save. |
|
||
| `metadata.modified_at` | `string` | Timestamp written on save. |
|
||
|
||
### Part keys
|
||
|
||
`head`, `torso`, `left_upper_arm`, `left_lower_arm`, `right_upper_arm`, `right_lower_arm`, `left_upper_leg`, `left_lower_leg`, `right_upper_leg`, `right_lower_leg`
|
||
|
||
### Part object
|
||
|
||
Each body part is an object with the following keys:
|
||
|
||
| Key | Type | Description |
|
||
|---|---|---|
|
||
| `shapes` | `array` | Array of shape objects (see below). Draw order = array order (first = back, last = front). Wrapped as a single-element array when migrating v1.0/v1.1 files. |
|
||
| `position` | `object` | `{ "x": float, "y": float }` position of the part within the Whole Stickman preview. |
|
||
| `rotation` | `float` | Rotation in degrees. `0.0` = original orientation. Defaults to `0.0` for v1.0/v1.1 files. |
|
||
| `scale` | `object` | `{ "x": float, "y": float }` scale factors relative to created size. `1.0` = original size. May be **negative** (Phase 5) to represent mirroring along an axis. Defaults to `{ "x": 1.0, "y": 1.0 }` for older files. |
|
||
| `pivot` | `object` | **Phase 8.** `{ "x": float, "y": float }` — the **local rotation origin**, i.e. the bounding-box center of all shape points in the part's local drawing space (before `position` is applied). Always written for all 10 parts; `{ "x": 0.0, "y": 0.0 }` for an empty part. Write-only metadata — never read back on load. |
|
||
| `length` | `float` | **Phase 8.** The part's bounding-box extent along its **segment axis**, in local pixels: **width** (`max_x - min_x`) for the 4 arm parts, **height** (`max_y - min_y`) for torso, legs, and head. `0.0` for an empty part. Write-only metadata — never read back on load. |
|
||
| `guide_offset` | `object` | **Phase 9 Round 5.** `{ "x": float, "y": float }` — the part's **bbox center in preview space minus its silhouette-guide joint in preview space** (a pure master-space delta; the panel-size terms cancel). The adapter uses it to reproduce the editor's guide-relative placement in the harness. Write-only metadata — never read back on load; v1.0–v1.4 files load unchanged and gain the key on their next save. |
|
||
|
||
### Shape object
|
||
|
||
Each entry in the `shapes` array:
|
||
|
||
| Key | Type | Description |
|
||
|---|---|---|
|
||
| `shape_type` | `string` | `"line"`, `"rectangle"`, `"circle"`, or `""` (empty). Descriptive tag only — rendering uses `closed`. |
|
||
| `closed` | `bool` | Whether the polygon is closed (filled) or open (outline-only). |
|
||
| `points` | `array` | Array of `{ "x": float, "y": float }` vertex positions **local to the drawing area**. |
|
||
| `color` | `string` | Hex colour, e.g. `"#000000"`. Changeable in-app via right-click → **Color...** (Phase 3). |
|
||
| `vertex_flags` | `array[int]` | Same length as `points`. `0` = original vertex, `1` = user-created via "Create Point". Controls handle appearance. |
|
||
|
||
> On load, parts missing from the file fall back to built-in default preview positions. v1.0 files have `closed` inferred from `shape_type` and `vertex_flags` default to all zeros. v1.0/v1.1 files have their single shape wrapped in a `shapes` array and default `rotation`/`scale` applied. Files without `part_order` (v1.0–v1.2) use the default part-key order. Note that shape mirroring (Phase 5) rewrites the `points` array in place, so no new shape fields are required — the resulting vertices load identically across all supported versions.
|
||
|
||
## Settings file (`user://settings.json`)
|
||
|
||
Phase 3 introduces a global settings file stored in Godot's **user data** directory (`user://`), separate from the `.stk` figure files. It persists the grid configuration and is versioned for future extensibility:
|
||
|
||
```json
|
||
{
|
||
"version": "1.0",
|
||
"grid_size": 15,
|
||
"snap_to_grid": false,
|
||
"show_pose_guide": true
|
||
}
|
||
```
|
||
|
||
### Settings keys
|
||
|
||
| Key | Type | Default | Description |
|
||
|---|---|---|---|
|
||
| `version` | `string` | `"1.0"` | Settings file version (for future extensibility). |
|
||
| `grid_size` | `int` | `15` | Grid interval in pixels, applied equally to width and height. Default changed from `5` to `15` in Phase 5. Clamped to 1–100 on load. |
|
||
| `snap_to_grid` | `bool` | `false` | Whether snap-to-grid is active. |
|
||
| `show_pose_guide` | `bool` | `true` | Whether the pose silhouette guide is visible in the Whole Stickman preview. |
|
||
|
||
Behavior:
|
||
- **Load** — read on editor startup; if the file is missing or fails to parse, defaults (`grid_size = 15`, `snap_to_grid = false`, `show_pose_guide = true`) are used silently (no error dialog).
|
||
- **Save** — written whenever the user changes the grid size, toggles Snap to Grid, or toggles the pose guide.
|
||
- **Scope** — global; all body-part panels and the Whole Stickman preview share the same grid size and snap setting.
|
||
- **Pan offsets are NOT persisted** — they reset on load, clear, and "Reset Views".
|
||
|
||
## Project structure
|
||
|
||
| Path | Purpose |
|
||
|---|---|
|
||
| `res://project.godot` | Engine config; sets main scene to the editor and enabled features. |
|
||
| `res://scenes/stickman_editor.tscn` | **Main scene** — editor layout, File/Edit/View menu bar, dialogs (`GridConfigDialog` + SpinBox), column containers (unique-name nodes). |
|
||
| `res://scenes/body_part_panel.tscn` | Reusable single body-part editor panel (title, drawing area, context menu, `ColorPickerPopup`); expands vertically in its column. |
|
||
| `res://scripts/stickman_editor.gd` | Editor controller — File/Edit/View menu actions, save/load/clear, JSON v1.5 serialization with multi-shape/rotation/scale, `part_order`, Phase 8 `proportions`/`pivot`/`length`, and Phase 9 Round 5 per-part `guide_offset` export, `settings.json` load/save, editor-wide shape clipboard (Copy/Paste across panels), broadcast of grid/snap settings to panels, Reset Views, populates panels, coordinates selection across panels. |
|
||
| `res://scripts/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9 (Rounds 4–6 bugfix).** Standalone runtime adapter (`class_name StkRigAdapter`, `static func apply(stk_data, rig)`): fits an instantiated `master_rig.tscn` to a loaded `.stk` by re-fitting the 8 limb bones (`Skeleton2D/Torso/...` `Bone2D` lengths + lower-bone origins), recalibrating the IK targets (`IK_Targets/Left|Right_Hand`, `Left|Right_Leg`), and mounting the `.stk` shapes onto the `Body/*` visual nodes (**one node per shape**: closed → single `Polygon2D` fill, open → single `Line2D` width 2). Shape mounting recomputes each part's bounding box at mount time (file `pivot`/`length` are no longer trusted) and derives a mount transform in the rig's **hanging convention** (joint anchor at the local origin, far end along local `+Y`) via `_compute_mount_transform()`: the part's preview transform `E(P) = C + R(rot)·S·(P − C)` (rotation + scale about the bbox center — the editor's exact Whole-Stickman-preview transform) is composed **first**, then the anchor/alignment θ/bone-fit scale are computed on the **transformed geometry**; rotations near ±180° (`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint). Anchors (raw family rules): head/torso bottom-center `(cx, max_y)`, left horizontal limbs `(max_x, cy)`, right horizontal limbs `(min_x, cy)`, vertically drawn limbs top-center `(cx, min_y)`; alignment rotation θ maps the far end onto `+Y`; scaling is **anisotropic** — only the **auto-detected drawn long axis** (`width >= height`) scales to the bone length (`bone_length/extent`, guard `extent <= 0.0001` → `1.0`), cross-axis thickness stays 1:1. The `RemoteTransform2D` drivers keep `update_rotation = true`, so mounted shapes follow their bones under IK flexing. (Phase 9 Round 5) when a part dict carries `guide_offset`, the mounted geometry is translated by `t = (guide_offset + (A − C)).rotated(−c_node)`; (Phase 9 Round 6) when `guide_offset` is present, the joint anchor is whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is nearest the part's guide joint (`center − guide_offset`), replacing the per-side family choice + 180° flip heuristic for that case (fixing the lower-left-leg and lower-right-arm, which were mounted 180° off their bones) — old files without the key keep the family rules + flip heuristic as the fallback in the driver's bone frame (A = mount anchor incl. the 180° flip rule, C = raw bbox center, `c_node` = driver `RemoteTransform2D.global_rotation`), so the harness reproduces the editor's guide-relative placement 1:1; old files without the key keep the offset-0 behavior (head falls back to `HEAD_CHIN_DROP`). Each `Body/*` container's scale is reset to `(1,1)` / rotation `0` (position untouched). (Phase 9) also fits the head bone (`Head.position.y = -proportions.torso_length`) while mounting the head as **full geometry** — it clears the head's inline `@tool` circle script and mounts `.stk` head shapes as `Line2D`/`Polygon2D`, and zeroes the Head driver's local position so the chin sits on the neck joint; the head mounts upright (`θ = 0`, `s = 1`) but still applies the part scale via `E` (face ≈160 px). `_mount_shapes()` also handles **v1.0/v1.1 single-shape** part dicts (wraps the part dict as one shape when it carries `points` but no `shapes` array), so older `.stk` files mount as visible geometry instead of being cleared. **Not used by the editor** — consumed by the runtime pipeline. |
|
||
| `res://scripts/stickman_factory.gd` | **Phase 9.** Runtime entry point (`class_name StickmanFactory`, `extends RefCounted`); a static factory that turns a `.stk` file into a live, rigged `master_rig.tscn` instance. `load_stk(path)` reads + parses the file (`{}` + `push_warning` on failure); `spawn_from_data(stk_data)` instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, and returns the rig root **typed as `StickmanRig`** (the rig now carries the `StickmanRig` root script); `spawn(path)` chains them (`null` on empty data). **Not used by the editor.** |
|
||
| `res://scripts/stickman_rig.gd` | **Phase 9 Task 4.** `class_name StickmanRig`, `extends Node2D`; the runtime owner of facing direction, per-joint bone bend, `Body/*` z-order, and (Phase 10/11) the **kinematic-to-ragdoll** state switch with instant handoff + stand-up recovery, attached to the `master_rig.tscn` root `Master`. Exports a `facing_profile` preset (`FacingProfile` LEFT/RIGHT/FORWARD, default FORWARD) and four `@export_enum("Normal","Inverted")` per-joint bend vars (`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend`), plus (Phase 11) `rest_timeout` (2.0 s) and `auto_recover` (true) exports. Non-`@tool`: resolves `Skeleton2D`/`Body`/bend joints at runtime, enables its own modification stack, and applies the profile (flag writes + `Body/*` reorder) in `_ready()` and setters. Signals `facing_profile_changed` / `bend_flag_changed` / `state_changed`; public API `set_facing_profile`/`get_facing_profile`, `set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, `get_bend_joint_global_position()`, plus the ragdoll API `set_ragdoll(enabled)`/`toggle_ragdoll()`/`is_in_ragdoll()`/`request_recovery()` with `state` / `enum RigState { ANIMATED, RAGDOLL, RECOVERING }`. (Phase 4b) `_update_walking()` latches `_walk_mode` once per walk (`LATCH_PROBE_MAX_FRAMES` 6), unifies arrival on the final target at `ARRIVE_DISTANCE` (snap-on-arrive), steers to the final target when close, and re-asserts standing markers one frame after stop — fixing the walk-waypoint arrival jitter. Null-guarded (`push_warning` + skip). **Not used by the editor.** |
|
||
| `res://scripts/create_animations.gd` | **Phase 11.** `@tool extends EditorScript`; a **standalone editor utility** (run manually with `master_rig.tscn` open; not auto-loaded or referenced at runtime) that supersedes the deleted `scripts/create_walk.gd`. `_run()` bakes `walk_left`/`walk_right` (same keyframes as the old script) and a one-shot `stand_up` (`POSE_DOWN` → `POSE_STANDING`, `STAND_UP_DURATION` 2.0, `loop_mode = LOOP_NONE`) into the open scene's default `AnimationLibrary`. The baked `stand_up` is an **authored reference only** — runtime recovery does not play it (`StickmanRig` tweens the IK targets directly from the captured ragdoll pose, since a fixed first keyframe can never match an arbitrary rest pose). `walk_right` is the **canonical walk** clip; `walk_left` remains baked in the library but is **not used at runtime** (facing is set explicitly and the rig root X-mirrors for LEFT). |
|
||
| `res://scripts/test_harness.gd` | **Phase 9.** Standalone staging scene (run via **F6** on `res://scenes/test_harness.tscn`, not wired into the editor) for debugging bone scales, vector-drawing offsets, and IK limits in isolation. Top UI bar: "Open .stk…" / quick-select buttons (`stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`), "Show Bones" / "Show IK Handles" toggles, loaded-filename label. `SubViewport` world + enabled `Camera2D` (middle-mouse pan, wheel zoom, recenter on spawn); each load frees the previous rig and spawns a fresh one via `StickmanFactory.spawn()`. A world-space debug overlay draws true bone segments (joint dots + parent→child lines, with limb leaf bones drawn out to their IK targets so wrist/ankle joints are visible; the **Head** leaf is the exception — its target is a LookAt aim point, not a joint, so it draws a ~90 px segment along the bone's own direction instead) and colored IK-target markers (hands green, feet blue, head yellow, torso magenta) plus a semi-transparent yellow head-aim line; the **6** `Marker2D` IK targets are click-draggable — the 4 limb targets flex limbs live via `SkeletonModificationStack2D` TwoBoneIK (the rig self-enables its stack), the Torso target translates the whole rig via its `RemoteTransform2D`, and the Head target drives the head's LookAt aim rotation (Phase 9 Round 7). |
|
||
| `res://scenes/test_harness.tscn` | **Phase 9.** Standalone staging scene backing `scripts/test_harness.gd` (run via **F6**; not wired into the editor). |
|
||
| `res://scripts/terrain_block.gd` | **Vector Terrain System.** `class_name TerrainBlock`, `extends StaticBody2D` — a reusable vector terrain component building `Polygon2D` (fill) + `Line2D` (border) + `CollisionPolygon2D` (`BUILD_SOLIDS`, supports concave) children in code. Has a `spawn_id: String` property (set by `StageSpawner`) so same-template terrain overlaps are detectable during drag-painting. |
|
||
| `res://scripts/terrain_utils.gd` | **Vector Terrain System.** `class_name TerrainUtils`, `extends RefCounted` — static `sanitize_points()` (grid snap → local `_simplify_polyline()` → clockwise enforcement) and a `spawn_block()` factory. |
|
||
| `res://scripts/physics_test_harness.gd` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene root building flat/ramp/step terrain via `TerrainUtils`, instantiating `master_rig.tscn`, spawning props via **1/2/3** (`PropUtils`), and adding a rig collision proxy (run via **F6**; not wired into the editor). A toggle-mode button flips the rig's kinematic-to-ragdoll mode via `_rig.set_ragdoll()`, plus (Phase 11) a **Rest** `SpinBox` (writes `_rig.rest_timeout`) and **"Recover Now"** button (`_rig.request_recovery()`); the rig's `state_changed` signal removes the proxy on `RAGDOLL` and re-adds it (idempotently) on `ANIMATED`/`RECOVERING`. |
|
||
| `res://scenes/physics_test_harness.tscn` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene backing `scripts/physics_test_harness.gd` (run via **F6**; not wired into the editor). |
|
||
| `res://scripts/prop_block.gd` | **Dynamic Vector Props.** `class_name PropBlock`, `extends RigidBody2D` — a reusable physical prop building `Polygon2D` (fill) + `Line2D` (outline) + `CollisionPolygon2D`/`CollisionShape2D` (polygon/circle collision) children in code, with material presets (mass + friction/bounce) and live-updating exports. |
|
||
| `res://scripts/prop_utils.gd` | **Dynamic Vector Props.** `class_name PropUtils`, `extends RefCounted` — static `create_box()` / `create_ball()` / `create_plank()` / `create_triangle()` primitive generators and a `spawn_prop()` factory (sanitizes polygon points via `TerrainUtils`). |
|
||
| `res://scripts/sandbox_stage.gd` | **Sandbox Stage Builder.** `class_name SandboxStage`, `extends Node2D` — root controller: `enum StageMode { EDIT, DIRECT, PLAY }` state machine (freezes props with `FREEZE_MODE_KINEMATIC`; runs stickman queues + rags props/areas in PLAY), placement mode + terrain drag-painting, a grid spatial dictionary, camera pan/zoom, deletion, bottom status bar, mode badge/frame/cursors, the `res://sandbox_theme.json` loader, and signal fan-out (`mode_changed(mode: int)` / `object_placed` / `object_selected` / `object_deselected` / `object_deleted`). **Phase 3b** instantiates the `AssetSelector` popup + thumbnail renderers, owns the selector open/close flow and lazy per-frame thumbnail drain (§22). **Phase 3c** instantiates the queue/rule panels + editors + waypoint context, owns the unified `CaptureKind` target-capture system, the "Edit Queue…"/"Edit Rules…" + right-click context entry points, the shared confirmation dialog, and consequence-only rule editing (§23). Standalone staging scene run via **F6**; not wired into the editor. |
|
||
| `res://scripts/stage_spawner.gd` | **Sandbox Stage Builder.** `class_name StageSpawner`, `extends RefCounted` — registry-driven factory (`Array[Dictionary]`, no id `match`); reuses `TerrainUtils` / `PropUtils` / `StickmanFactory`; centers terrain on its origin. Exposes `is_terrain_id()` / `get_template_aabb()` and tags spawned terrain with a `spawn_id`. `get_template_aabb(id)` mirrors `_spawn_terrain()`'s sanitize pass (`TerrainUtils.sanitize_points` at `TERRAIN_GRID_SIZE` 16), so the returned extent matches the real placed footprint — e.g. the 200-px-wide Ground template returns a **192-px** stride — and drives the block-unit paint stride, ghost sizing, and cell rasterization. **Phase 3b:** registry ids `ground/ramp/step/prop/stickman/area` (separate `crate`/`ball` removed); holds `selected_stickman_path` / `selected_prop_id` session state + a per-path `_stickman_cache`; `prop`/`stickman` spawn the **selected** asset (§22). |
|
||
| `res://scripts/stickman_library.gd` | **Asset Library (Phase 3b).** `class_name StickmanLibrary`, `extends RefCounted` — scans `res://stickmen/*.stk` into `{path, name, data}` entries (corrupt/missing-`body_parts` skipped; name = `stickman_name` else filename basename); `make_entry(path)` for Browse-chosen paths. |
|
||
| `res://scripts/prop_library.gd` | **Asset Library (Phase 3b).** `class_name PropLibrary`, `extends RefCounted` — static registry of the 4 prop templates (Crate/Wood, Ball/Rubber, Plank/Metal, Triangle/Cardboard); `get_default_id()` = `"crate"`. |
|
||
| `res://scripts/asset_selector.gd` | **Asset Library (Phase 3b).** `class_name AssetSelector`, `extends PopupPanel` — grid popup controller (root of `scenes/asset_selector.tscn`): pagination 12/page, empty state, per-cell thumbnail + name + prop material badge, Browse/Refresh/Close, Esc. |
|
||
| `res://scripts/thumbnails/stickman_thumbnail.gd` | **Asset Library (Phase 3b).** `class_name StickmanThumbnail`, `extends Node` — renders a parsed `.stk` to a `Texture2D` via the real rig in an offscreen `SubViewport`. |
|
||
| `res://scripts/thumbnails/prop_thumbnail.gd` | **Asset Library (Phase 3b).** `class_name PropThumbnail`, `extends Node` — renders a prop template to a `Texture2D` (lightweight non-physics visual). |
|
||
| `res://scripts/thumbnails/thumbnail_cache.gd` | **Asset Library (Phase 3b).** `class_name ThumbnailCache`, `extends RefCounted` — disk PNG cache (`user://thumbnails/`) keyed by basename+mtime (stickmen) / `id_v<PROP_VERSION>` (props); load/save/stale cleanup. |
|
||
| `res://scenes/asset_selector.tscn` | **Asset Library (Phase 3b).** `PopupPanel` root + `asset_selector.gd` — minimal shell (title bar, empty grid, footer); dynamic cells built in code. |
|
||
| `res://scripts/action_registry.gd` | **Editor Tools (Phase 3c).** `class_name ActionRegistry`, `extends RefCounted` — const registry of the 5 action templates (`walk_to`/`speak`/`wait`/`ragdoll`/`recover`: label/icon/params); static `types()`/`has_type`/`label`/`icon`, `to_rule_action()`/`from_rule_action()`, `summarize()`. |
|
||
| `res://scripts/trigger_registry.gd` | **Editor Tools (Phase 3c).** `class_name TriggerRegistry`, `extends RefCounted` — const registry of the 5 trigger templates (label/icon/`target_type`); static `types()`/`has_type`/`label`/`icon`/`target_type`/`summarize()`. |
|
||
| `res://scripts/queue_panel.gd` | **Editor Tools (Phase 3c).** `class_name QueuePanel`, `extends PopupPanel` — Action Queue panel (one stickman's actions with ✎/✕/drag-reorder); mutates the rig queue via its API. |
|
||
| `res://scripts/rule_panel.gd` | **Editor Tools (Phase 3c).** `class_name RulePanel`, `extends PopupPanel` — rule list panel filtered by source stickman or waypoint, with ✎/✕/drag-reorder + Add Rule/Clear All. |
|
||
| `res://scripts/action_editor.gd` | **Editor Tools (Phase 3c).** `class_name ActionEditor`, `extends PopupPanel` — add/edit single-action editor; type dropdown + params generated from `ActionRegistry`; `walk_to` target captured on stage. |
|
||
| `res://scripts/rule_editor.gd` | **Editor Tools (Phase 3c).** `class_name RuleEditor`, `extends PopupPanel` — rule editor in **full** or **consequence-only** modes (trigger read-only); actions add/edit/remove. |
|
||
| `res://scripts/waypoint_context.gd` | **Editor Tools (Phase 3c).** `class_name WaypointContext`, `extends PopupMenu` — right-click waypoint menu: Edit/Delete Walk, Insert action before/after, Edit Trigger Rules. |
|
||
| `res://scenes/queue_panel.tscn` / `rule_panel.tscn` / `action_editor.tscn` / `rule_editor.tscn` | **Editor Tools (Phase 3c).** Minimal `PopupPanel` shells (bare root + root script); all UI is built in code at `_ready()`. |
|
||
| `res://scripts/stage_selection.gd` | **Sandbox Stage Builder.** `class_name StageSelection`, `extends RefCounted` — hover/click/box selection via geometric world-space AABB hit-testing (frontmost `World` child wins; `RagdollBodyContainer` subtree excluded); `hover_changed` / `selection_changed` signals. |
|
||
| `res://scripts/stage_gizmos.gd` | **Sandbox Stage Builder.** `class_name StageGizmos`, `extends Node2D` — hover highlight + selection outline + rotate ring via `_draw()` and distance-based hit-testing; objects are dragged directly (no move handle); drives `global_position` / `global_rotation`; emits `transform_committed`. |
|
||
| `res://scripts/stage_grid.gd` | **Sandbox Stage Builder.** `class_name StageGrid`, `extends Node2D` — optional world-space grid overlay (major line every 5 cells) that pans/zooms with the camera; `grid_size` / `enabled` set by `SandboxStage`. |
|
||
| `res://scripts/stage_placement_overlay.gd` | **Sandbox Stage Builder (Phase 4b).** `class_name StagePlacementOverlay`, `extends Node2D` — world-space overlay drawing the terrain drag-painting dashed guide line (`set_terrain_guide` / `clear_terrain_guide`) and the director action rubber-band trajectory + ghost marker (`set_action_trajectory` / `clear_action`); pure drawing, no hit-testing. |
|
||
| `res://sandbox_theme.json` | **Sandbox Stage Builder (Phase 4b).** Hand-editable styling defaults for the sandbox (font paths/sizes, grid snap default, mode accent + guide-line colors); loaded by `SandboxStage._load_theme()` with defaults on missing/malformed file. **Phase 3c** extends the `fonts` block (bold/italic variant paths, per-widget sizes/style flags, optional `action_popup` object form) and drives the panels'/editors' `apply_font(...)` (§21.3 / spec §14). |
|
||
| `res://scenes/sandbox_stage.tscn` | **Sandbox Stage Builder.** Standalone staging scene backing `scripts/sandbox_stage.gd` (run via **F6**; not wired into the editor): root `Node2D` + `Camera2D` + empty `World`; the gizmo layer, placement overlay, and CanvasLayer UI (mode switcher, toolbars, bottom status bar, badge, tooltip) are built in code. |
|
||
| `res://scripts/body_part_panel.gd` | Multi-shape creation, vertex editing, shape dragging, per-panel zoom & pan, grid drawing & snap-to-grid, ColorPicker, shape/vertex delete, Z-ordering (Send Back / Bring Forward), shape Copy/Paste, shape Mirror X/Y, drawing (fill + outline for closed shapes). |
|
||
| `res://scripts/whole_stickman_preview.gd` | Assembly preview, drag-to-reposition, part selection with white bounding box, rotation gizmo (circle below box) with Ctrl 15° snap, scale gizmo (corner crosses) with Ctrl aspect lock, part Z-ordering (Send Back / Bring Forward) via `part_order`, part Mirror X/Y (scale negation), zoom & pan, grid drawing & snap-to-grid, pose silhouette guide (Phase 7), part hit-bounds, labels, and (Phase 9 Round 5) `get_guide_joint_preview()` — the preview-space position of a guide joint, used by the editor to export per-part `guide_offset`. |
|
||
| `res://addons/curved_lines_2d/` | Scalable Vector Shapes 2D addon (v2.27.7) — required dependency. |
|
||
| `res://stick.tscn` | **Legacy** rigged/animated stick figure scene (Skeleton2D + IK). Not used by the editor. |
|
||
| `res://AGENTS.md` | Guidance for AI agents working in this codebase. |
|
||
|
||
### Menu bar structure
|
||
|
||
```
|
||
File Edit View
|
||
──────────── ──────────────────── ───────────
|
||
Save Configure Grid... Reset Views
|
||
Load ───────── ─────────────
|
||
──────── Snap to Grid (check) Hide/Show Pose Guide
|
||
Clear
|
||
```
|
||
|
||
- **File** — Save, Load, Clear (Phase 1).
|
||
- **Edit** — `Configure Grid...` (dialog with a 1–100 SpinBox) and the checkable `Snap to Grid` toggle (Phase 3).
|
||
- **View** — `Reset Views` (resets zoom to 100% and pan offset to origin on every panel) (Phase 3) and the dynamic text-only `Hide/Show Pose Guide` toggle (Phase 7): reads **"Hide Pose Guide"** while the guide is visible and **"Show Pose Guide"** while it is hidden.
|
||
|
||
### Context menu (per body-part panel)
|
||
|
||
The right-click context menu is context-sensitive. Item IDs:
|
||
|
||
| ID | Label | Shown when |
|
||
|----|-------|------------|
|
||
| 0 | Line | Empty space right-click |
|
||
| 1 | Rectangle | Empty space right-click |
|
||
| 2 | Circle | Empty space right-click |
|
||
| 3 | Create Point | Shape under mouse, near outline |
|
||
| 4 | Color... | Shape under mouse |
|
||
| 5 | Delete | Shape under mouse |
|
||
| 6 | Remove Point | Right-click on a vertex handle |
|
||
| 7 | Send Back | Shape under mouse |
|
||
| 8 | Bring Forward | Shape under mouse |
|
||
| 9 | Copy | Shape under mouse (Phase 5) |
|
||
| 10 | Paste | Shape or empty space right-click; shown only when the clipboard is populated (Phase 5) |
|
||
| 11 | Mirror X | Shape under mouse (Phase 5) |
|
||
| 12 | Mirror Y | Shape under mouse (Phase 5) |
|
||
|
||
### Context menu (Whole Stickman preview)
|
||
|
||
Right-clicking a selected part opens the preview's context menu:
|
||
|
||
| ID | Label | Description |
|
||
|----|-------|-------------|
|
||
| 0 | Send Back | Move the selected part one step back in `part_order` (Phase 5) |
|
||
| 1 | Bring Forward | Move the selected part one step forward in `part_order` (Phase 5) |
|
||
| 2 | Mirror X | Mirror the selected part around its bounding-box center (scale X negated) (Phase 5) |
|
||
| 3 | Mirror Y | Mirror the selected part around its bounding-box center (scale Y negated) (Phase 5) |
|
||
|
||
### Signal flow
|
||
|
||
```
|
||
BodyPartPanel.shape_changed(data) ---(bound to part_name)---> stickman_editor
|
||
| |
|
||
| _on_body_part_shape_changed |
|
||
+--------------------------> WholeStickmanPreview |
|
||
.set_body_parts(all_data) |
|
||
^ |
|
||
WholeStickmanPreview.part_moved(name, pos) (drag handling) --+
|
||
|
||
BodyPartPanel.shape_selected() ---(bound to part_name)---> stickman_editor
|
||
|
|
||
_on_body_part_shape_selected
|
||
(deselects all other panels)
|
||
```
|
||
|
||
- `BodyPartPanel` emits `shape_changed(shapes_array)` whenever a shape is created, a vertex is dragged (on release), a color is committed via the ColorPicker (on OK), a shape is deleted, a vertex is removed via "Remove Point", or Z-order changes via "Send Back"/"Bring Forward". The signal carries the full `Array[Dictionary]` of all shapes in the panel. Note: `clear_shape()` does **not** emit the signal; the editor's Clear flow refreshes the preview directly.
|
||
- `BodyPartPanel` emits `shape_selected` when left-clicked; the editor deselects all other panels.
|
||
- `stickman_editor.gd` collects shape data from all 10 panels and forwards it to `WholeStickmanPreview.set_body_parts()`.
|
||
- `WholeStickmanPreview` emits `part_moved(part_name, new_position)` during translation drags. Rotation and scale are stored internally and polled by the editor at save time via `get_part_rotation()` / `get_part_scale()`. Part Z-order is polled the same way via `get_part_order()` and saved to the top-level `part_order` field (Phase 5).
|
||
- Shape Copy/Paste (Phase 5) is owned by the editor: `_copy_shape()` stores a deep duplicate in `_shape_clipboard`, `_broadcast_clipboard_state()` tells every panel whether Paste should be offered, and `_paste_shape()` inserts the clipboard into the target panel at the click position. The clipboard persists after pasting and is shared across all panels.
|
||
|
||
> **Phase 4:** Each body part can have multiple shapes in Z-order. The Whole Stickman preview treats all shapes in a panel as one combined object, with selection (white bounding box), rotation (circle gizmo below box), and scale (cross gizmos at corners). The `.stk` format evolved to v1.2 with a `shapes` array per part plus `rotation` and `scale` fields. Rotation snaps to 15° with Ctrl; scale locks aspect ratio with Ctrl.
|
||
|
||
> **Phase 5:** Adds drawing-surface clipping, shape dragging, an editor-wide shape clipboard (Copy/Paste across panels), shape Mirror X/Y (vertex recompute), and preview object Z-ordering (Send Back/Bring Forward) with Mirror X/Y (scale negation). The format evolved to v1.3: a new top-level `part_order` array stores preview Z-order; scale may be negative for mirrored parts; shape mirroring stores no new fields because it rewrites `points`. The default grid size changed from 5 px to 15 px, and the Snap to Grid checkmark now renders correctly when toggled. v1.0–v1.2 files remain backward compatible and are migrated on load.
|
||
|
||
> **Phase 7:** Adds the pose silhouette guide to the Whole Stickman preview — a semi-transparent, color-coded stick figure (left cyan-blue, right orange-red, central white) whose 13 joint anchors match the rest-pose pivots of the future `master_rig.tscn` rig, drawn above the grid and in front of user parts (ghosting over them, below the selection gizmos and drag highlight), centered in the preview at the default view. Toggleable via the dynamic **View → Hide/Show Pose Guide** label (on by default, persisted to `settings.json`). The guide is a view aid only: **no `.stk` format change** — `FILE_VERSION` stays `"1.3"`.
|
||
|
||
> **Phase 8:** The `.stk` export evolved to **v1.4** with write-only metadata the editor never reads back. A top-level `proportions` object stores the 5 master-rig rest-pose bone lengths (`upper_arm_length` 168.0, `lower_arm_length` 200.0, `upper_leg_length` 200.0, `lower_leg_length` 200.0, `torso_length` 391.5) — hardcoded constants from `master_rig.tscn`, not measured from the user's shapes. Each `body_parts` entry gains `pivot` (local bounding-box center = rotation origin) and `length` (bbox extent along the segment axis: width for arms, height for torso/legs/head). v1.0–v1.3 files load unchanged and gain these keys on their next save. A new standalone `res://scripts/stk_rig_adapter.gd` (`class_name StkRigAdapter`) fits an instantiated `master_rig.tscn` to a loaded `.stk` (bone fitting + IK recalibration + visual shape mount); it is **not** used by the editor and is reserved for a future runtime pipeline.
|
||
|
||
> **Phase 9:** Adds the **runtime pipeline** for turning a `.stk` file into a live, rigged `master_rig.tscn` instance, plus a standalone staging scene to debug it. A new `res://scripts/stickman_factory.gd` (`class_name StickmanFactory`) provides the runtime entry point: `load_stk(path)` reads/parses a `.stk` (`FileAccess` + `JSON.parse_string`, `{}` + `push_warning` on failure), `spawn_from_data(stk_data)` instantiates `master_rig.tscn` and applies `StkRigAdapter.apply(stk_data, rig)`, and `spawn(path)` chains them (`null` on empty data). `StkRigAdapter` is extended to also fit the head bone (`Head.position.y = -proportions.torso_length`) and to mount the head as **full geometry** like every other part — clearing its inline `@tool` circle script and mounting `.stk` head shapes as `Line2D`/`Polygon2D` (removed the dead `_mount_head_circle`, `_compute_shapes_bbox`, and `_first_shape_color` helpers). A new standalone scene/resource pair, `res://scenes/test_harness.tscn` + `res://scripts/test_harness.gd` (run via **F6**), loads `.stk` files (open dialog + quick-select for `stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`), toggles a world-space debug overlay (true bone segments — parent→child lines with joint dots, limb leaf bones drawn to their IK targets and the Head leaf along its own direction — plus IK-target markers), pans/zooms a `Camera2D`, and lets you click-drag the 4 limb IK targets with live TwoBoneIK flexing (`SkeletonModificationStack2D` enabled on spawn). Neither the factory nor the harness is wired into the editor. **No `.stk` format change** — `FILE_VERSION` stays `"1.4"`.
|
||
|
||
> **Phase 9 Round 2 bugfix:** `StkRigAdapter._mount_shapes()` no longer trusts the file's `pivot`/`length`; it recomputes each part's bounding box at mount time and mounts geometry in the rig's **hanging convention** (joint anchor at the local origin, far end along local `+Y`) with a per-family alignment rotation (head `0`, torso `π`, left limbs `−π/2`, right limbs `+π/2`, vertical-drawn limbs `0`). Anchors: head/torso bottom-center, left horizontal limbs `max_x`, right horizontal limbs `min_x`, vertical limbs top-center. Scaling is **anisotropic** — only the **auto-detected drawn long axis** (`width >= height`) scales to the bone length (`bone_length/extent`, guard `extent <= 0.0001` → `1.0`), cross-axis thickness stays 1:1. Driver rotation neutralization was **removed** — the 10 `RemoteTransform2D` drivers keep `update_rotation = true`, so mounted shapes follow their bones under IK flexing. `Body/*` container transforms are reset (scale `(1,1)`, rotation `0`, position untouched), the Head driver's local position is zeroed so the chin lands on the neck joint, and `DEFAULT_LINE_WIDTH` is 2.0 (editor's 2 px outline). The harness `_draw_bones()` was rewritten to draw true parent→child bone segments with leaf bones out to their IK targets (wrist/ankle joints now visible). Per docs/phase9_round2_bugfix_spec.md; verified with a 46-assertion headless smoke test.
|
||
|
||
> **Phase 9 Round 3 bugfix:** `StkRigAdapter._mount_shapes()` now reads each part's `rotation` (degrees, default 0.0) and `scale` (`{x,y}`, default `(1,1)`) and composes the preview's part transform `E(P) = C + R(rot)·S·(P − C)` (scale-then-rotate about the bbox center) **before** the hanging-convention mount — the anchor, alignment θ, and bone-fit scale are computed on the transformed geometry, so every rotation/scale the user applied in the Whole Stickman preview renders in the harness. Rotations near ±180° (`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end so flips are visible (the 180° torso shows its drawn neck end at the hip joint); the bone-fit scale `s = bone_length/|V|` is measured on the transformed extent so user-scaled parts are not double-fitted. The head mounts upright (`θ = 0`, `s = 1`) but still applies the part scale via `E` (face ≈160 px), chin at the neck joint. `_mount_shape()` mounts **one node per shape** — closed → single `Polygon2D` (fill only), open → single `Line2D` (width 2) — removing the previous Polygon2D + Line2D outline duplication. The harness head-leaf overlay now draws along the bone's own direction (the LookAt aim point is not a joint) instead of a line to `IK_Targets/Head`, eliminating the bone sticking out of the head. Per docs/phase9_round3_bugfix_spec.md; verified with a 26-assertion headless smoke test.
|
||
|
||
> **Phase 9 Round 4 bugfix:** the mounted **head** was too high in the harness — its chin (the local origin) landed on the neck joint at `y = −391.5`, floating above the torso. The editor's silhouette guide draws the head as a radius-100 circle centered at the Head joint `(0, −463.5)` (bottom at `−363.5`), so `StkRigAdapter` now applies a `+28 px` rig-space translation (`const HEAD_CHIN_DROP := 28.0`) to the mounted head points after the part transform and the `(θ = 0, s = 1)` transform. The chin lands at world ≈ `(0, −363.5)` — the guide circle's bottom — overlapping the torso (top at `−391.5`) by 28 px, matching the editor guide. The drop is flip-agnostic (a rig-space fixture applied after part scale/rotation). Per docs/phase9_round4_bugfix_spec.md; verified with a 15-assertion headless smoke test.
|
||
|
||
> **Phase 9 Round 5:** the `.stk` export evolved to **v1.5** with per-part **`guide_offset`** — the part's bbox center minus its silhouette-guide joint (both in preview space, a pure master-space delta). `StkRigAdapter` applies it only when present as a node-frame translation `t = (guide_offset + (A − C)).rotated(−c_node)` (A = the mount anchor incl. the 180° flip rule, C = the raw bbox center, `c_node` = the part's driver `RemoteTransform2D.global_rotation` at apply time), so the harness reproduces the editor's guide-relative placement **1:1**; old files without the key keep the previous offset-0 behavior and the head falls back to the Round 4 `HEAD_CHIN_DROP`. The head maps to the guide **Neck** joint — the head bone's rig attachment origin, **not** the circle center (which sits 72 px above the neck). `guide_offset` is **write-only** metadata like `pivot`/`length` — the load path ignores it, so v1.0–v1.4 files load unchanged and gain the key on their next save. **Note:** `.stk` files must be re-saved from the editor to populate the offsets. Per docs/phase9_round5_bugfix_spec.md.
|
||
|
||
> **Phase 9 Round 6 bugfix:** `StkRigAdapter._compute_mount_transform()` now selects the joint anchor as whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is **nearest the part's stored guide joint** (`center − guide_offset`) when a part carries `guide_offset`. This replaces the per-side family choice and the 180° flip heuristic for that case, fixing the **lower left leg** and **lower right arm**, which were mounted 180° off their bones (the far end attached at the joint) because the user's drawn-side conventions are inconsistent across parts — the stored guide placement is the ground truth for which drawn end is the joint. The nearest-end rule naturally preserves the 180° flip behavior (a flipped part's far end lands nearest the joint), the head chin, and every previously-correct case. Old files without the key keep the family rules + flip heuristic exactly as before. `theta`, `s`, the Round 5 offset `t`, and the head `HEAD_CHIN_DROP` fallback are unchanged. Per docs/phase9_round6_bugfix_spec.md; verified with a 32-assertion headless smoke test.
|
||
|
||
> **Phase 9 Round 7:** the test harness (`scripts/test_harness.gd`) now exposes **6** draggable IK handles. `IK_HANDLE_PATHS` gains `"Head"` (`IK_Targets/Head`, the `SkeletonModification2DLookAt` aim point) and `"Torso"` (`IK_Targets/Torso`, whose child `RemoteTransform2D` moves the hip bone). Dragging the **Torso** handle moves **bones only** (no target following) — the marker's `RemoteTransform2D` translates the hip bone, and the whole skeleton + `Body/*` visuals follow rigidly, while the limb/head targets stay put so dragging the figure away from them stretches the limbs toward the stationary targets (per user decision). Dragging the **Head** handle drives the Head bone's LookAt rotation (the solver now uses a full-range, non-inverted band solved in global space — `constraint_angle_min = -180 / max = 180`, `constraint_invert = false`, `constraint_in_localspace = false` — so the head converges to the aim point with no per-frame mirror oscillation; the earlier authored ~55° clamp is gone); `Body/Head` follows. `_handle_color()` colors the head marker yellow (`HANDLE_COLOR_HEAD`) and the torso marker magenta (`HANDLE_COLOR_TORSO`); hands stay green, feet blue. The IK overlay additionally draws a null-guarded semi-transparent yellow aim line from the Head bone origin to the head marker (visual aid for the LookAt test). **No `.stk` format change.** Per docs/phase9_round7_feature_spec.md; verified with a 17-assertion headless test.
|
||
|
||
> **Phase 10 (Kinematic-to-Ragdoll):** adds a reversible **kinematic-to-ragdoll** state switch to the runtime rig. `StickmanRig` gains `enum RigState { ANIMATED, RAGDOLL }`, `var state: RigState`, `signal state_changed(new_state)`, and the `set_ragdoll(enabled)` / `toggle_ragdoll()` / `is_in_ragdoll()` API. In `RAGDOLL` mode the IK modification stack is disabled, the `AnimationPlayer` stopped, and the `Body/*` visuals hidden; a procedural network of **10** `RigidBody2D` (torso `CapsuleShape2D` mass 8.0, head `CircleShape2D` radius 100, limb capsules radius 8) + **9** `PinJoint2D` (elbow/knee fold-only ±bands, shoulder/hip ±160°, neck free) is built in code and reparented into a `"RagdollBodyContainer"` under the rig's **parent** (world root), layer 1/mask 1 so it collides with terrain and props. The rig root's momentum (tracked in `_physics_process`) is applied to the ragdoll Torso body for a seamless handoff. Exiting frees the ragdoll, re-shows `Body/*`, re-enables IK, and stops the animation. The physics harness toggles via its **Stickman ↔ Ragdoll** button, removing the `RigCollisionProxy` on entry and re-adding it (idempotently) on exit. `master_rig.tscn` is **not** modified.
|
||
|
||
> **Phase 11 (Instant Handoff & Recovery):** replaces the hard ragdoll entry/exit with an **instant handoff** and adds a **stand-up recovery** path. `StickmanRig` gains `enum RigState { ANIMATED, RAGDOLL, RECOVERING }` plus exports `rest_timeout` (2.0 s) and `auto_recover` (true). On entering `RAGDOLL` the ragdoll is built from the **current solved bone positions** (the player is stopped with `keep_state`), then `Body/*` is hidden and the IK stack disabled in the same call — no crossfade, since the ragdoll spawns at exactly the same pose and a fade would only read as ghosting (an earlier `transition_duration` blend was removed on director feedback). Rest detection reads the Torso body — sleeping, or linear ≤ `REST_LINEAR_THRESHOLD` (5.0 px/s, tuned up from the plan's 0.1 because a soft-pinned ragdoll micro-jitters around ~0.5 px/s) and angular ≤ 0.1 rad/s — then after `rest_timeout` + `STABILIZATION_DELAY` (0.1 s) with `auto_recover` on, calls `_start_recovery()`. Recovery captures the 10 bodies' rig-local pose **plus a landing anchor** (`_captured_landing_center` = the ragdoll torso's world center, `_captured_ground_y` = torso `center.y + RAGDOLL_TORSO_RADIUS`), re-anchors the rig root so the standing figure's **feet** sit on the ground at the landing X (`_reanchor_root_to_landing()`, `new_root = feet + FOOT_OFFSET`) so the figure stands up **in place, on the ground**, where the ragdoll landed rather than sliding back to its pre-ragdoll root position (the earlier spine-direction-hip anchor sat at ground level for a lying torso and buried the standing feet), destroys the ragdoll, sets `state = RECOVERING` + emits, snap-solves the skeleton via the **6** IK targets (`IK_Targets/Torso` pos+rot, `IK_Targets/Head`, 4 limb markers — never the slaved Torso `Bone2D`), deriving the **hip** from the torso capsule's bottom end (`pos − dir·half`) and the wrist/ankle targets from the lower-limb capsules' far ends (`pos + dir·half`), with the Torso marker rotation subtracting the Torso bone's `bone_angle` (copying the body rotation directly would slam the skeleton −90° and lay it flat), then `_play_stand_up()` tweens the markers **directly** from their captured values to `STAND_POSE` over `STAND_UP_DURATION` (2.0 s, sine ease-in-out) — the baked `stand_up` animation is **not** played, because a fixed first keyframe can never match an arbitrary ragdoll rest pose (the earlier bridge-into-the-animation approach caused a visible jump); `_on_stand_up_finished()` then returns the rig to `ANIMATED`. `request_recovery()` is public (no-op unless in `RAGDOLL`); `set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll, `set_ragdoll(false)` during `RAGDOLL` routes through recovery, and calls are otherwise idempotent. `is_in_ragdoll()` stays `state == RAGDOLL` (so `RECOVERING` reads as "Stickman"). A new `res://scripts/create_animations.gd` editor script (superseding the deleted `create_walk.gd`) bakes `walk_left`/`walk_right`/the one-shot `stand_up` into `master_rig.tscn`'s `AnimationLibrary` (the baked `stand_up` is an authored reference only — recovery does not play it) — **no `.stk` format change**; `master_rig.tscn` scene nodes are unchanged (only its baked animations are added). The physics harness gains a **Rest** `SpinBox` (0.1–10 s, writes `_rig.rest_timeout`), a **"Recover Now"** button (`request_recovery()`), and a `state_changed` hook that removes the `RigCollisionProxy` on `RAGDOLL` and re-adds it (idempotently) on `ANIMATED`/`RECOVERING`.
|