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
+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.