- 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.
460 lines
43 KiB
Markdown
460 lines
43 KiB
Markdown
# 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 2–4). 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", 4–7 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 ~16–24 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.2–2.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` §18–20 (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.
|