Files
stickman/plans/PHASE_4b_SPEC.md
ryan 1f91f3d2e5 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.
2026-09-04 15:08:08 -04:00

43 KiB
Raw Permalink Blame History

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; DIRECThidden; 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 Vector2iArray[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_toend_dragtransform_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_nodesset_rulesmark_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:

{
  "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.