- 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.
43 KiB
Phase 4b Polish — Implementation Specification
Status: Draft for implementation (tester/developer will refine unit tests). Source plan:
plans/PHASE_4b_POLISH.mdScope: Sandbox Stage Builder (scenes/sandbox_stage.tscn+scripts/sandbox_stage.gdand 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 ofscenes/sandbox_stage.tscn(which is a minimalNode2D+Camera2D+ emptyWorld; 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_modetoggled 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
Labelin 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:798iteratesWorldchildren each bake). StageDirectorVisualsrecomputes rule connector anchors live frominstance_from_id(...).global_positionon every_draw()(stage_director_visuals.gd:257-287), but it only redraws when_dirtyis set (:80-83), and_on_transform_committed()(sandbox_stage.gd:875-879) does not callmark_dirty()— this is the "moving a TriggerArea does not update its rule connector" bug (§8).walk_left/walk_rightbake a vertical body bob:IK_Targets/Torso:positionis keyed(0,10) → (0,-15) → (0,10) → (0,-15) → (0,10)(scripts/create_animations.gd:80; baked intomaster_rig.tscn) — relevant to the jitter bug (§11).- There is no
res://assets/directory, no.ttf, no.theme/.tresin the repo; all drawing usesThemeDB.fallback_font. The only existing sandbox config is the runtimeuser://sandbox_settings.json(sandbox_stage.gd:1500-1527, keysversion/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 .(seetests/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 statusLabelwith a bottom bar. Build aPanelContainer+HBoxContaineranchoredControl.PRESET_BOTTOM_WIDE(height ~28 px, mirroring the editor's 28 pxStatusBar), containing a leftLabel(_status_label, expands) and a rightLabel(_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_bottomstays 40 for the top bar; the bottom bar is a separate Control).- Add
_process()coordinate polling (extend the existing_processat:219): compute_camera.get_global_mouse_position(), writeX: %d Y: %dinto_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 toenum 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) intocurrent_mode. Delete_direct_button(:154) and_mode_button(:119); introduce a small array of 3Button(toggle_mode) built from aconst 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_modebehavior, show director visuals, hide spawner/grid controls._enter_edit_mode()(:366) must clear direct state (currently_enter_play_mode()clears_direct_modeat: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) — thecurrent_mode != StageMode.EDITearly return (:301) must now acceptDIRECTfor the_handle_direct_clickpath; rule-builder and rule-label hit-testing still need to work in Direct mode._unhandled_key_inputEsc ordering (:269-282) — add aDIRECTbranch (exit Direct → Edit) consistent with the current_direct_modebranch (:276-278)._handle_mouse_motion(:335) — thecurrent_mode != StageMode.EDITguard (: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-screenPanel(orColorRect) Control in theCanvasLayernamed e.g.ModeFrame,mouse_filter = MOUSE_FILTER_IGNORE, drawn behind the top bar and over the viewport, with aStyleBoxFlatthat has transparentbg_colorand aborder_color/border_widthfrom the theme JSON. Togglevisible/border_colorin 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 optionalalphadim 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 aPanelContainer(StyleBoxFlatwith accentbg_color, rounded corners, padding) containing aLabel, 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) — dropMode: …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. UseInput.set_default_cursor_shape(Input.CURSOR_CROSS)etc. Custom cursors (flag/reticle) requireInput.set_custom_mouse_cursor(texture, shape, hotspot)— note this needs an image asset (none exists yet; ares://assets/addition or a runtime-generatedImage/ImageTextureviaImage.create()is acceptable).
- New
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/dyfrom 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 ofStageGrid/StageGizmos, e.g.PlacementOverlay) or as a draw method inStageGizmos; recommend a new lightweight overlay to keepStageGizmosfocused 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/Vector2atcell * grid_size. - Replace the single
_ghostwith a ghost array: one translucentTerrainBlockper path cell (matching the active template). Update each frame in_process()/motion handling. ReuseStageSpawner.spawn()+ reparent out ofWorldinto_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):
- Empty → green ghost → instantiate on release.
- Occupied by same block type (same registry id, e.g. another
ground) → neutral/transparent ghost → skip on release (no double-create, no z-fight). - 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
DictionaryonSandboxStagekeyed by grid-cellVector2i→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
TerrainBlockfootprints can be larger than one cell (Ground is 200×32 atTERRAIN_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 useStageSelection.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].
- Because
- 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 = trueonce (not per node). Emitobject_placedper node (or add anobjects_placed(nodes)signal; keepobject_placedfor 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) anddelete_selected(:584) must update the dictionary;_rebake_navigation(:798) optionally consumes it.scripts/terrain_block.gd: add aspawn_idString(or useset_meta) so same-type overlap is detectable.scripts/stage_spawner.gd:_spawn_terrain(:202) already centers the template; expose the template extent (e.g. aget_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_idon nodes placed before this phase (none, since the dictionary is new) — but a rebuild from scratch in_ready()must scan existingWorldchildren; 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_stateand single_nav_dirtycoalescing (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), callset_placement_mode("")(frees the ghost and un-toggles the palette button viaset_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_dirtygate andqueue_redraw()every frame in_processfor simplicity, but keep the gate (cheaper) and drive it viamark_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:
{
"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 toThemeDB.fallback_fontwith a singlepush_warning.action_popup_font_size/action_popup_emoji_size→ applied viaPopupMenu.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 dedicatedemoji_fontis set, applyadd_theme_font_override("font", emoji_font).assignment_badge_font_size/assignment_badge_radius→ replacesStageDirectorVisuals.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→ replacesRULE_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 inuser://sandbox_settings.json(grid_size,:1500-1527) as the runtime source of truth;sandbox_theme.jsonsupplies the initial default (and the clamp min/max stayMIN_GRID_SIZE/MAX_GRID_SIZE,:52-53). On first run (nouser://file), seed_grid_sizefrom the theme.mode_colors→ drive the status pill (§2.4), the mode frame (§2.3), and the dashed guide line (§2.6.2). Parse viaColor(html_string)/Color.from_stringguarded with fallback constants.
Affected files/functions.
- New file
res://sandbox_theme.json(the schema above). scripts/sandbox_stage.gd: newconst 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_visualsand the new overlay.scripts/stage_director_visuals.gd: replace hardcoded font-size constants with instance vars set via a newset_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_alphafor 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:
- Custom action cursor — swap to a flag/reticle cursor (see §2.5).
- 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. - Ghost target marker — a semi-transparent flag/reticle/footprint at the cursor, snapped to grid when Snap is on; optionally a pulsing floor ring.
- 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 propetc. (reuse the existing_rule_hintstrings atsandbox_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+Labelin theCanvasLayer, positioned each frame in_process()atget_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 bySandboxStagerather 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, playswalk_left/walk_right,_walking = true,_walk_done = false._update_walking(delta)(:1067-1118) — per physics frame:- Map-sync guard:
NavigationServer2D.map_get_iteration_id(...) == 0 → return(:1076). next_feet = _nav_agent.get_next_path_position()(forces path update) (:1084).if _nav_agent.is_target_reachable():→ nav branch:if is_navigation_finished(): _finish_walk("finished"); returnelseroot_target = next_feet + FOOT_OFFSET(:1086-1092).else:→ direct branch:root_target = _walk_target_feet + FOOT_OFFSET(:1093-1099).global_position = global_position.move_toward(root_target, _walk_speed_current * delta)(:1100).if global_position.distance_to(_walk_target_feet + FOOT_OFFSET) <= ARRIVE_DISTANCE: _finish_walk("arrive"); return(:1101-1103).
- Map-sync guard:
- 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, emitsarrived(_walk_target_feet)._walk_mode("nav"|"direct") is recomputed every frame fromis_target_reachable()(:337,:1086-1099); nothing latches it.walk_left/walk_rightkeyIK_Targets/Torso:positionbetween(0,10)and(0,-15)— a ±12.5 px vertical body bob (scripts/create_animations.gd:80; baked inmaster_rig.tscntracksIK_Targets/Torso:position, and the.:facing_profiletrack).
3.3 Root-cause hypothesis (ranked; confirm at runtime with DEBUG_WALK)
-
Mode-flip oscillation (primary). Because
_walk_modeis 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 betweentrueandfalseacross consecutive frames while the agent moves. Each flip swapsroot_targetbetween:- 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 theARRIVE_DISTANCEguard finally trips.
- nav:
-
Arrival-radius mismatch + intermediate-point re-targeting (secondary).
is_navigation_finished()triggers atNAV_TARGET_DESIRED_DISTANCE(12 px, feet-space) while the hard arrival guard isARRIVE_DISTANCE(8 px, root-space). Near the destinationget_next_path_position()can return a point at/behind the agent, somove_towardsteps can reverse direction (micro-oscillation). The two different radii mean the walk can terminate early ("finished" at 12 px) or chase a now-behindnext_feetpoint. -
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 exactSTAND_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:
- Latch the mode once per walk. In
walk_to()(or on the first post-sync frame), compute_walk_modeonce (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. - 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 snapglobal_position = _walk_target_feet + FOOT_OFFSETbefore restoring markers (removes any residual offset). Drop the premature_finish_walk("finished")early-return, or keep it only when the rig is also withinARRIVE_DISTANCE. - Steer to the final target when close. When within e.g.
2 * ARRIVE_DISTANCEof the final target, ignorenext_feetand move directly toward_walk_target_feet + FOOT_OFFSET(prevents chasing a behind-path point). - 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. KeepDEBUG_WALK(:167) prints to capturemode,dist,next,finalat 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()+_themevar; 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+_processpolling (§2.1). StageMode { EDIT, DIRECT, PLAY }refactor + 3-segment control +_enter_direct_mode()(§2.2)._set_edit_controls_visible/_set_direct_controls_visiblesplit (§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). TerrainBlockspawn-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_walkingmode latch + unified arrival + snap-on-arrive + atomic stop (§3.4).DEBUG_WALKcapture 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:
- Theme JSON — missing file → defaults; malformed → defaults + single warning; overridden
action_popup_font_sizeactually changesPopupMenufont size;grid.snap_sizeseeds_grid_sizeon first run. - Status bar —
_status_cursor_coordsupdates each frame with world coords; pans/zooms reflect in the numbers. - Mode switcher — 3 segments; Edit shows spawners+grid, Direct hides them, Play hides them;
mode_changedemits 0/1/2; entering Direct from Play and vice-versa clears pending state; Esc from Direct → Edit. - Mode frame/badge/cursor — correct accent per mode; frame never intercepts input; cursor shape correct per mode and restored after cancel.
- 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.
- Grid dictionary — moving/deleting a block updates occupancy; querying a cell returns correct classification; no stale entries after delete.
- 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). - 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.
- Walk jitter — with
DEBUG_WALKon,modestays constant for the whole walk; exactly onearrivedfires; rigglobal_positionis unchanged after arrival for 60+ physics frames; body-bob stops (Torso marker at(0,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
- Mode refactor scope. Folding
_direct_modeinto a 3-valueStageModetouches 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). - 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
Worldwhen in doubt. - Custom cursor asset. No image assets exist. Generating a reticle/flag via
Image.create()/ImageTextureat runtime avoids asset dependency; otherwise ares://assets/addition is required. - Bresenham in world vs cell space. Terrain cells are
TERRAIN_GRID_SIZE=16but 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)
- 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. - "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.
- Play toolbar "Pause/Restart". → Out of scope for Phase 4b. Play keeps only the mode switcher; note as future work.
- Direct toolbar contents. → Hint label only. The director stays click/popup-driven; the Direct toolbar segment shows a brief instruction hint.
- Grid visibility in Direct mode. → Hide the grid entirely in Direct (amber frame only). §2.3 updated accordingly.
- 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.). - Jitter fix confirmation. → Yes — developer may enable
DEBUG_WALK/DEBUG_STAGEduring 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_cellsdictionary 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 whentarget == 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 ondist_to_final <= 2*ARRIVE_DISTANCE) and remove vertical drift on final approach.