Add headless regression tests for Phase 4b features

- Implement test for popup anchor behavior in rule-builder menus to ensure consistent anchor positioning during menu transitions.
- Create tests for stage logic, including mode transitions, toolbar visibility, and status bar updates.
- Add terrain drag-painting tests to verify correct block placement behavior and conflict handling.
- Introduce walk waypoint tests to check for arrival conditions and position stability after navigation.
This commit is contained in:
2026-09-04 15:08:08 -04:00
parent d77b5e5441
commit 1f91f3d2e5
54 changed files with 7960 additions and 162 deletions
+330
View File
@@ -0,0 +1,330 @@
# Phase 3b: Asset Library — Stickman + Prop Selector Grids
## 1. Overview
Phase 3b adds a **visual asset library** to the Sandbox Stage Builder. Currently, placing a stickman always spawns the hard-coded `test.stk` figure, and props are limited to the palette buttons (Crate, Ball, Plank). This phase replaces those limitations with **visual selector grids** that let the user choose from all available assets.
## 2. Core Concept
```
┌─────────────────────────────────────────────────────────────────────┐
│ ASSET LIBRARY WORKFLOW │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ EDIT MODE │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ User clicks "Stickman" palette button │ │
│ │ ↓ │ │
│ │ Stickman Selector Grid opens (popup overlay) │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ Choose Your Stickman [×] Close │ │ │
│ │ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │ │
│ │ │ │ 📷 │ │ 📷 │ │ 📷 │ │ 📷 │ │ │ │
│ │ │ │ Bob │ │ Sally│ │ John │ │ Joe │ │ │ │
│ │ │ └──────┘ └──────┘ └──────┘ └──────┘ │ │ │
│ │ │ ┌──────┐ ┌──────┐ │ │ │
│ │ │ │ 📷 │ │ 📷 │ │ │ │
│ │ │ │ Sue │ │ Tom │ │ │ │
│ │ │ └──────┘ └──────┘ │ │ │
│ │ │ [Prev] Page 1/2 [Next] [Browse...] [Refresh] │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ │ ↓ │ │
│ │ User clicks a cell → grid closes │ │
│ │ ↓ │ │
│ │ Click the stage → spawns the selected stickman │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
## 3. What's Being Replaced
| Before | After |
| ------------------------------------------- | ------------------------------------------------- |
| "Stickman" palette button spawns `test.stk` | "Stickman" palette button opens the selector grid |
| Separate "Crate", "Ball", "Plank" buttons | Single "Prop" button opens the prop selector grid |
| No visual preview | Thumbnail preview in each grid cell |
| No choice | Choose from all available assets |
| Hard-coded file path | Dynamic library from scanned files |
## 4. Feature Breakdown
### 4.1. Stickman Selector Grid
| Feature | Description |
| --------------- | ------------------------------------------------------------------------ |
| **Source** | Scans `res://stickmen/` for `.stk` files |
| **Thumbnails** | Renders each stickman to a texture (cached to `user://thumbnails/`) |
| **Display** | Grid cells: thumbnail + `stickman_name` (or filename if no name) |
| **Pagination** | 12 items per page (3 rows × 4 columns) with Prev/Next buttons |
| **Selection** | Click a cell → selected stickman is cached as the default |
| **Browse** | "Browse..." button opens a `FileDialog` to select a `.stk` from anywhere |
| **Refresh** | "Refresh" button rescans the `stickmen/` folder |
| **Empty state** | "No stickmen found! Create one in the editor first." |
### 4.2. Prop Selector Grid
| Feature | Description |
| -------------- | ------------------------------------------------------ |
| **Source** | Built-in prop templates (Crate, Ball, Plank, Triangle) |
| **Thumbnails** | Renders each prop with its material/color |
| **Display** | Grid cells: thumbnail + prop name + material badge |
| **Pagination** | 12 items per page with Prev/Next buttons |
| **Selection** | Click a cell → selected prop is cached as the default |
| **Future** | Custom props (`.prp` files) will appear here |
### 4.3. Single Palette Buttons
| Before | After |
| ----------------------- | ---------------------------- |
| "Stickman" (hard-coded) | "Stickman" (opens grid) |
| "Crate" (hard-coded) | Removed |
| "Ball" (hard-coded) | Removed |
| "Plank" (hard-coded) | Removed |
| (None) | **"Prop"** (opens prop grid) |
## 5. Visual Design
### 5.1. Grid Cell Layout
```
┌─────────────────────────────────────┐
│ ┌─────────────────────────────┐ │
│ │ │ │
│ │ [THUMBNAIL] │ │ ← 150×150 px preview
│ │ │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ Bob │ │ ← Name (bold, centered)
│ │ 🪵 Wood │ │ ← Material badge (props only)
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
```
**Cell size:** ~160×220 px (thumbnail 150×150, name area 60px)
### 5.2. Grid Popup Layout
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Choose Your Stickman [×] Close │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ 📷 │ │ 📷 │ │ 📷 │ │ 📷 │ │
│ │ Bob │ │Sally │ │ John │ │ Joe │ │
│ └──────┘ └──────┘ └──────┘ └──────┘ │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ 📷 │ │ 📷 │ │ 📷 │ │ 📷 │ │
│ │ Sue │ │ Tom │ │Alex │ │Jess │ │
│ └──────┘ └──────┘ └──────┘ └──────┘ │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ 📷 │ │ 📷 │ │ 📷 │ │ 📷 │ │
│ │Sam │ │Ella │ │Max │ │Mia │ │
│ └──────┘ └──────┘ └──────┘ └──────┘ │
│ │
│ [← Prev] Page 1/3 [Next →] [Browse...] [Refresh] │
│ │
│ Selected: Bob │
└─────────────────────────────────────────────────────────────────────────┘
```
### 5.3. Visual States
| State | Appearance |
| --------------------- | ------------------------------------- |
| **Default cell** | Light background, subtle border |
| **Hover** | Border highlight, slight scale (1.05) |
| **Selected** | Blue/cyan border, checkmark overlay |
| **Loading thumbnail** | Spinner or placeholder icon |
| **Empty cell** | "No preview available" placeholder |
## 6. Data Flow
### 6.1. Stickman Library Indexer
```gdscript
# stickman_library.gd (NEW)
class_name StickmanLibrary
extends RefCounted
# Scans res://stickmen/*.stk
# Returns Array[Dictionary] with:
# {
# "path": "res://stickmen/bob.stk",
# "name": "Bob",
# "thumbnail": Texture2D,
# "data": Dictionary # parsed .stk data (optional)
# }
```
### 6.2. Prop Library Registry
```gdscript
# prop_library.gd (NEW)
class_name PropLibrary
extends RefCounted
# Built-in prop templates:
# - Crate (Wood)
# - Ball (Rubber)
# - Plank (Metal)
# - Triangle (Cardboard)
# Returns Array[Dictionary] with:
# {
# "id": "crate",
# "name": "Crate",
# "factory": Callable (returns shape payload),
# "material_preset": PropBlock.MaterialPreset.WOOD,
# "thumbnail": Texture2D
# }
```
### 6.3. Thumbnail Generation
#### Stickman Thumbnails:
- Parse the .stk file to get body_parts and part_order.
- Create a temporary SubViewport (200×200 px).
- Render the stickman using a simplified version of WholeStickmanPreview logic.
- Capture the viewport as a Texture2D.
- Cache to user://thumbnails/stickmen/<hash>.png.
#### Prop Thumbnails:
- Instantiate a temporary PropBlock in a SubViewport.
- Apply the material preset and geometry.
- Capture the viewport as a Texture2D.
- Cache to user://thumbnails/props/<id>.png.
#### Caching Strategy:
- Thumbnails are generated once per file and cached to disk.
- On subsequent runs, load the cached thumbnail if it exists and the source file hasn't been modified.
- If the source file is modified, regenerate the thumbnail.
## 7. UI Integration
### 7.1. Palette Button Changes
#### Button Behavior
- "Stickman" Opens the Stickman Selector Grid
- "Prop" Opens the Prop Selector Grid
### 7.2. Selected Asset Persistence
- The last selected stickman is stored in StageSpawner.selected_stickman_path.
- The last selected prop is stored in StageSpawner.selected_prop_id.
- These persist across mode toggles and scene reloads (in memory only — no disk save in Phase 3b).
### 7.3. Placement Flow
```
User clicks "Stickman" palette button
Stickman Selector Grid opens
User clicks a stickman cell
Grid closes → selected stickman is cached
Stage enters placement mode (ghost appears)
User clicks the stage → spawns the selected stickman
```
## 8. File Structure
| File | Purpose |
| ---------------------------------------------- | ------------------------------------------------------ |
| res://scripts/stickman_library.gd | Scans .stk files, manages thumbnails |
| res://scripts/prop_library.gd | Registry of prop templates |
| res://scripts/asset_selector.gd | Grid UI controller (shared between stickmen and props) |
| res://scenes/asset_selector.tscn | Grid popup scene |
| res://scripts/thumbnails/stickman_thumbnail.gd | Renders stickman to texture |
| res://scripts/thumbnails/prop_thumbnail.gd | Renders prop to texture |
| res://user://thumbnails/ | Cached thumbnails (generated at runtime) |
## 9. Acceptance Criteria
### 9.1. Stickman Selector Grid
- [ ] Clicking "Stickman" palette button opens the grid.
- [ ] Grid displays all .stk files in res://stickmen/.
- [ ] Each cell shows a thumbnail preview of the stickman.
- [ ] Each cell shows the stickman's stickman_name (or filename if no name).
- [ ] Clicking a cell selects that stickman and closes the grid.
- [ ] The selected stickman persists for future placements.
- [ ] Pagination works when more than 12 stickmen exist.
- [ ] "Browse..." button opens a FileDialog to select any .stk.
- [ ] "Refresh" button rescans the stickmen/ folder.
- [ ] If no .stk files exist, shows "No stickmen found! Create one in the editor first."
- [ ] If only one .stk exists, the grid is skipped and the stickman is selected directly.
- [ ] Thumbnails are cached to user://thumbnails/ and reused.
- [ ] Modified .stk files regenerate their thumbnails.
### 9.2. Prop Selector Grid
- [ ] Clicking "Prop" palette button opens the grid.
- [ ] Grid displays all built-in prop templates.
- [ ] Each cell shows a thumbnail preview of the prop.
- [ ] Each cell shows the prop name and material badge.
- [ ] Clicking a cell selects that prop and closes the grid.
- [ ] The selected prop persists for future placements.
- [ ] Pagination works when more than 12 props exist.
- [ ] Thumbnails are cached to user://thumbnails/props/.
### 9.3. Palette Integration
- [ ] The old "Crate", "Ball", and "Plank" buttons are removed.
- [ ] A single "Prop" button replaces them.
- [ ] The "Stickman" button now opens the grid (instead of spawning test.stk).
### 9.4. Performance
- [ ] Scanning 50+ .stk files does not stall the UI.
- [ ] Thumbnail generation is non-blocking (or uses a loading state).
- [ ] Grid opens and paginates smoothly.
## 10. Implementation Order
| Step | Task | Dependencies |
| ---- | ---------------------------------------------------------- | ------------ |
| 1 | Create stickman_library.gd (file scanner + entry model) | None |
| 2 | Create stickman_thumbnail.gd (renders stickman to texture) | Step 1 |
| 3 | Create asset_selector.gd / asset_selector.tscn (grid UI) | Steps 1-2 |
| 4 | Integrate selector with "Stickman" palette button | Step 3 |
| 5 | Create prop_library.gd (built-in prop registry) | None |
| 6 | Create prop_thumbnail.gd (renders prop to texture) | Step 5 |
| 7 | Integrate selector with "Prop" palette button | Steps 5-6 |
| 8 | Remove old "Crate", "Ball", "Plank" buttons | Step 7 |
| 9 | Implement thumbnail caching | Steps 2, 6 |
## 11. Edge Cases
| Edge Case | Handling |
| -------------------------- | --------------------------------------------------------- |
| No .stk files exist | Show "No stickmen found! Create one in the editor first." |
| Only one .stk exists | Skip the grid, select it directly |
| Thumbnail generation fails | Show placeholder icon + regenerate on next run |
| File is corrupted/invalid | Skip the file, log a warning |
| Stickman has no name | Use the filename (without .stk) |
| Many files (50+) | Pagination keeps the UI responsive |
| Grid size changes | Rebuild the grid layout on resize |
| Selected file is deleted | Reselect the first available file (or show empty state) |
## 12. Summary
| Before | After |
| --------------------------------- | ------------------------------------- |
| Hard-coded test.stk | Visual grid of all .stk files |
| Separate Crate/Ball/Plank buttons | Single "Prop" button with visual grid |
| No preview | Thumbnail preview in every cell |
| No choice | Choose from all available assets |
| No caching | Thumbnails cached to user:// |
This is the final piece connecting the Stickman Editor to the Sandbox Stage. Users can now create stickmen in the editor, save them as .stk, and pick them from the grid when placing actors on the stage.
+593
View File
@@ -0,0 +1,593 @@
# Phase 3c: Editor Tools — Action & Rule Editing
## 1. Overview
Phase 3c adds **full editing capabilities** for both the Director Tool's action queues and the Event System's rules. Currently, directors can only **append** actions and **create/delete** rules. They cannot fix mistakes, change order, or modify existing items. This phase makes the entire script **fully editable**.
The plan combines action editing and rule editing into a single, cohesive implementation with a logical progression from simpler to more complex features.
---
## 2. Core Principles
### 2.1. Extensibility First
The system is built to accommodate future growth:
| Future Addition | How It's Supported |
| ---------------------- | --------------------------------------------------------- |
| New action types | Registry pattern — add action template, UI auto-generates |
| New rule trigger types | Registry pattern — add trigger type, UI auto-generates |
| New action properties | Action data model is a Dictionary — add new keys freely |
| New rule properties | Rule data model is a Dictionary — add new keys freely |
| New visual styles | Theme JSON already supports font/color overrides |
### 2.2. Consistency
The UI patterns for action editing and rule editing are **identical**:
| Pattern | Action Edition | Rule Edition |
| ------------ | ----------------------------------- | ---------------------------------- |
| Entry points | Popup menu + Right-click + Waypoint | Rule label + Stickman context menu |
| Panel UI | Action Queue Panel | Rule Panel |
| Editor Popup | Action Editor | Rule Editor |
| Controls | [✎] Edit, [✕] Delete, [≡] Reorder | Same |
| Drag handles | Reorder actions | Reorder rules |
---
## 3. Implementation Order
The plan is organized into **5 phases**, each building on the previous:
| Phase | Focus | Deliverables |
| -------------- | ------------------------- | -------------------------------------------- |
| **Phase 3c.1** | **Foundation** | Shared UI components, extensible data models |
| **Phase 3c.2** | **Action Queue Panel** | View, edit, delete, reorder actions |
| **Phase 3c.3** | **Action Visual Editing** | Waypoint context menu, visual walk editing |
| **Phase 3c.4** | **Rule Panel** | View, edit, delete, reorder rules |
| **Phase 3c.5** | **Rule Visual Editing** | Rule label click, waypoint trigger editing |
---
## 4. Phase 3c.1: Foundation
### 4.1. Extensible Action Registry
The action system should be registry-driven to support future action types:
```gdscript
# action_registry.gd (NEW)
const ACTION_TEMPLATES = {
"walk_to": {
"label": "Walk To",
"icon": "🚶",
"params": [
{ "key": "target", "type": "position", "required": true }
]
},
"speak": {
"label": "Speak",
"icon": "💬",
"params": [
{ "key": "text", "type": "text", "required": true },
{ "key": "duration", "type": "float", "default": 2.0 }
]
},
"wait": {
"label": "Wait",
"icon": "",
"params": [
{ "key": "duration", "type": "float", "required": true }
]
},
"ragdoll": {
"label": "Ragdoll",
"icon": "💥",
"params": []
},
"recover": {
"label": "Recover",
"icon": "🔄",
"params": []
}
}
```
**Extensibility:** Adding a new action type = appending to `ACTION_TEMPLATES`. No other code changes required.
### 4.2. Extensible Trigger Registry
Similarly, trigger types are registry-driven:
```gdscript
# trigger_registry.gd (NEW)
const TRIGGER_TEMPLATES = {
"arrived_at_waypoint": {
"label": "Arrives at waypoint",
"icon": "📍",
"target_type": "waypoint"
},
"action_finished": {
"label": "Completes any action",
"icon": "",
"target_type": "action_type"
},
"speech_finished": {
"label": "Finishes speaking",
"icon": "💬",
"target_type": "none"
},
"entered_area": {
"label": "Enters trigger area",
"icon": "🎯",
"target_type": "area"
},
"collided": {
"label": "Collides with something",
"icon": "💥",
"target_type": "prop"
}
}
```
### 4.3. Shared UI Components
| Component | Purpose | Reused By |
| ----------------------- | ----------------------------- | ------------------------------ |
| **Panel Container** | Scrollable list of items | Action Queue Panel, Rule Panel |
| **Editor Popup** | Edit single item's properties | Action Editor, Rule Editor |
| **Drag Handle** | Reorder items | Both panels |
| **Delete Confirmation** | Confirm before deletion | Both panels |
### 4.4. Extensible Action Properties
Actions are stored as Dictionaries, so future properties can be added without breaking existing code:
```gdscript
# Current action
{ "type": "speak", "text": "Hello", "duration": 2.0 }
# Future action (with text color)
{ "type": "speak", "text": "Hello", "duration": 2.0, "text_color": "#ff0000" }
```
**Extensibility:** New properties are just new keys in the Dictionary. The editor should display editable fields for all known keys and gracefully ignore unknown ones.
---
## 5. Phase 3c.2: Action Queue Panel
### 5.1. Feature Breakdown
| Feature | Description |
| ------------------- | ------------------------------------------- |
| **Queue Panel** | Popup showing all actions for a stickman |
| **Edit Action** | Re-open action popup with pre-filled values |
| **Delete Action** | Remove action from queue (confirmation) |
| **Reorder Actions** | Drag handle to reorder |
| **Add Action** | Append new action from panel |
| **Clear All** | Remove all actions (confirmation) |
### 5.2. Entry Points
| Entry Point | When to Use | How it Works |
| --------------- | -------------------------------- | ------------------------------------------- |
| **Popup** | Edit queue for selected stickman | Click stickman → "Edit Queue" → panel opens |
| **Right-click** | Quick access | Right-click stickman → "Edit Queue" |
### 5.3. UI Layout
```
┌─────────────────────────────────────────────────────────────────────┐
│ ACTION QUEUE PANEL │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Stickman: Bob [×] Close │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ───────────────────────────────────────────────────────── │ │
│ │ 1 🚶 Walk to Crate [✎] [✕] [≡] │ │
│ │ 2 💬 Speak "Hello!" [✎] [✕] [≡] │ │
│ │ 3 ⏳ Wait 2.0s [✎] [✕] [≡] │ │
│ │ 4 💥 Ragdoll [✎] [✕] [≡] │ │
│ │ 5 🔄 Recover [✎] [✕] [≡] │ │
│ │ ───────────────────────────────────────────────────────── │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ [Add Action] [Clear All] │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### 5.4. Edit Action Flow
1. User clicks **[✎]** on an action.
2. The **same action popup** appears, with current values pre-filled.
3. User makes changes.
4. Click **OK** → action is updated in the queue.
5. Visuals update immediately.
| Action Type | Pre-filled Values |
| ----------- | -------------------------------------------------- |
| **Walk To** | Current target position (waypoint dot highlighted) |
| **Speak** | Current text and duration |
| **Wait** | Current duration |
| **Ragdoll** | (No parameters) |
| **Recover** | (No parameters) |
---
## 6. Phase 3c.3: Action Visual Editing
### 6.1. Feature Breakdown
| Feature | Description |
| ------------------------- | ----------------------------------------- |
| **Waypoint Context Menu** | Right-click waypoint → Edit/Delete/Insert |
| **Edit Walk (Visual)** | Click a new position → waypoint moves |
| **Insert Action** | Insert before/after a specific waypoint |
### 6.2. Waypoint Context Menu
In Edit mode, right-clicking a waypoint dot opens:
```
Right-click Waypoint 3:
┌─────────────────────────────────────────────┐
│ ✎ Edit this Walk │
│ ✕ Delete this Walk │
│ ⬆ Insert action before │
│ ⬇ Insert action after │
└─────────────────────────────────────────────┘
```
| Action | Behavior |
| ------------------------ | -------------------------------------------------------------- |
| **Edit this Walk** | Enters target placement mode → click new spot → waypoint moves |
| **Delete this Walk** | Removes the walk action from the queue |
| **Insert action before** | Opens action popup → inserts new action before this one |
| **Insert action after** | Opens action popup → inserts new action after this one |
### 6.3. Visual Update Flow
```
User right-clicks waypoint
Context menu appears
User clicks "Edit this Walk"
Stage enters target placement mode
Current waypoint is highlighted (blinking)
User clicks new position on stage
Old waypoint removed, new waypoint appears
walk_to action's target is updated
Dotted lines reconnect
Order numbers remain the same
```
---
## 7. Phase 3c.4: Rule Panel
### 7.1. Feature Breakdown
| Feature | Description |
| -------------------------------- | --------------------------------------------- |
| **Rule Panel** | Popup showing all rules for a source stickman |
| **Edit Rule (Full)** | Edit trigger type, trigger target, action(s) |
| **Edit Rule (Consequence-Only)** | Quick edit of actions only |
| **Delete Rule** | Remove rule from registry (confirmation) |
| **Reorder Rules** | Drag handle to reorder evaluation order |
| **Add Action to Rule** | Add another action to an existing rule |
| **Remove Action from Rule** | Delete an action from a rule |
| **Clear All** | Remove all rules (confirmation) |
### 7.2. Entry Points
| Entry Point | When to Use | How it Works |
| -------------------- | ----------------------------- | ------------------------------------------------- |
| **Rule Label** | Edit a specific rule | Click the rule label (dashed line) → editor opens |
| **Stickman Context** | View all rules for a stickman | Right-click stickman → "Edit Rules" → panel opens |
### 7.3. Rule Panel UI
```
┌─────────────────────────────────────────────────────────────────────┐
│ RULE PANEL │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Source: Stickman A [×] Close │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ───────────────────────────────────────────────────────── │ │
│ │ 1 📍 When A arrives at Waypoint 3 │ │
│ │ → B speaks "Hello there!" [✎] [✕] [≡] │ │
│ │ │ │
│ │ 2 📍 When A arrives at Waypoint 5 │ │
│ │ → C walks to crate [✎] [✕] [≡] │ │
│ │ │ │
│ │ 3 🎯 When A enters Area │ │
│ │ → All stickmen ragdoll [✎] [✕] [≡] │ │
│ │ ───────────────────────────────────────────────────────── │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ [Add Rule] [Clear All] │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### 7.4. Rule Editor (Full)
Opens when user clicks **[✎]** on a rule in the panel:
```
┌─────────────────────────────────────────────────────────────────────┐
│ EDIT RULE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Trigger: │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ [📍 Arrives at waypoint ▼] [Click target →] │ │
│ │ Target: Waypoint 3 (on stage) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ Actions: │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 1 💬 B speaks "Hello there!" [✎] [✕] │ │
│ │ 2 🚶 C walks to crate [✎] [✕] │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ [Add Action] [Cancel] [OK] │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### 7.5. Rule Editor (Consequence-Only)
Opens when user clicks a rule label on the stage:
```
┌─────────────────────────────────────────────────────────────────────┐
│ EDIT RULE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ When: 📍 A arrives at Waypoint 3 (read-only) │
│ ────────────────────────────────────────────────────────────── │
│ Then: │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 1 💬 B speaks "Hello there!" [✎] [✕] │ │
│ │ 2 🚶 C walks to crate [✎] [✕] │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ [Add Action] [Done] [Cancel] │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
**Focus:** The trigger is displayed but **read-only**. For trigger editing, use the full rule editor.
---
## 8. Phase 3c.5: Rule Visual Editing
### 8.1. Feature Breakdown
| Feature | Description |
| ---------------------------- | ------------------------------------------- |
| **Rule Label Click** | Click rule label → consequence-only editor |
| **Waypoint → Rules** | Right-click waypoint → "Edit Trigger Rules" |
| **Rule Reordering (Visual)** | Evaluation order shown on stage (optional) |
### 8.2. Waypoint → Rules
In Edit mode, right-clicking a waypoint dot that is used as a trigger target:
```
Right-click Waypoint 3:
┌─────────────────────────────────────────────┐
│ ✎ Edit this Walk (action) │
│ ───────────────────────────────────────── │
│ ⚡ Edit Trigger Rules (2 rules) │
└─────────────────────────────────────────────┘
```
Clicking "Edit Trigger Rules" opens the Rule Panel filtered to rules using that waypoint.
---
## 9. File Structure
| File | Purpose |
| ----------------------------------- | ------------------------------------------------------ |
| `scripts/action_registry.gd` | Registry of action templates (extensible) |
| `scripts/trigger_registry.gd` | Registry of trigger templates (extensible) |
| `scripts/queue_panel.gd` | Action Queue Panel controller |
| `scripts/queue_panel.tscn` | Action Queue Panel scene |
| `scripts/rule_panel.gd` | Rule Panel controller |
| `scripts/rule_panel.tscn` | Rule Panel scene |
| `scripts/action_editor.gd` | Action Editor popup controller |
| `scripts/action_editor.tscn` | Action Editor popup scene |
| `scripts/rule_editor.gd` | Rule Editor popup controller (full + consequence-only) |
| `scripts/rule_editor.tscn` | Rule Editor popup scene |
| `scripts/waypoint_context.gd` | Waypoint right-click menu |
| `scripts/sandbox_stage.gd` | Add "Edit Queue", "Edit Rules", integrate editors |
| `scripts/stage_director_visuals.gd` | Waypoint hit-testing, rule label click → editor |
---
## 10. Extensibility Guide
### 10.1. Adding a New Action Type
```gdscript
# 1. Add to action_registry.gd
const ACTION_TEMPLATES = {
# ... existing actions ...
"jump": {
"label": "Jump",
"icon": "🦘",
"params": [
{ "key": "height", "type": "float", "default": 100.0 },
{ "key": "duration", "type": "float", "default": 0.5 }
]
}
}
```
# 2. Implement the action in StickmanRig.\_process_queue()
# 3. Add the action to the popup (automatically from registry)
### 10.2. Adding a New Rule Trigger Type
```gdscript
# 1. Add to trigger_registry.gd
const TRIGGER_TEMPLATES = {
# ... existing triggers ...
"variable_changed": {
"label": "Variable changes",
"icon": "📊",
"target_type": "variable"
}
}
```
# 2. Emit the trigger signal from SandboxStage
# 3. Add the trigger to the rule builder (automatically from registry)
### 10.3. Adding a New Action Property
```gdscript
# 1. The action is stored as a Dictionary
{ "type": "speak", "text": "Hello", "duration": 2.0, "text_color": "#ff0000" }
# 2. The Action Editor reads all keys from params
# 3. Unknown keys are displayed as read-only (or editable with generic control)
# 4. The action runner reads the new key when executing
```
---
## 11. Acceptance Criteria
### 11.1. Action Queue Panel
-"Edit Queue" opens panel from popup and right-click.
-Panel shows all actions in order.
-Each action shows type, parameters, and order number.
-[✕] deletes action (confirmation dialog).
-[✎] opens edit popup with pre-filled values.
-[≡] reorders actions.
-"Clear All" removes all actions (confirmation).
-"Add Action" appends a new action.
### 11.2. Edit Action
- Walk To: Edit opens target placement mode; clicking new spot updates waypoint.
- Speak: Edit opens text dialog with current text pre-filled.
- Wait: Edit opens duration dialog with current value pre-filled.
- Ragdoll/Recover: Edit opens confirmation dialog.
### 11.3. Waypoint Context Menu
- Right-click waypoint opens context menu.
- "Edit this Walk" enters target placement mode.
- "Delete this Walk" removes the action.
- "Insert action before/after" inserts new action.
### 11.4. Rule Panel
- "Edit Rules" opens panel from stickman context menu.
- Panel shows all rules where stickman is trigger source.
- Each rule shows trigger type, target, and action(s).
- `[✕] deletes rule (confirmation).
- `[✎] opens rule editor with pre-filled values.
- `[≡] reorders rules.
- "Add Rule" opens rule builder.
- "Clear All" removes all rules (confirmation).
### 11.5. Rule Editor
- Full editor: trigger type dropdown works, trigger target can be clicked.
- Full editor: action type dropdown works, target can be clicked.
- Full editor: parameters (text, duration) can be edited.
- Consequence-only editor: trigger is read-only.
- Consequence-only editor: actions can be edited.
- Multi-action rules: add/remove actions.
- Saving updates rule in event registry.
- Visual connectors update immediately.
### 11.6. Visual Updates
- Waypoint dots move when Walk actions are edited.
- Speech badge text updates when Speak actions are edited.
- Wait duration updates.
- Dotted lines reconnect to reflect new order.
- Order numbers update after insert/delete/reorder.
- Rule labels update with new summaries.
- Dashed lines reconnect to new targets.
### 11.7. Backward Compatibility
- Existing queues load and display correctly.
- Editing preserves action types and parameters.
- Existing rules load and display correctly.
- Editing a rule preserves the rule ID.
- Deleting an action/rule cleans up all references.
### 11.8. Extensibility
- New action types can be added via registry (no code changes required).
- New trigger types can be added via registry.
- New action properties are supported (dictionary keys).
- Editor gracefully handles unknown keys.
---
## 12. Implementation Order Summary
| Phase | Focus | Key Deliverables |
| -------- | --------------------- | ------------------------------------------------------- |
| **3c.1** | Foundation | Action Registry, Trigger Registry, Shared UI Components |
| **3c.2** | Action Queue Panel | Queue panel, edit/delete/reorder actions |
| **3c.3** | Action Visual Editing | Waypoint context menu, visual walk editing |
| **3c.4** | Rule Panel | Rule panel, full/consequence-only edit, reorder |
| **3c.5** | Rule Visual Editing | Rule label click, waypoint trigger rules |
---
## 13. Summary
| Before | After |
| ------------------------------------ | --------------------------------------------- |
| Actions can only be appended | Actions can be edited, deleted, and reordered |
| Rules can only be created or deleted | Rules can be edited, deleted, and reordered |
| No way to fix mistakes | Edit any parameter |
| No way to change order | Drag to reorder |
| No visual editing | Waypoint context menu + visual walk editing |
| No chain reaction editing | Add/remove actions from rules |
| Fixed evaluation order | Drag to reorder rules |
| Tightly coupled code | Registry-driven, extensible architecture |
**This completes the Director Tool's full editing capabilities.** Directors can now create, edit, delete, and reorder both actions and rules with full control and extensibility for future development.
+565
View File
@@ -0,0 +1,565 @@
# Phase 3c — Editor Tools: Action & Rule Editing (Spec)
Status: SPEC (pending implementation)
Related plan: `plans/PHASE_3c_EDITOR.md`
Target: Godot **4.7** (`project.godot:19` declares `config/features=PackedStringArray("4.7", "Forward Plus")`).
---
## 1. Overview & Scope
Phase 3c makes the Sandbox Stage Director's action queues and the Event System rules
**fully editable** (edit / delete / reorder / insert), replacing the current
append-only + create/delete-only behavior. It is built on two registry-driven tables
(action + trigger) so the UI is generated from data rather than hard-coded `match`
statements, matching the existing `StageSpawner._registry` / `PropLibrary` patterns.
### In scope
- Action + trigger **registries** (`action_registry.gd`, `trigger_registry.gd`).
- **Action Queue Panel** — view / edit / delete / reorder / add / clear a stickman's queue.
- **Action visual editing** — waypoint right-click context menu (edit walk / delete walk /
insert before / insert after).
- **Rule Panel** — view / edit / delete / reorder / add / clear rules (filtered by source stickman).
- **Rule Editor** — full edit (trigger type + target + actions) and consequence-only edit
(actions only, trigger read-only).
- **Rule visual editing** — rule-label click → consequence-only editor (already partially
present via `_begin_edit_rule`); waypoint → "Edit Trigger Rules".
### Out of scope / untouched (must not regress)
- **StickmanRig runner execution semantics** — the action runner (`_begin_action`,
`_update_runner`, the 5 action phases) is **not** changed. Editing mutates queue/rule
*data*; the runner already consumes any of those shapes.
- **Event engine matching** (`_rule_matches`, `_update_area_entry`,
`_update_stickman_prop_collision`) — unchanged. Rule *reordering* changes evaluation
order (array order) but not the per-rule matching logic.
- **Navigation / walk steering** (`_update_walking`, mode latch) — unchanged.
- **Terrain drag-painting, selection, gizmos, placement ghost, asset selector** — unchanged.
- **No `.stk` / `settings.json` format changes.** Queues and rules remain session-only
(persist across EDIT ⇄ DIRECT ⇄ PLAY, reset on scene reload) — no disk save, matching the
Phase 3a/4 decision.
- **No new action types / trigger types** are implemented (only the *machinery* to add them
cleanly). The registry is the extension point; wiring a genuinely new action still requires
a runner case in `StickmanRig._begin_action` (see §6 and §13).
---
## 2. Recorded Decisions
1. **Registries are static tables, not singletons.** `action_registry.gd` /
`trigger_registry.gd` are `class_name`-less-optional, `RefCounted` scripts exposing
`static` const tables + `static func` accessors (mirroring `PropLibrary`). `sandbox_stage.gd`
preloads them like the other `preload` consts. No autoload, no instance state.
2. **All new UI is code-built; no `.tscn` files.** The plan lists `queue_panel.tscn`,
`rule_panel.tscn`, `action_editor.tscn`, `rule_editor.tscn`. The codebase builds all stage
UI in code inside `_build_ui()` (top bar, popups, dialogs); only `AssetSelector` uses a
`.tscn` shell, and it has authored content. These panels/editors are **dynamic** (row lists
change every mutation), so they are `PopupPanel`-based controller scripts constructed in
code. **Dropped**: `action_editor.gd`/`action_editor.tscn` (action param editing reuses the
existing `_speak_dialog`/`_wait_dialog` + pending-target machinery) and
`waypoint_context.gd` (the waypoint menu is a code-built `PopupMenu`, exactly like
`_trigger_popup`). See §7 file list.
3. **Reorder UX = Move Up / Move Down buttons, not drag.** A `[≡]` drag handle in a `PopupPanel`
requires hand-rolled `_gui_input` drag/reorder hit-testing; up/down buttons are simpler,
keyboard/gamepad accessible (matches the architecture's focus-navigation rule), and testable
headlessly. Each row shows `⬆`/`⬇` (disabled at the ends) plus `✎`/`✕`. Drag reorder is
logged as deferred (tech debt §13).
4. **Waypoint → action mapping returns `(rig, index, pos)`, not just a position.**
`StageDirectorVisuals.hit_test_waypoint()` currently returns only the nearest `Vector2`
(used by the rule builder for `arrived_at_waypoint`). A new `hit_test_waypoint_action()`
returns `{rig, index, pos}` so the context menu knows **which rig's queue and which
`walk_to` action index** to edit/delete/insert around. The existing position-only method is
kept for the rule builder (rules reference waypoint *positions*, not indices).
5. **Insert before/after maps directly to `StickmanRig.insert_action(index, action)`.**
"Before waypoint *i*" → `insert_action(i, action)`; "after" → `insert_action(i + 1, action)`.
`i` is the **queue index** of the `walk_to` action (equal to the waypoint ordinal 1 because
`walk_to` is the only waypoint-producing action). `insert_action` already clamps to `[0, size]`.
6. **Edit-walk visual flow reuses the pending target-capture machinery.** A new edit state
(`_pending_walk_edit_index >= 0`) reuses `_pending_walk_target`'s cursor/hint/trajectory and
the `_handle_direct_click`/`_handle_world_click` routing; the next stage click **replaces**
the existing `walk_to.target` instead of appending. The edited waypoint is highlighted in
the director visuals.
7. **Rule reorder = array order; `id` is immutable and preserved.** `_event_rules` is iterated
in order by `_handle_event` (all matching rules execute — no short-circuit). Reordering swaps
array entries; editing preserves `id` (already the case in `_finalize_rule`). `_next_rule_id`
stays monotonic (no id reuse on delete).
8. **Rule Panel filter = `trigger.source`.** "Edit Rules" on a stickman shows rules whose
`trigger.source` equals that stickman's instance id. The waypoint "Edit Trigger Rules" filter
matches by `params.waypoint_pos` proximity (`WAYPOINT_MATCH_EPSILON`), because rules store
waypoint **positions**, not queue indices.
9. **Delete confirmation policy (deliberately asymmetric).** Panel-initiated `✕` deletes and
"Clear All" (queue and rules) confirm via `ConfirmationDialog`. The on-stage rule-label `✕`
and the waypoint "Delete this Walk" stay **immediate** (current low-friction behavior,
not regressed). Documented in §13.
10. **Ragdoll/Recover have no editable parameters.** Their `✎` is hidden in the panel and their
"edit" is a no-op; "Add Action" for them appends immediately. (Plan §11.2's "Edit opens
confirmation dialog" is corrected — there is nothing to edit.)
11. **The full rule editor drives the existing `RuleStep` state machine.** Trigger-type change
re-enters `SELECT_TRIGGER`; trigger-target change re-enters `TRIGGER_TARGET`; "Add Action"
uses the existing `SELECT_ACTION → ACTION_TARGET → (PARAMS | ACTION_POSITION) → rule-more`
flow. The editor panel is a *view*; `sandbox_stage.gd` remains the *controller* owning
`_rule_builder`/`_rule_step`/`_event_rules`.
---
## 3. Discrepancies: Plan vs. Actual Code
| # | Plan claim | Reality (verified) | Resolution |
|---|---|---|---|
| 1 | Panels/editors are new `.tscn` scenes (`queue_panel.tscn`, `rule_panel.tscn`, `action_editor.tscn`, `rule_editor.tscn`, `waypoint_context.gd`). | Stage UI is built entirely in code (`_build_ui`); only `AssetSelector` has a `.tscn` shell. | Code-built `PopupPanel` scripts (§2.2); drop `action_editor.gd` + `waypoint_context.gd` (§2.2). |
| 2 | Registry `params` with `key`/`type`/`default` drives the editor generically. | Action data lives in **two** shapes: queue action (`walk_to.target` top-level; `speak.text`/`.duration` top-level; `wait.duration` top-level) vs. rule action (`{type, target, params:{...}}`). No generic param model exists. | Registry describes *logical* params; `get_fields`/`make_action` normalize the two shapes (§5.3). |
| 3 | "New action type = append to registry; **no code changes**" (§10.1, §11.8). | `StickmanRig._begin_action` hard-codes the 5 types; a new type also needs a runner case + `_action_for_rig` + `_action_desc`. | Corrected: registry removes *UI* changes; runner changes still required (§6, §12.7). |
| 4 | `hit_test_waypoint()` returns an index. | It returns only `Vector2` (nearest waypoint position); no rig/index identity. | Add `hit_test_waypoint_action()` returning `{rig, index, pos}` (§2.4, §9.2). |
| 5 | "Rule label click → consequence-only edit already exists" (implied complete). | `_begin_edit_rule(id)` pre-populates and jumps straight to the add-action popup (`SELECT_ACTION`), preserving the trigger but offering **no actions list / remove / edit** and no trigger readout UI. | New `RuleEditor` (consequence-only mode) formalizes this; `_begin_edit_rule` routes to it (§11.4). |
| 6 | `action_finished` trigger has a `target_type: "action_type"` (registry). | The builder does **not** expose an action-type selector for `action_finished` (only "completes any action"); `_rule_matches` reads optional `params.action_type`. | Registry marks `action_type` as optional; the full editor exposes it **only if cheap** — otherwise the trigger keeps "any action" and the field is documented as future (deferred, §13). |
| 7 | Rule Panel "shows all rules for a source stickman" and reorders. | `_event_rules` is a flat stage-level array (no per-rig grouping); source is `trigger.source` (instance id). | Filter by `trigger.source` (§2.8); reorder operates on the flat array (§2.7). |
| 8 | §11 acceptance criteria formatting: stray backticks and `-` prefixes. | Cosmetic markdown errors in the plan. | Rewritten cleanly in §12. |
| 9 | Plan §11.2 "Ragdoll/Recover: Edit opens confirmation dialog." | Ragdoll/recover are param-less; editing is meaningless. | Corrected (§2.10). |
| 10 | Plan §11.8 "unknown keys displayed as read-only / editable." | No generic editor exists; the runner ignores unknown keys. | Corrected: unknown keys are **preserved** (round-tripped) on edit, never dropped; no generic widget (§12.7). |
| 11 | Waypoint context menu / stickman right-click are new. | Right-click is currently fully consumed by the EDIT placement-cancel branch (`_handle_world_click` returns on RMB). | Insert waypoint + stickman right-click routing in that branch before the placement-cancel (§10.1). |
| 12 | Godot "4.4" (task prompt). | `project.godot:19` and the test runner reference **4.7**. | Spec targets 4.7. |
---
## 4. New Files
| File | `class_name` / extends | Responsibility |
|---|---|---|
| `res://scripts/action_registry.gd` | `ActionRegistry` / `RefCounted` | Static action template table + `static` helpers (labels/icons/params/describe/normalize). |
| `res://scripts/trigger_registry.gd` | `TriggerRegistry` / `RefCounted` | Static trigger template table + `static` helpers. |
| `res://scripts/queue_panel.gd` | `QueuePanel` / `PopupPanel` | Code-built panel listing one stickman's queue; emits edit/delete/move/add/clear signals. |
| `res://scripts/rule_panel.gd` | `RulePanel` / `PopupPanel` | Code-built panel listing rules (filtered by source); emits edit/delete/move/add/clear signals. |
| `res://scripts/rule_editor.gd` | `RuleEditor` / `PopupPanel` | Code-built full + consequence-only rule editor; drives the stage's rule-builder state machine via signals. |
No `.tscn` files are added (decision 2). `action_editor.gd` and `waypoint_context.gd` from the
plan are **not** created — their responsibilities are absorbed into `sandbox_stage.gd` (existing
dialogs + a code-built `PopupMenu`).
---
## 5. Data Contracts
### 5.1 Queue action dict (consumed by `StickmanRig` runner — top-level keys)
```gdscript
{ "type": "walk_to", "target": Vector2 } # target = feet/ground world position
{ "type": "speak", "text": String, "duration": float }
{ "type": "wait", "duration": float }
{ "type": "ragdoll" }
{ "type": "recover" }
# optional, ignored by the editor: "speed": float (walk_to), "reactive": bool (event-injected)
```
### 5.2 Rule dict (stored in `SandboxStage._event_rules`)
```gdscript
{
"id": int, # immutable, monotonic from _next_rule_id
"trigger": {
"type": String, # arrived_at_waypoint | action_finished |
# speech_finished | entered_area | collided
"source": int, # instance id of the triggering stickman
"target": int, # instance id (entered_area area | collided prop);
# -1 otherwise
"params": {
# arrived_at_waypoint -> { "waypoint_pos": Vector2 }
# action_finished -> { "action_type": String } (optional; "" == any)
# else -> {}
},
},
"actions": [ { "type": String, "target": int, "params": {...} } ],
}
```
Rule action `params`:
```gdscript
walk_to -> { "target": Vector2 } # destination (the action's `target` = walking stickman id)
speak -> { "text": String, "duration": float }
wait -> { "duration": float }
ragdoll / recover -> {}
```
> **Key asymmetry (documented):** in a **queue** action `walk_to`, `target` is the destination.
> In a **rule** action, `target` is the *stickman instance id* and the destination is
> `params.target`. The registry normalizes this via `get_fields`/`make_action` (§5.3).
### 5.3 ActionRegistry API (`action_registry.gd`)
```gdscript
static func get_types() -> Array[String]
# ["walk_to", "speak", "wait", "ragdoll", "recover"] (stable order == popup order)
static func get_label(type: String) -> String # "Walk To", "Speak", "Wait", "Ragdoll", "Recover"
static func get_icon(type: String) -> String # "🚶","💬","⏳","💥","🔄"
static func get_params(type: String) -> Array[Dictionary]
# [{ "key": "target", "kind": "position", "required": true }]
# [{ "key": "text", "kind": "text", "required": true },
# { "key": "duration", "kind": "float", "default": 2.0 }]
# [{ "key": "duration", "kind": "float", "required": true }]
# [] for ragdoll/recover
static func has_params(type: String) -> bool
static func get_fields(action: Dictionary) -> Dictionary
# reads each param key from action top-level, falling back to action.params
# (walk_to.target top-level in queue, params.target in rule -> both yield {"target": v})
static func make_action(type: String, fields: Dictionary, for_rule: bool) -> Dictionary
# for_rule=false -> { "type": type, ...top-level keys }
# for_rule=true -> { "type": type, "target": -1, "params": {...keys} }
static func describe(action: Dictionary) -> String
# "Walks", "Speak 'Hello'", "Wait 2s", "Ragdolls", "Recovers"
# (semantics identical to StageDirectorVisuals._action_desc; the registry is the new home)
static func row_summary(action: Dictionary) -> String
# panel row text: "<icon> <label> <param summary>"
```
### 5.4 TriggerRegistry API (`trigger_registry.gd`)
```gdscript
static func get_types() -> Array[String]
# ["arrived_at_waypoint", "action_finished", "speech_finished", "entered_area", "collided"]
static func get_label(type: String) -> String
static func get_icon(type: String) -> String
static func get_target_kind(type: String) -> String
# "waypoint" | "action_type" | "none" | "area" | "prop"
static func needs_target(type: String) -> bool
# true for arrived_at_waypoint (waypoint), entered_area (area), collided (prop)
static func describe(trigger: Dictionary) -> String
# "arrives", "completes an action", "finishes speaking", "enters area", "collides"
```
> `speech_finished` (`none`) and `action_finished` (`action_type`) are "no world target" triggers.
> `action_finished`'s optional `params.action_type` filter is **not** surfaced in the Phase 3c
> editor (the builder's current "completes any action" behavior is preserved) — deferred §13.
---
## 6. Extensibility Contract (corrected from plan §10)
- **New action type** = (1) append a template to `ActionRegistry` (label/icon/params),
(2) add a `_begin_action` case + (if it has params) a `_action_for_rig` mapping in
`StickmanRig`, (3) add a params dialog if it has new param kinds. The popups/panels/rows
then auto-generate. **The runner case is not optional** (plan §11.8 "no code changes" is false).
- **New trigger type** = (1) append a template to `TriggerRegistry`, (2) emit a matching event in
`SandboxStage` and add a `_rule_matches` case, (3) add a target-capture branch if it has a
world target. UI auto-generates from the registry.
- **New action property** = add a param entry + a widget; the editor must be taught the widget.
Unknown keys on load/edit are **preserved** (round-tripped), never dropped.
---
## 7. File-by-File Change List
### 7.1 `scripts/stickman_rig.gd` (modified — additive only)
- `func move_action(from: int, to: int) -> void` — bounds-check both (`push_warning` + return on
out-of-range), remove-at + insert-at (reusing `remove_action`/`insert_action` semantics but
emitting `queue_changed` **once**). Used by reorder up/down.
- `func replace_action(index: int, action: Dictionary) -> void` — bounds-check, `action_queue[index]
= action`, emit `queue_changed`. Used by edit (speak/wait/walk-retarget/ragdoll/recover).
No runner-phase changes; `get_queue()`, `clear_queue()`, `insert_action`, `remove_action`,
`enqueue_reactive`, `clear_reactive_actions` are reused as-is.
### 7.2 `scripts/stage_director_visuals.gd` (modified — additive)
- `func _collect_waypoint_refs() -> Array[Dictionary]` — mirror `_collect_waypoints` exactly but
return `{ "rig": StickmanRig, "index": int, "pos": Vector2 }` (index = queue index of the
`walk_to` action). Same world-child iteration order.
- `func hit_test_waypoint_action(world_pos: Vector2) -> Dictionary` — nearest ref within
`WAYPOINT_HIT_RADIUS_PX / _zoom()`; returns `{}` on miss. Keeps `hit_test_waypoint()` (still
used by the rule builder).
- `var edit_waypoint: Vector2 = Vector2.INF` + `func set_edit_waypoint(pos: Vector2) -> void`
(`mark_dirty()`). While finite, `_draw_waypoint` draws that dot with an accent outline/ring
(blinking is optional — a static accent + thicker ring is sufficient for the acceptance test;
a `_process`-driven blink is deferred). Cleared by the stage when edit-capture ends.
### 7.3 `scripts/sandbox_stage.gd` (modified — the controller)
New consts:
```gdscript
const ACT_EDIT_QUEUE := 7 # item ids added to _action_popup (after ACT_WHEN=5 / RULE_ACTION_DONE=6)
const ACT_EDIT_RULES := 8
const QUEUE_ADD_WALK := ACT_WALK # reuse 0..4 for the add-action popup
# ... (ACT_SPEAK/WAIT/RAGDOLL/RECOVER reused)
const WP_EDIT_WALK := 0
const WP_DELETE_WALK := 1
const WP_INSERT_BEFORE := 2
const WP_INSERT_AFTER := 3
const WP_EDIT_RULES := 4
const RIG_EDIT_QUEUE := 0
const RIG_EDIT_RULES := 1
```
New state:
```gdscript
var _pending_walk_edit_index: int = -1 # >=0 while retargeting an existing walk_to
var _editing_action_index: int = -1 # queue index being edited via speak/wait dialog
var _queue_add_context: StickmanRig = null # target rig for "Add Action" from the queue panel
var _waypoint_context: Dictionary = {} # {rig, index} for the open waypoint context menu
var _queue_panel: QueuePanel = null
var _rule_panel: RulePanel = null
var _rule_editor: RuleEditor = null
var _rig_context_popup: PopupMenu = null
var _waypoint_context_popup: PopupMenu = null
var _queue_add_popup: PopupMenu = null
var _confirm_dialog: ConfirmationDialog = null # shared, repurposed per action (with a pending closure)
var _confirm_action: Callable = Callable() # what to run on confirm
```
New signal wiring in `_build_ui()`:
- Add `_rig_context_popup` (`PopupMenu`: "📋 Edit Queue…", "📋 Edit Rules…"), `_waypoint_context_popup`
(`PopupMenu`: "✎ Edit this Walk", "✕ Delete this Walk", "⬆ Insert action before",
"⬇ Insert action after", "⚡ Edit Trigger Rules (N)"), `_queue_add_popup` (`PopupMenu`: the 5
action types), and a shared `_confirmation_dialog`. Apply `_apply_popup_theme` to the popups.
- Append `_action_popup.add_separator()` + `add_item("📋 Edit Queue…", ACT_EDIT_QUEUE)` +
`add_item("📋 Edit Rules…", ACT_EDIT_RULES)`.
- Instantiate `_queue_panel`, `_rule_panel`, `_rule_editor` (code-built; add to the `ui`
CanvasLayer) and connect their signals (below). Apply `_ui_font`/`_emoji_font` via their
`apply_font` methods.
Handlers (new/changed):
- `_handle_world_click` right-click branch (before placement-cancel): when `_rule_step == IDLE`
and mode is EDIT or DIRECT, hit-test waypoint → `_waypoint_context_popup`; else `_selection.hit_test`
a `StickmanRig` → `_rig_context_popup`. Both record `_popup_anchor` from the click's screen rect.
- `_on_action_popup_id_pressed`: new `ACT_EDIT_QUEUE`/`ACT_EDIT_RULES` cases → open panels for
`_context_rig`.
- Queue-panel signal handlers: `_queue_edit(index)`, `_queue_delete(index)` (confirm),
`_queue_move(index, dir)`, `_queue_add()`, `_queue_clear()` (confirm).
- Rule-panel signal handlers: `_rule_edit(id)`, `_rule_delete(id)` (confirm), `_rule_move(index,
dir)`, `_rule_add()`, `_rules_clear()` (confirm).
- Waypoint-context handlers: `_waypoint_edit_walk()`, `_waypoint_delete_walk()`,
`_waypoint_insert(before: bool)`, `_waypoint_edit_rules()`.
- Rig-context handlers: `_rig_edit_queue()`, `_rig_edit_rules()`.
- `_pending_walk_edit_index` consumed in `_handle_direct_click` (and the EDIT left-click path):
when `>= 0`, replace `_context_rig`'s queue action at that index with the new `walk_to` target
(via `replace_action`), clear the edit state + `edit_waypoint`, refresh cursor/status/visuals.
- `_move_rule(from: int, to: int)` helper (bounds-check, swap, `_director_visuals.set_rules`).
- `_open_rule_editor(id, consequence_only: bool)` — populate `RuleEditor` and show; `RuleEditor`
drives `_rule_builder`/`_rule_step` through signals back to the stage.
Esc priority (`_unhandled_key_input`): insert `_rule_editor`/`_queue_panel`/`_rule_panel` close
and `_pending_walk_edit_index` cancel into the existing chain (above placement/selection clear).
---
## 8. New File Detail
### 8.1 `scripts/queue_panel.gd` — `QueuePanel extends PopupPanel`
Signals (all emitted to the stage, which mutates data):
```gdscript
signal edit_requested(index: int)
signal delete_requested(index: int)
signal move_requested(index: int, dir: int) # dir: -1 up, +1 down
signal add_requested()
signal clear_requested()
signal closed()
```
API:
```gdscript
func open_for(rig_name: String, queue: Array[Dictionary]) -> void # store + _rebuild + popup_centered()
func refresh(queue: Array[Dictionary]) -> void # re-render after a mutation
func apply_font(ui_font: Font, emoji_font: Font) -> void
```
Rows (one per action, in order): order number, `ActionRegistry.row_summary(action)`, then
`` (hidden for ragdoll/recover), ``, ``, `` (disabled at ends). Footer: `[Add Action]`
`[Clear All]` `[Close]`. `exclusive = true`; Esc → `closed.emit()`. Rebuilt on every `refresh`.
### 8.2 `scripts/rule_panel.gd` — `RulePanel extends PopupPanel`
Signals:
```gdscript
signal edit_requested(id: int)
signal delete_requested(id: int)
signal move_requested(index: int, dir: int)
signal add_requested()
signal clear_requested()
signal closed()
```
API:
```gdscript
func open_for(source_name: String, rules: Array[Dictionary]) -> void
func refresh(rules: Array[Dictionary]) -> void
func apply_font(ui_font: Font, emoji_font: Font) -> void
```
Rows (one per rule): order number + `StageDirectorVisuals.rule_summary(rule)` (or
`TriggerRegistry.describe` + action summaries), then ``, ``, ``, ``. Footer:
`[Add Rule]` `[Clear All]` `[Close]`.
### 8.3 `scripts/rule_editor.gd` — `RuleEditor extends PopupPanel`
Signals:
```gdscript
signal trigger_type_changed(type: String)
signal trigger_target_requested()
signal action_add_requested()
signal action_edit_requested(index: int)
signal action_remove_requested(index: int)
signal done_requested()
signal cancelled()
```
API:
```gdscript
func open_full(trigger: Dictionary, actions: Array[Dictionary], trigger_types: Array[String]) -> void
func open_consequence(trigger_summary: String, actions: Array[Dictionary]) -> void
func apply_font(ui_font: Font, emoji_font: Font) -> void
```
- **Full mode:** trigger `OptionButton` (registry types) + `[Set target…]` (hidden for
`speech_finished`/`action_finished`) + target readout; actions list (summary + `` ``);
`[Add Action]`; `[Cancel]` `[OK]`.
- **Consequence-only mode:** read-only trigger line; actions list; `[Add Action]`; `[Done]`
`[Cancel]`.
---
## 9. UI Flows
### 9.1 Action Queue Panel
Entry: Direct-click stickman → action popup → **"📋 Edit Queue…"**; or right-click stickman →
**"Edit Queue…"**. Panel lists the queue (order numbers, type, params). `` routes to the
existing edit machinery (§9.2); ``/Clear All confirm; ``/`` call `move_action`;
`[Add Action]` opens `_queue_add_popup` (appends via the same per-type flow, targeting the
queue's rig).
### 9.2 Action editing (reuses existing dialogs)
- **walk_to** — `_pending_walk_edit_index = index`; `edit_waypoint` highlights the dot; next
stage click → `replace_action(index, {type:"walk_to", target: new_pos})`.
- **speak** — pre-fill `_speak_edit.text`/duration; confirm (with `_editing_action_index >= 0`)
→ `replace_action(index, {type:"speak", text, duration})`.
- **wait** — pre-fill `_wait_spin.value`; confirm → `replace_action`.
- **ragdoll/recover** — no ``; nothing to edit.
`_on_speak_confirmed`/`_on_wait_confirmed` gain an `_editing_action_index >= 0` branch
(prefixed above the existing `_rule_step == PARAMS` branch).
### 9.3 Waypoint context menu (EDIT/DIRECT, right-click on a dot)
Items: `✎ Edit this Walk`, `✕ Delete this Walk`, `⬆ Insert action before`, `⬇ Insert action
after`, `⚡ Edit Trigger Rules (N)` (N = count of rules referencing that waypoint position; hidden
when 0). Edit → §9.2 walk flow; delete → `remove_action(index)`; insert → `_queue_add_popup` then
`insert_action(index | index+1, action)`; Edit Trigger Rules → `RulePanel` filtered by
`params.waypoint_pos` proximity.
### 9.4 Rule Panel
Entry: right-click stickman → "Edit Rules…" (filter `trigger.source == rig id`); or action popup
→ "Edit Rules…". Rows show `rule_summary`; `` → full `RuleEditor`; ``/Clear All confirm;
``/`` → `_move_rule`; `[Add Rule]` → fresh build (`ACT_WHEN` flow, source = panel rig).
### 9.5 Rule Editor
Full (`` in panel): trigger dropdown + target button + actions list (``/``) + `[Add Action]` +
`[OK]`. Consequence-only (rule-label click on stage): trigger read-only + actions list +
`[Add Action]` + `[Done]`. `OK`/`Done` → `_finalize_rule()` (preserves `id`); `Cancel` →
`_cancel_rule_build()`.
---
## 10. Entry-Point Integration (existing code hooks)
- **Action popup items** appended in `_build_ui` after the `ACT_WHEN` separator
(`_action_popup` currently ends at `ACT_WHEN`).
- **Right-click routing** in `_handle_world_click` (RMB branch) gains waypoint → stickman hit
tests **before** the EDIT placement-cancel, gated on `_rule_step == IDLE` and mode != PLAY.
- **Rule-label click** (`_handle_world_click` LMB path already calls `_begin_edit_rule`) now
routes to `_open_rule_editor(id, true)` instead of the bare add-action popup.
- **`_clear_director_pending`** also resets `_pending_walk_edit_index`, `_editing_action_index`,
`edit_waypoint`, and hides the new panels/popups (mode exit must cancel edit flows).
---
## 11. Acceptance Criteria (corrected from plan §11)
### 11.1 Action Queue Panel
- "Edit Queue…" opens the panel from the action popup and from stickman right-click.
- Panel lists every action in queue order with an order number + type + parameter summary.
- `` deletes an action after a confirmation dialog.
- `` re-opens the action editor with values pre-filled (hidden for ragdoll/recover).
- ``/`` move an action (order numbers and waypoint connectors update; first/last disabled).
- "Add Action" appends via the add-action popup.
- "Clear All" removes all actions after a confirmation dialog.
### 11.2 Edit Action
- walk_to: edit enters target-capture mode; clicking a new spot moves the waypoint (dot +
dashed connector update; order numbers unchanged).
- speak: text + duration pre-filled; confirm updates the action + badge summary.
- wait: duration pre-filled; confirm updates.
- ragdoll/recover: no edit (param-less); `` hidden.
### 11.3 Waypoint Context Menu
- Right-click a waypoint dot opens the context menu (EDIT and DIRECT).
- "Edit this Walk" enters target placement (highlighted dot) and moves the waypoint.
- "Delete this Walk" removes the walk action (immediate).
- "Insert action before/after" inserts at `index` / `index + 1` (connectors reconnect, order
numbers renumber).
### 11.4 Rule Panel
- "Edit Rules…" opens the panel filtered to rules whose `trigger.source` is the stickman.
- Each rule shows a summary (trigger verb + action summaries) + order number.
- `` deletes after confirmation; ``/`` reorders; "Add Rule" starts the rule builder;
"Clear All" confirms and clears.
### 11.5 Rule Editor
- Full: trigger type dropdown works; trigger target can be re-clicked; action list supports
add/remove; parameters (text/duration/walk target) editable; `OK` updates the rule preserving
`id`.
- Consequence-only: trigger read-only; actions editable; `Done` updates the rule preserving `id`.
- Visual connectors update immediately on save.
### 11.6 Visual Updates
- Waypoint dots move on walk edit; speech badge text + wait duration reflect edits; dashed
connectors re-render after insert/delete/reorder; rule labels refresh; rule connectors
re-target on trigger/action target edits.
### 11.7 Backward Compatibility
- Existing queues/rules load and display unchanged.
- Editing preserves action types and unknown keys; editing a rule preserves `id`.
- Deleting a rule/action leaves no dangling references (`_cleanup_rules_for_nodes` on node delete
already covers object deletion; queue/rule edits only mutate their own data).
### 11.8 Extensibility (corrected)
- New action **UI** requires only a registry entry; new action **runtime** still needs a
`_begin_action` case (documented in §6).
- New trigger **UI** requires only a registry entry; new trigger **runtime** needs an event
emission + `_rule_matches` case.
- Unknown action keys are preserved on edit (not dropped); no generic unknown-key widget.
---
## 12. Implementation Order (matches the 5 sub-phases)
1. **3c.1 Foundation** — `action_registry.gd` + `trigger_registry.gd`; `StickmanRig.move_action`
/ `replace_action`; `StageDirectorVisuals._collect_waypoint_refs` / `hit_test_waypoint_action` /
`edit_waypoint`. (No behavior change yet — existing popups may optionally read labels/icons
from the registry, but the plan allows keeping the hard-coded popup strings until 3c.2+.)
2. **3c.2 Action Queue Panel** — `queue_panel.gd`; `_action_popup` items; `_rig_context_popup`;
queue edit/delete/move/add/clear handlers; `_confirmation_dialog`.
3. **3c.3 Action Visual Editing** — `_waypoint_context_popup`; `_pending_walk_edit_index` +
`edit_waypoint` highlight; insert before/after; edit-walk target capture.
4. **3c.4 Rule Panel** — `rule_panel.gd`; `rule_editor.gd` (full + consequence-only);
`_move_rule`; rewire `_begin_edit_rule` → consequence-only editor; rule add/delete/clear.
5. **3c.5 Rule Visual Editing** — waypoint "Edit Trigger Rules (N)" filter; waypoint→rule
proximity matching; final Esc-priority / `_clear_director_pending` cleanup.
Each sub-phase is independently testable via the headless `SceneTree` suite pattern
(`tests/test_phase4b_*.gd`: instantiate `sandbox_stage.tscn`, drive handlers directly, assert on
`_event_rules` / `rig.get_queue()` / popup visibility).
---
## 13. Tech-Debt / Deferred
- **Drag reorder** — up/down buttons chosen over `[≡]` drag (decision 3). Revisit if a
touch/pointer drag reorder is wanted.
- **Generic param-field auto-generation** — `ActionRegistry.get_params` describes params, but
widgets (LineEdit/SpinBox/position click) remain hand-built per type. A registry→widget factory
is future work.
- **`action_finished` type filter** — the full editor does not expose `params.action_type`;
the trigger remains "completes any action" (§3.6).
- **`StageDirectorVisuals.rule_summary`/`_action_desc` duplication** — the registry's `describe`
now duplicates `rule_summary`'s summary logic; a follow-up can make `rule_summary` delegate to
the registry to remove the copy.
- **Asymmetric delete confirmation** — panel deletes confirm; on-stage rule `✕` and waypoint
"Delete this Walk" stay immediate (decision 9).
+459
View File
@@ -0,0 +1,459 @@
# Phase 4b Polish — Implementation Specification
> **Status:** Draft for implementation (tester/developer will refine unit tests).
> **Source plan:** `plans/PHASE_4b_POLISH.md`
> **Scope:** Sandbox Stage Builder (`scenes/sandbox_stage.tscn` + `scripts/sandbox_stage.gd` and its supporting scripts). **Not** wired into the main editor. Run standalone via **F6**.
---
## 1. Overview
Phase 4b is a **polish + bugfix** pass over the Sandbox Stage Builder (Phases 24). It does **not** add new gameplay systems; it (a) restructures the top-bar UI into a unified 3-segment mode switcher with contextual toolbars, a bottom status bar, a mode badge, per-mode viewport framing, and per-mode cursors; (b) upgrades terrain placement from single-click into a drag-to-paint "drawing" workflow with Bresenham staircase pathing, a 3-state occupancy query, ghost previews, and atomic batch commit backed by a new grid spatial dictionary; (c) makes the director tool's "pick a target" flows kid-friendly with cursor-attached tooltips, rubber-band trajectory lines, and a custom action cursor; (d) introduces a hand-editable theme JSON (fonts/sizes/colors/grid default); and (e) fixes the walk-waypoint arrival jitter.
The codebase state verified for this spec:
- The sandbox stage is `scripts/sandbox_stage.gd` (`class_name SandboxStage`), root of `scenes/sandbox_stage.tscn` (which is a minimal `Node2D` + `Camera2D` + empty `World`; **all UI is built in code** in `_build_ui()`).
- There are currently **two** modes (`enum StageMode { EDIT, PLAY }`, `sandbox_stage.gd:27`); "Direct" is a **separate bool** `_direct_mode` toggled by a `_direct_button` (`:153`, `:660-664`) that sits *between* the spawner buttons and the Grid/Snap controls.
- The status readout is a **right-aligned `Label` in the top bar** (`_status_label`, `:691-694`), not a bottom bar, and there is **no mouse-coordinate readout**.
- Terrain placement is **single-click**: `_place_at()` (`:467`) spawns one node per click; there is no drag trajectory, no Bresenham pathing, no occupancy query, and **no grid spatial dictionary** (`_rebake_navigation()` at `:798` iterates `World` children each bake).
- `StageDirectorVisuals` recomputes rule connector anchors **live** from `instance_from_id(...).global_position` on every `_draw()` (`stage_director_visuals.gd:257-287`), but it only redraws when `_dirty` is set (`:80-83`), and `_on_transform_committed()` (`sandbox_stage.gd:875-879`) does **not** call `mark_dirty()` — this is the "moving a TriggerArea does not update its rule connector" bug (§8).
- `walk_left`/`walk_right` bake a vertical body bob: `IK_Targets/Torso:position` is keyed `(0,10) → (0,-15) → (0,10) → (0,-15) → (0,10)` (`scripts/create_animations.gd:80`; baked into `master_rig.tscn`) — relevant to the jitter bug (§11).
- There is **no `res://assets/` directory, no `.ttf`, no `.theme`/`.tres`** in the repo; all drawing uses `ThemeDB.fallback_font`. The only existing sandbox config is the runtime `user://sandbox_settings.json` (`sandbox_stage.gd:1500-1527`, keys `version`/`grid_size`/`snap_to_grid`/`show_grid`).
- The established headless test pattern is `Godot_v4.4-stable_win64_console.exe --headless --script res://tests/<name>.gd --path .` (see `tests/test_text_baseline_fix.gd:13-16`).
---
## 2. Feature-by-feature breakdown
Each subsection: **Behavior**, **Affected files/functions**, **Data/format changes**, **Edge cases**.
---
### 2.1 Bottom status bar with mouse coordinates
**Behavior.** Add a bottom-anchored status bar (mirroring the stickman editor's `%StatusBar` pattern). Left side may carry the existing `Mode: … | Objects: N | Selected: …` text (or that moves to a toast); **right side** shows the live mouse world coordinates as `X: ### Y: ###`, exactly like the editor's `_process()` cursor readout (`scripts/stickman_editor.gd:147-162`, which polls `get_global_mouse_position()` each frame and writes `_status_cursor_coords.text`).
**Affected files/functions.**
- `scripts/sandbox_stage.gd`:
- `_build_ui()` (`:633`) — replace the top-bar status `Label` with a bottom bar. Build a `PanelContainer` + `HBoxContainer` anchored `Control.PRESET_BOTTOM_WIDE` (height ~28 px, mirroring the editor's 28 px `StatusBar`), containing a left `Label` (`_status_label`, expands) and a right `Label` (`_status_cursor_coords`). The top bar (`top_bar`, `offset_bottom = 40.0`, `:638-641`) must have its bottom reduced so the new bar does not overlap (`offset_bottom` stays 40 for the top bar; the bottom bar is a separate Control).
- Add `_process()` coordinate polling (extend the existing `_process` at `:219`): compute `_camera.get_global_mouse_position()`, write `X: %d Y: %d` into `_status_cursor_coords` (world space, integer-rounded, consistent with the editor). This is world-space (not screen-space) so it pans/zooms correctly.
- `_refresh_status()` (`:607`) keeps writing the left label.
**Edge cases.** When the mouse is over the top bar/bottom bar/popups (`_is_mouse_over_ui()` at `:1450`), the coordinate readout should still update (world position under a UI hover is still meaningful) — decide and document: the editor hides coords when not over a drawing surface, but the sandbox stage is a single full-screen viewport, so **always show** world coords. Handle `Camera2D` null (should not happen; it is `@onready`).
---
### 2.2 Unified mode switcher + contextual toolbars
**Behavior.** Replace the current `_mode_button` ("Edit"/"Play") + `_direct_button` ("Direct") with a single **3-segment control** `[ ✏️ Edit | 🎬 Direct | ▶️ Play ]` at the far left of the top bar. The three modes have contextual toolbars:
- **Edit:** spawner buttons (Ground/Ramp/Step/Crate/Ball/Stickman/Area), Grid/Snap/Size controls. *(No separate "transform tool" buttons exist today — translation/rotation are direct-drag/ring gizmos, not toolbar tools.)*
- **Direct:** spawner buttons + grid/snap controls hidden; the toolbar shows director affordances (currently the director is entirely click/popup-driven, so this segment may initially show only a hint label; see Open Questions).
- **Play:** layout tools hidden; toolbar shows only the mode switcher (+ playback controls if added — see Open Questions).
**Affected files/functions.**
- `scripts/sandbox_stage.gd`:
- `enum StageMode { EDIT, PLAY }` (`:27`) → extend to `enum StageMode { EDIT, DIRECT, PLAY }`. **Recommend values EDIT=0, DIRECT=1, PLAY=2** (segmented order). `mode_changed(mode: int)` (`:36`) now carries 3 values.
- Fold `_direct_mode: bool` (`:153`) into `current_mode`. Delete `_direct_button` (`:154`) and `_mode_button` (`:119`); introduce a small array of 3 `Button` (toggle_mode) built from a `const MODES = [{id, label}, …]` in `_build_ui()`.
- `set_mode(mode)` (`:354`) → route to `_enter_edit_mode()` / **new `_enter_direct_mode()`** / `_enter_play_mode()`. `_enter_direct_mode()` = enter EDIT-side state (freeze props, stand stickmen, gizmos enabled) **without** clearing selection-as-placement, set `_direct_mode` behavior, show director visuals, hide spawner/grid controls.
- `_enter_edit_mode()` (`:366`) must clear direct state (currently `_enter_play_mode()` clears `_direct_mode` at `:404`; edit must also reset it).
- `_on_direct_toggled` (`:885`), `_on_mode_toggled` (`:830`) — replaced by `_on_mode_segment_toggled(pressed, mode)`.
- `_set_build_controls_visible(visible)` (`:1469`) — split into `_set_edit_controls_visible(bool)` (spawners + grid/snap/size) and `_set_direct_controls_visible(bool)`; call from the three mode-enter functions.
- `_handle_world_click` (`:298`) — the `current_mode != StageMode.EDIT` early return (`:301`) must now accept `DIRECT` for the `_handle_direct_click` path; rule-builder and rule-label hit-testing still need to work in Direct mode.
- `_unhandled_key_input` Esc ordering (`:269-282`) — add a `DIRECT` branch (exit Direct → Edit) consistent with the current `_direct_mode` branch (`:276-278`).
- `_handle_mouse_motion` (`:335`) — the `current_mode != StageMode.EDIT` guard (`:339`) must allow Direct-mode hover only if needed (Direct currently does not hover; keep hover Edit-only, but permit Direct click routing).
**Data/format changes.** None.
**Edge cases.** `set_mode()` no-ops when `mode == current_mode` (`:355`) — keep. Entering PLAY from DIRECT must clear `_direct_mode` state and the pending-walk-target/rules popup (already handled in `_enter_play_mode` `:396-412`; verify after folding the bool). The old `mode_changed` consumers (only the stage itself) must be re-audited for the new enum values.
---
### 2.3 Viewport color frame / canvas background per mode
**Behavior.** Distinct canvas cues per mode:
- **Edit:** construction grid visible (current behavior).
- **Direct:** amber/gold viewport **border frame** (a thin full-screen border overlay, "camera viewfinder" feel) and/or gold-tinted gizmos. **Grid hidden entirely** (user decision — no dimmed grid).
- **Play:** grid faded out; green glow on the Play segment.
**Affected files/functions.**
- `scripts/sandbox_stage.gd`:
- `_build_ui()` — add a full-screen `Panel` (or `ColorRect`) Control in the `CanvasLayer` named e.g. `ModeFrame`, `mouse_filter = MOUSE_FILTER_IGNORE`, drawn **behind** the top bar and **over** the viewport, with a `StyleBoxFlat` that has transparent `bg_color` and a `border_color`/`border_width` from the theme JSON. Toggle `visible`/`border_color` in the three mode-enter functions.
- `_apply_grid_settings()` (`:1460-1467`) — the grid visibility line `_grid.visible = _show_grid and current_mode == StageMode.EDIT` (`:1464`) must change: `EDIT` → visible; `DIRECT`**hidden**; `PLAY` → hidden (fade).
- `_refresh_status()` (`:607`) — the Play segment glow is a button theme override applied on mode change.
- `scripts/stage_grid.gd` — no change required (Direct hides the grid; the optional `alpha` dim knob is **not** needed).
**Edge cases.** The frame overlay must never intercept input (`MOUSE_FILTER_IGNORE`). Border width is screen-constant (not zoom-dependent).
---
### 2.4 High-contrast status badge pill
**Behavior.** A prominent pill in a viewport corner showing the mode: `✏️ EDIT` (cyan/blue), `🎬 DIRECTING` (amber/gold), `▶️ SIMULATING` (green). Replaces the "Mode:" portion of the status text as the primary mode indicator.
**Affected files/functions.**
- `scripts/sandbox_stage.gd`:
- `_build_ui()` — build a `PanelContainer` (`StyleBoxFlat` with accent `bg_color`, rounded corners, padding) containing a `Label`, anchored top-left (or top-right) of the viewport, `mouse_filter = MOUSE_FILTER_IGNORE`.
- New `_refresh_mode_badge()` called from the three mode-enter functions and `_refresh_status()`; text + `StyleBoxFlat.bg_color` + label color sourced from the theme JSON mode colors (§2.9).
- `_refresh_status()` (`:607`) — drop `Mode: …` from the status text (now redundant) or keep it; the badge is authoritative.
**Edge cases.** Badge must float above the world but below the toolbar; ensure it doesn't overlap the top bar when the window is short.
---
### 2.5 Cursor feedback per mode
**Behavior.** `Input.set_default_cursor_shape()` per mode: Edit → `CURSOR_CROSS`, Direct → `CURSOR_CROSS` (or a custom reticle), Play → `CURSOR_ARROW`. The action-pick cursor (§2.10) overrides this with a custom flag/reticle.
**Affected files/functions.**
- `scripts/sandbox_stage.gd`:
- New `_apply_cursor()` called from the mode-enter functions and on `_pending_walk_target`/rule-builder step transitions. Use `Input.set_default_cursor_shape(Input.CURSOR_CROSS)` etc. Custom cursors (flag/reticle) require `Input.set_custom_mouse_cursor(texture, shape, hotspot)` — note this needs an image asset (none exists yet; a `res://assets/` addition or a runtime-generated `Image`/`ImageTexture` via `Image.create()` is acceptable).
**Edge cases.** Restore the default cursor when returning to Play/Edit and when the rule builder or pending-target is cancelled (Esc). A custom cursor must be cleared with `Input.set_custom_mouse_cursor(null)`.
---
### 2.6 Terrain placement improvements (Edit mode)
This is the largest workstream. Current state: `set_placement_mode()` spawns **one** ghost (`_spawn_ghost` `:495`) and `_place_at()` spawns **one** node per click (`:467`). There is **no** drag painting and **no** occupancy tracking.
**2.6.1 Anchor & drag trajectory.**
- Pressing LMB (in Edit, with a terrain placement id active) sets a fixed **anchor grid cell**; moving updates a **target grid cell** (`_snap_to_grid` `:1454`).
- **Shift** locks the trajectory to a cardinal axis: compute `dx`/`dy` from anchor→target; if `|dx| >= |dy|` zero the y-delta, else zero the x-delta (0°/90°/180°/270°). Re-evaluate per motion event.
**2.6.2 High-contrast dashed guide line.**
- Draw a dashed line from the anchor cell center to the locked target cell center. High-contrast accent (cyan/gold). Implemented either in a new overlay `Node2D` (sibling of `StageGrid`/`StageGizmos`, e.g. `PlacementOverlay`) or as a draw method in `StageGizmos`; recommend a **new lightweight overlay** to keep `StageGizmos` focused on selection.
**2.6.3 Bresenham staircase pathing + ghost pipeline.**
- Compute an ordered cell array from anchor to target using **Bresenham's line algorithm** (grid-cell space). Cells are `Vector2i`/`Vector2` at `cell * grid_size`.
- Replace the single `_ghost` with a **ghost array**: one translucent `TerrainBlock` per path cell (matching the active template). Update each frame in `_process()`/motion handling. Reuse `StageSpawner.spawn()` + reparent out of `World` into `_ghost_holder` (`:495-529`), `modulate.a ≈ 0.5`, collision disabled (already done for StaticBody2D ghost at `:513-516`). Free the whole ghost array on release/cancel.
**2.6.4 Three-state grid query (per cell).**
For every cell in the path, classify against a **grid spatial dictionary** (below):
1. **Empty** → green ghost → instantiate on release.
2. **Occupied by same block type** (same registry id, e.g. another `ground`) → neutral/transparent ghost → **skip** on release (no double-create, no z-fight).
3. **Occupied by a different/conflicting object** (crate/ball/stickman/area/different terrain) → muted-red ghost → **skip** on release.
Matching "same block type" requires knowing which registry id produced an existing `TerrainBlock`. Since `TerrainBlock` does not store its template id today, add a `set_meta("spawn_id", id)` in `_place_at()`/`_spawn_terrain()` (or a `spawn_id` property on `TerrainBlock`). Ground/Ramp/Step are distinct ids so only identical-template overlaps skip.
**2.6.5 Atomic batch commit + grid spatial dictionary.**
- **Grid spatial dictionary (new):** a `Dictionary` on `SandboxStage` keyed by grid-cell `Vector2i``Array[Node2D]` (nodes whose footprint overlaps that cell). Populated on spawn/load, updated on move/rotate/delete, cleared/rebuilt when grid size changes. Used by (a) the 3-state query, (b) optionally to accelerate `_rebake_navigation()`/event-engine broadphase (ties into tech-debt #14).
- Because `TerrainBlock` footprints can be larger than one cell (Ground is 200×32 at `TERRAIN_GRID_SIZE=16`, i.e. ~13×2 cells; Ramp/Step larger), occupancy must mark **all cells covered by the node's world AABB**, not just the anchor cell. For terrain the template is grid-aligned; for props/stickmen/areas use `StageSelection.get_world_aabb()` (`stage_selection.gd:129`) rasterized to cells. This is the one non-trivial piece — spec a helper `_rasterize_aabb_to_cells(aabb: Rect2) -> Array[Vector2i]`.
- **Batch commit:** on release, collect all "empty" cells into one batch, spawn all nodes in one frame (loop `_spawner.spawn()`), then add them to the dictionary and mark `_nav_dirty = true` once (not per node). Emit `object_placed` per node (or add an `objects_placed(nodes)` signal; keep `object_placed` for compat). `_save_object_state()` per node (for authored restore).
**Affected files/functions.**
- `scripts/sandbox_stage.gd`: `set_placement_mode` (`:456`), `_place_at` (`:467`), `_spawn_ghost`/`_free_ghost`/`_update_ghost_position` (`:495-544`) → replaced/extended by drag placement; `_handle_world_click` (`:298`) and `_handle_mouse_motion` (`:335`) gain drag-placement branches; `_on_transform_committed` (`:875`) and `delete_selected` (`:584`) must update the dictionary; `_rebake_navigation` (`:798`) optionally consumes it.
- `scripts/terrain_block.gd`: add a `spawn_id` `String` (or use `set_meta`) so same-type overlap is detectable.
- `scripts/stage_spawner.gd`: `_spawn_terrain` (`:202`) already centers the template; expose the template extent (e.g. a `get_template_aabb(id)`) for ghost sizing and cell rasterization. `TERRAIN_GRID_SIZE` (`:31`) is the cell size.
- **New file** `scripts/stage_placement_overlay.gd` (recommended): draws the dashed guide line + per-cell ghost tint state (green/neutral/red) — or fold into the ghost array directly.
**Data/format changes.** Grid spatial dictionary is runtime-only (not persisted). No `.stk`/settings format change.
**Edge cases.**
- **Case A (seamless extension):** blocks at (1,0),(2,0),(3,0); drag (4,0)→(7,0) → 4 new blocks.
- **Case B (overlap extension):** start on existing block (3,0), drag to (7,0) → cell 3 is "same-type skip", 47 spawn.
- **Case C:** drag across a crate → that cell red-skipped, neighbors still spawn.
- **Shift lock** suppresses diagonals entirely (Bresenham produces a pure horizontal/vertical run under cardinal lock).
- Grid-size change while a drag is active: recompute the dictionary or cancel the drag (simplest: cancel drag + free ghosts on `_on_grid_size_changed`).
- Terrain `spawn_id` on nodes placed before this phase (none, since the dictionary is new) — but a rebuild from scratch in `_ready()` must scan existing `World` children; since the stage starts empty, this is trivial.
- Batch spawn of e.g. 50 cells must not stall: reuse the existing per-node `_save_object_state` and single `_nav_dirty` coalescing (nav already coalesces via `_process` `:219-222`).
---
### 2.7 Build cancelling (RMB ends placement)
**Behavior.** When in Edit with a palette object selected (terrain/prop/stickman/area), **right-clicking** ends draw/placement mode — the palette button toggles off and the cursor returns to normal. **LMB keeps the existing Phase 2 repeated-placement behavior** (each LMB click/drag commits one placement and the tool stays active); RMB is the explicit "put the tool down" gesture.
> **Decision (user):** the plan's "LMB ends placement" was a typo — **RMB activates build cancelling**. This preserves Phase 2 repeated placement.
**Affected files/functions.**
- `scripts/sandbox_stage.gd`:
- `_unhandled_input` / `_gui_input` / world input path — add an RMB branch: when Edit mode + placement id active (or an active terrain drag), cancel the drag (free ghosts, keep already-placed cells if the drag committed), call `set_placement_mode("")` (frees the ghost and un-toggles the palette button via `set_placement_mode` `:456-464`), restore the cursor.
- Keep **Esc** cancel (`_unhandled_key_input` `:279-280`) for cancelling before committing (same code path).
- Verify RMB is not currently bound to another action in Edit mode (direct-mode context menus are DIRECT-only, so no conflict).
**Edge cases.** RMB during an in-progress terrain drag: cancel the drag — decide and document whether cells already painted in the current drag stay (commit-on-release semantics) or the whole drag is aborted; **recommend abort-the-drag** (nothing placed until release; RMB before release = clean cancel). RMB with no placement active: no-op (do not interfere with gizmo/context behavior). Blocked-cell-only drags (all red) simply place nothing on release; the tool stays active for another drag until RMB/Esc.
---
### 2.8 Moving a TriggerArea refreshes its rule connector
**Root cause (verified).** `StageDirectorVisuals` rule anchors are computed live each draw from `instance_from_id(...).global_position` (`stage_director_visuals.gd:257-287`), so a redraw **would** follow a moved area — but `_draw()` only runs when `_dirty` is set (`:80-83`), and `SandboxStage._on_transform_committed()` (`:875-879`) saves object state + marks nav dirty for terrain but **never calls `_director_visuals.mark_dirty()`**. Translating a `TriggerArea` via the gizmos (`StageGizmos.drag_to``end_drag``transform_committed`) therefore leaves the dashed connector at the stale position.
**Fix.** In `_on_transform_committed()` (`:875`), call `_director_visuals.mark_dirty()` whenever any moved node is a `TriggerArea` (or, simpler and cheap: unconditionally, since a moved stickman/prop also anchors rule lines/badges). Optionally also mark dirty **during** the drag for live-follow: add a lightweight `transform_dragged`/`transform_changed` signal or have `SandboxStage._handle_mouse_motion` (`:335`) call `mark_dirty()` while `_gizmos.is_dragging()`.
**Affected files/functions.**
- `scripts/sandbox_stage.gd`: `_on_transform_committed` (`:875`).
- `scripts/stage_director_visuals.gd`: no change required (already recomputes live); optionally remove the `_dirty` gate and `queue_redraw()` every frame in `_process` for simplicity, but keep the gate (cheaper) and drive it via `mark_dirty`.
**Edge cases.** Rotating (not translating) an area also repositions its corners but `get_area_rect()` is axis-aligned around the node origin, so the connector anchor (`area.global_position`, `:265`) is unchanged by rotation — acceptable. Deleting a referenced area already triggers `_cleanup_rules_for_nodes``set_rules``mark_dirty` (`:1273-1278`).
---
### 2.9 Styling & theme JSON
**Behavior.** A single, hand-editable JSON config file drives: (1) the Direct-mode action/trigger popup font + emoji size (currently too small), (2) the assignment badge emoji size under stickmen/objects, (3) the grid snap size, and (4) configurable font names for the sandbox UI. All defaults live in the file; the stage loads it at `_ready()` and falls back to built-in constants if missing/malformed.
**Data/format changes (NEW file).** `res://sandbox_theme.json` (committed asset, editable in the editor or by hand). Concrete schema with defaults:
```json
{
"version": "1.0",
"fonts": {
"ui_font": "",
"emoji_font": "",
"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
},
"grid": {
"snap_size": 15.0
},
"mode_colors": {
"edit_accent": "#22c6ff",
"direct_accent": "#ffb300",
"play_accent": "#33dd77",
"guide_line": "#22c6ff"
}
}
```
- `fonts.ui_font` / `fonts.emoji_font`: `res://` paths (e.g. `res://assets/fonts/...`). Empty string = `ThemeDB.fallback_font`. **No font assets exist yet**; loading an empty path or a missing file falls back to `ThemeDB.fallback_font` with a single `push_warning`.
- `action_popup_font_size` / `action_popup_emoji_size` → applied via `PopupMenu.add_theme_font_size_override("font_size", n)` on `_action_popup`, `_trigger_popup`, `_rule_action_popup`, `_rule_more_popup` (`sandbox_stage.gd:696-731`). Emoji size is the font size too (emoji render at the same size); if a dedicated `emoji_font` is set, apply `add_theme_font_override("font", emoji_font)`.
- `assignment_badge_font_size` / `assignment_badge_radius` → replaces `StageDirectorVisuals.ICON_SIZE_PX` (12.0) / `RULE_BADGE_RADIUS_PX` (7.0) and, for the order numbers, `NUMBER_FONT_SIZE_PX` (16.0) (`stage_director_visuals.gd:19-33`).
- `rule_label_font_size` → replaces `RULE_LABEL_FONT_SIZE_PX` (14.0) (`:30`).
- `status_pill_font_size` / `tooltip_font_size` → new badge/tooltip labels.
- `grid.snap_size` → the **default** grid size. Persistence model: keep the live user value in `user://sandbox_settings.json` (`grid_size`, `:1500-1527`) as the runtime source of truth; `sandbox_theme.json` supplies the **initial default** (and the clamp min/max stay `MIN_GRID_SIZE`/`MAX_GRID_SIZE`, `:52-53`). On first run (no `user://` file), seed `_grid_size` from the theme.
- `mode_colors` → drive the status pill (§2.4), the mode frame (§2.3), and the dashed guide line (§2.6.2). Parse via `Color(html_string)` / `Color.from_string` guarded with fallback constants.
**Affected files/functions.**
- **New file** `res://sandbox_theme.json` (the schema above).
- `scripts/sandbox_stage.gd`: new `const THEME_PATH := "res://sandbox_theme.json"`; `_load_theme()` called in `_ready()` (`:200`) before `_build_ui()`; store `_theme: Dictionary`; apply popup overrides in `_build_ui()`; push relevant values to `_director_visuals` and the new overlay.
- `scripts/stage_director_visuals.gd`: replace hardcoded font-size constants with instance vars set via a new `set_style(cfg: Dictionary)` (defaults = current constants), keeping the existing names as defaults so existing behavior is unchanged when no theme is present.
- `scripts/stage_grid.gd` (optional): `grid_alpha` for Direct-mode dimming.
**Edge cases.** Missing/malformed JSON → log once, use all defaults, do **not** crash. A referenced font file that doesn't exist → `push_warning` + fallback font. Unknown extra keys are ignored (forward-compatible). `Color.from_string` failures → fallback color.
---
### 2.10 Actions UX (cursor-attached tooltip, trajectory, ghost marker, action cursor)
**Behavior.** When a click-awaiting director step is active (`_pending_walk_target` for Walk To; the `RuleStep` steps `TRIGGER_TARGET`/`ACTION_TARGET`/`ACTION_POSITION` for "When… Trigger Area"/"When… Collision" and other rule steps), show the **combined workflow**:
1. **Custom action cursor** — swap to a flag/reticle cursor (see §2.5).
2. **Rubber-band dashed trajectory line** — from the stickman's feet (`rig.global_position - StickmanRig.FOOT_OFFSET`) to the cursor; green when the target is valid/reachable, red when invalid (off-reach / inside solid terrain). For "When…" flows the origin is the trigger/action anchor instead of the stickman.
3. **Ghost target marker** — a semi-transparent flag/reticle/footprint at the cursor, snapped to grid when Snap is on; optionally a pulsing floor ring.
4. **Cursor-attached floating tooltip pill** — a rounded high-contrast badge (orange/cyan) following the cursor, reading e.g. `🚩 Click to set walk target` + `[Esc to cancel]`, and for the rule steps `🎯 Click the trigger area` / `💥 Click the prop` etc. (reuse the existing `_rule_hint` strings at `sandbox_stage.gd:999-1003`, `:1019`, `:1141`).
**Affected files/functions.**
- `scripts/sandbox_stage.gd`:
- `_pending_walk_target` (`:157`), `_rule_step`/`_rule_hint` (`:176-180`), `_handle_direct_click` (`:895`), `_handle_rule_click`/`_handle_trigger_target_click`/`_handle_action_target_click`/`_handle_action_position_click` (`:1080-1169`).
- New overlay drawing + a code-built tooltip `PanelContainer`+`Label` in the `CanvasLayer`, positioned each frame in `_process()` at `get_viewport().get_mouse_position() + offset`, hidden unless a click-awaiting step is active. **Replace** the top-bar `"Click stage for walk target"` hint (`:621-622`) with the tooltip (or keep the bar text as a fallback).
- New helpers: `_is_awaiting_click() -> bool`, `_action_hint_text() -> String`, `_action_origin() -> Vector2`, `_is_target_valid(pos) -> bool`.
- `scripts/stage_director_visuals.gd` (or the new overlay): draw the trajectory line + ghost marker; needs the active pending target state, so **prefer a new overlay** owned by `SandboxStage` rather than overloading the director visuals.
**Edge cases.** Tooltip must not cover the cursor (offset ~1624 px right/up, flipping near screen edges). Esc must clear the tooltip, cursor, and line in one place (already centralized in `_unhandled_key_input` `:269-282`). The "valid/invalid" color requires a reachability check: reuse `StickmanRig.is_target_reachable` semantics but without a live agent — simplest is: green always during picking, red only when the point is inside a `TerrainBlock` AABB (testable via the grid dictionary / `StageSelection.get_world_aabb`); full nav reachability preview is deferred.
---
### 2.11 Walk-waypoint arrival jitter (bugfix)
Detailed analysis below (§3).
---
## 3. Walk-waypoint jitter — root-cause analysis & fix
### 3.1 Reproduce (from plan)
Place stickman → Direct → create a walk waypoint → Play → the stickman walks, then **jitters rapidly but slightly up/down at the waypoint** instead of stopping.
### 3.2 Verified code facts
- `StickmanRig.walk_to(target, speed)` (`stickman_rig.gd:1032-1060`) sets `_walk_target_feet`, plays `walk_left`/`walk_right`, `_walking = true`, `_walk_done = false`.
- `_update_walking(delta)` (`:1067-1118`) — per physics frame:
1. Map-sync guard: `NavigationServer2D.map_get_iteration_id(...) == 0 → return` (`:1076`).
2. `next_feet = _nav_agent.get_next_path_position()` (forces path update) (`:1084`).
3. `if _nav_agent.is_target_reachable():`**nav** branch: `if is_navigation_finished(): _finish_walk("finished"); return` else `root_target = next_feet + FOOT_OFFSET` (`:1086-1092`).
4. `else:`**direct** branch: `root_target = _walk_target_feet + FOOT_OFFSET` (`:1093-1099`).
5. `global_position = global_position.move_toward(root_target, _walk_speed_current * delta)` (`:1100`).
6. `if global_position.distance_to(_walk_target_feet + FOOT_OFFSET) <= ARRIVE_DISTANCE: _finish_walk("arrive"); return` (`:1101-1103`).
- Constants: `ARRIVE_DISTANCE = 8.0` (root-space) (`:160`), `NAV_PATH_DESIRED_DISTANCE = 8.0`, `NAV_TARGET_DESIRED_DISTANCE = 12.0` (feet-space) (`:161-162`), `FOOT_OFFSET = (0,-385)` (`:156`).
- `_finish_walk(reason)` (`:1121-1138`) stops the animation, `_restore_standing_markers()`, `_walking = false`, `_walk_done = true`, emits `arrived(_walk_target_feet)`.
- `_walk_mode` (`"nav"|"direct"`) is **recomputed every frame** from `is_target_reachable()` (`:337`, `:1086-1099`); nothing latches it.
- `walk_left`/`walk_right` key **`IK_Targets/Torso:position`** between `(0,10)` and `(0,-15)` — a ±12.5 px **vertical body bob** (`scripts/create_animations.gd:80`; baked in `master_rig.tscn` tracks `IK_Targets/Torso:position`, and the `.:facing_profile` track).
### 3.3 Root-cause hypothesis (ranked; confirm at runtime with `DEBUG_WALK`)
1. **Mode-flip oscillation (primary).** Because `_walk_mode` is re-evaluated every frame and a clicked waypoint frequently sits **at/near the nav-mesh boundary** (the nav mesh is only the placed terrain polygons — `_rebake_navigation` `:798` — so a waypoint clicked in open space or just off an edge is borderline), `is_target_reachable()` can flip between `true` and `false` across consecutive frames while the agent moves. Each flip swaps `root_target` between:
- nav: `next_feet + FOOT_OFFSET` (clamped to the terrain-surface Y), and
- direct: `_walk_target_feet + FOOT_OFFSET` (the raw clicked Y).
Two targets with a small **vertical** offset → the rig visibly jitters up/down until the `ARRIVE_DISTANCE` guard finally trips.
2. **Arrival-radius mismatch + intermediate-point re-targeting (secondary).** `is_navigation_finished()` triggers at `NAV_TARGET_DESIRED_DISTANCE` (12 px, feet-space) while the hard arrival guard is `ARRIVE_DISTANCE` (8 px, root-space). Near the destination `get_next_path_position()` can return a point at/behind the agent, so `move_toward` steps can reverse direction (micro-oscillation). The two different radii mean the walk can terminate early ("finished" at 12 px) *or* chase a now-behind `next_feet` point.
3. **Body-bob frame interaction (visual, tertiary).** The walk animation bobs `IK_Targets/Torso` ±12.5 px each cycle. If the arrival frame's `_anim_player.stop()` (keep_state=false resets to the walk animation's first keyframe) races `_restore_standing_markers()` (writes exact `STAND_POSE`), there can be a brief vertical pop — small-amplitude and "up/down", matching the report.
### 3.4 Concrete fix approach
All three are addressed with small, contained changes to `stickman_rig.gd`:
1. **Latch the mode once per walk.** In `walk_to()` (or on the first post-sync frame), compute `_walk_mode` **once** (`is_target_reachable()`), store it, and stop re-evaluating per frame in `_update_walking`. Optionally allow a one-way upgrade nav→direct only (never direct→nav) to keep steering robust if the path later empties.
2. **Unified arrival radius against the FINAL target.** In both branches, once `global_position.distance_to(_walk_target_feet + FOOT_OFFSET) <= ARRIVE_DISTANCE`, call `_finish_walk("arrive")` **and** snap `global_position = _walk_target_feet + FOOT_OFFSET` before restoring markers (removes any residual offset). Drop the premature `_finish_walk("finished")` early-return, or keep it only when the rig is *also* within `ARRIVE_DISTANCE`.
3. **Steer to the final target when close.** When within e.g. `2 * ARRIVE_DISTANCE` of the final target, ignore `next_feet` and move directly toward `_walk_target_feet + FOOT_OFFSET` (prevents chasing a behind-path point).
4. **Make stop/restore atomic.** In `_finish_walk`, call `_anim_player.stop()` then `_restore_standing_markers()` (already ordered correctly), and add a one-frame re-assert (`_restore_standing_markers()` again next physics frame if `_walk_done`) if the trace shows a residual bob. Keep `DEBUG_WALK` (`:167`) prints to capture `mode`, `dist`, `next`, `final` at arrival.
**Verification:** enable `DEBUG_WALK` and `DEBUG_STAGE`, reproduce with (a) a waypoint on flat ground, (b) a waypoint in open space off the terrain, (c) a waypoint exactly on a terrain edge. The fix is verified when `mode` stays constant for the whole walk and exactly one `arrive` fires with no post-arrival position change.
---
## 4. Implementation order / checklist (grouped by workstream)
> Order is dependency-aware; each workstream is independently testable.
**WS0 — Theme JSON foundation (do first; everything else reads it).**
- [ ] Add `res://sandbox_theme.json` (§2.9 schema).
- [ ] `SandboxStage._load_theme()` + `_theme` var; call in `_ready()` before `_build_ui()`.
- [ ] `StageDirectorVisuals.set_style(cfg)` (defaults = current constants).
- [ ] Headless test: missing file / malformed JSON → defaults, no crash.
**WS1 — UI chrome.**
- [ ] Bottom status bar + `_status_cursor_coords` + `_process` polling (§2.1).
- [ ] `StageMode { EDIT, DIRECT, PLAY }` refactor + 3-segment control + `_enter_direct_mode()` (§2.2).
- [ ] `_set_edit_controls_visible` / `_set_direct_controls_visible` split (§2.2).
- [ ] Viewport mode frame + grid visibility per mode (§2.3).
- [ ] Mode badge pill + `_refresh_mode_badge()` (§2.4).
- [ ] Per-mode cursor `_apply_cursor()` (§2.5).
**WS2 — Terrain placement.**
- [ ] Grid spatial dictionary (`_grid_cells`, `_rasterize_aabb_to_cells`, populate/update/delete paths) (§2.6.5).
- [ ] `TerrainBlock` spawn-id tagging + `StageSpawner.get_template_aabb(id)` (§2.6.4).
- [ ] Drag anchor/target + Shift cardinal lock (§2.6.1).
- [ ] Bresenham path + per-cell ghost array + 3-state tint (§2.6.22.6.4).
- [ ] Batch commit + single `_nav_dirty` (§2.6.5).
- [ ] RMB ends placement; LMB keeps repeated placement (§2.7).
- [ ] Guide-line overlay (new `scripts/stage_placement_overlay.gd`).
**WS3 — Director UX.**
- [ ] `_on_transform_committed``_director_visuals.mark_dirty()` (+ live-drag dirty) (§2.8).
- [ ] Cursor-attached tooltip pill + `_is_awaiting_click`/`_action_hint_text` (§2.10).
- [ ] Rubber-band trajectory + ghost marker + green/red validity (§2.10).
- [ ] Flag/reticle action cursor (§2.10, shares §2.5 infra).
- [ ] Apply popup font/emoji overrides from theme (§2.9).
**WS4 — Bugfix.**
- [ ] `StickmanRig._update_walking` mode latch + unified arrival + snap-on-arrive + atomic stop (§3.4).
- [ ] `DEBUG_WALK` capture for the tester.
**WS5 — Docs.**
- [ ] Update `README.md` §1820 (mode switcher, status bar, theme JSON, terrain painting, jitter fix).
- [ ] Append entries to `docs/tech_debt_and_optimizations.md` (grid dictionary addresses #14; walk-mode latch note).
---
## 5. Testing plan
There is **no CLI build/test/lint**; the project is run in the Godot editor (F5/F6). The established **headless assertion pattern** (verified in `tests/test_text_baseline_fix.gd`) is:
```
& "C:\Godot4\Godot_v4.4-stable_win64_console.exe" --headless --script res://tests/<name>.gd --path .
```
The script `extends SceneTree`, prints `PASS/FAIL` per assertion, and `quit(0/1)`. The tester agent will author such scripts under `tests/`. Manual F6 verification on `res://scenes/sandbox_stage.tscn` is also required for visual/interaction features.
**Behaviors that must be verified:**
1. **Theme JSON** — missing file → defaults; malformed → defaults + single warning; overridden `action_popup_font_size` actually changes `PopupMenu` font size; `grid.snap_size` seeds `_grid_size` on first run.
2. **Status bar**`_status_cursor_coords` updates each frame with world coords; pans/zooms reflect in the numbers.
3. **Mode switcher** — 3 segments; Edit shows spawners+grid, Direct hides them, Play hides them; `mode_changed` emits 0/1/2; entering Direct from Play and vice-versa clears pending state; Esc from Direct → Edit.
4. **Mode frame/badge/cursor** — correct accent per mode; frame never intercepts input; cursor shape correct per mode and restored after cancel.
5. **Terrain painting** — Case A/B/C from §2.6.4 produce the expected block counts; Shift lock yields axis-aligned runs with no diagonals; batch commit spawns all blocks in one frame (nav baked once); same-type overlap skips; conflicting-object cells are red and skipped; LMB ends placement and toggles the button off.
6. **Grid dictionary** — moving/deleting a block updates occupancy; querying a cell returns correct classification; no stale entries after delete.
7. **Trigger-area move** — translating an area whose rule exists moves the dashed connector (and `⚡` badge) to the new position on drag end (and, if implemented, live during drag).
8. **Director tooltip/trajectory** — pending walk target shows tooltip + dashed line + ghost marker; Esc clears all three; rule-step hints use the tooltip; green/red validity for in-terrain target.
9. **Walk jitter** — with `DEBUG_WALK` on, `mode` stays constant for the whole walk; exactly one `arrived` fires; rig `global_position` is unchanged after arrival for 60+ physics frames; body-bob stops (Torso marker at `(0,10)`).
10. **Regression** — sequential queues, reactive rules (`entered_area`, `collided`), ragdoll/recover, and prop unfreeze still work (Phase 3a/4 acceptance).
---
## 6. Risks & open questions
### Risks
1. **Mode refactor scope.** Folding `_direct_mode` into a 3-value `StageMode` touches input routing (`_handle_world_click`, `_handle_mouse_motion`, Esc ordering), status, and the visuals enable/disable paths. Low logic risk but broad; mitigate by keeping Direct internally an "Edit-with-direct" state (a thin `_enter_direct_mode()` that reuses `_enter_edit_mode()` side effects).
2. **Grid spatial dictionary correctness.** Rasterizing arbitrary AABBs (rotated/oversized props/areas) to cells is the main new algorithm; a wrong rasterization causes wrong 3-state tints. Keep the dictionary **advisory** (visual tint + skip) and never authoritative for physics; always re-derive from `World` when in doubt.
3. **Custom cursor asset.** No image assets exist. Generating a reticle/flag via `Image.create()`/`ImageTexture` at runtime avoids asset dependency; otherwise a `res://assets/` addition is required.
4. **Bresenham in world vs cell space.** Terrain cells are `TERRAIN_GRID_SIZE=16` but the stage grid is `_grid_size` (default 15). Decide which grid drives terrain painting (see Open Questions) — mixing them produces misaligned ghosts.
### Open questions (resolved by user 2026-09-02)
1. **Terrain painting grid.****Terrain grid size 16** (`StageSpawner.TERRAIN_GRID_SIZE`). Terrain cells quantize to 16, matching terrain template dimensions so blocks align edge-to-edge; the user stage grid (`_grid_size`) remains a separate visual/snap aid.
2. **"Build cancelling" vs repeated placement.** → **RMB activates build cancelling** (the plan's "LMB" was a typo). LMB keeps Phase 2 repeated placement; RMB/Esc put the tool down. §2.7 updated accordingly.
3. **Play toolbar "Pause/Restart".****Out of scope for Phase 4b.** Play keeps only the mode switcher; note as future work.
4. **Direct toolbar contents.****Hint label only.** The director stays click/popup-driven; the Direct toolbar segment shows a brief instruction hint.
5. **Grid visibility in Direct mode.****Hide the grid entirely** in Direct (amber frame only). §2.3 updated accordingly.
6. **Theme JSON location/persistence split.****Accepted:** `res://sandbox_theme.json` = hand-editable styling defaults; `user://sandbox_settings.json` = live persisted values (grid size, snap, etc.).
7. **Jitter fix confirmation.****Yes** — developer may enable `DEBUG_WALK`/`DEBUG_STAGE` during the fix and must revert both to OFF before merge.
---
## 7. Phase 4b.1 bugfix decisions (2026-09-02, post-implementation triage)
> Recorded per the fix-pipeline triage of 5 user-reported bugs. Bug 1 was a **Spec/Design
> Defect** — the resolved open-question #1 encoded a false premise. The other four are
> Implementation Defects and need no spec change beyond this record.
### Decision D1 — Terrain paint stride = template extent (Bug 1)
The earlier decision ("terrain cells quantize to `TERRAIN_GRID_SIZE` 16, matching terrain
template dimensions") is **factually wrong**: templates are Ground 200×32, Ramp 192×128,
Step 256×256 px, so stamping one full block per 16-px Bresenham cell causes massive
overlap.
**New rule (supersedes open-question #1):** terrain drag-painting quantizes to **block
units** whose stride is the active template's AABB extent per axis
(`StageSpawner.get_template_aabb(id).size`, e.g. Ground → `(200, 32)`). Cell centers are
`block_cell * stride`; Bresenham runs over block units.
- **Horizontal/vertical runs (incl. Shift-locked):** blocks tile edge-to-edge, no overlap,
no gaps.
- **Free diagonals:** blocks tile corner-to-corner (adjacent diagonal blocks share exactly
a corner point — zero overlap, visually acceptable corner gaps).
- The 16-px `_grid_cells` dictionary remains **advisory only** (§2.6.5) for the 3-state
occupancy query and already rasterizes real AABBs.
- In-drag same-type self-overlap: mark freshly painted block cells in the dictionary (or a
transient in-drag set) so a drag crossing its own path skips re-stamping (§2.6.4 Case B).
### Bug 2/4/5 decisions (implementation only, no design change)
- Bug 2 (guide line persists after release) and Bug 4 (single-placement guide circles) are
overlay redraw/state defects in `stage_placement_overlay.gd` + `_update_terrain_drag`
fix: `queue_redraw()` on clear; suppress the guide when `target == anchor` (single click).
- Bug 5 (no cursor-following terrain ghost) is a Phase 2 regression — `_spawn_ghost()`
must allow terrain again; drag start frees the single ghost, drag end re-spawns it
(LMB-repeated placement preserved).
- Bug 3 (residual waypoint jitter) extends §3.4 without changing its intent: add a
nav-termination condition (`is_navigation_finished()` gated on
`dist_to_final <= 2*ARRIVE_DISTANCE`) and remove vertical drift on final approach.