# Phase 3a — Core Director Functionality (Implementation Specification) > **Status:** Draft for review — Architect deliverable (research + spec only; no code written). > **Source plan:** `plans/PHASE_3a_CORE_DIRECTOR.md` > **Scope:** Director Tool, waypoint visuals, per-stickman action queues, and an action runner executed on Play. --- ## 1. Overview & Goals Phase 3a turns the Sandbox Stage Builder (`scripts/sandbox_stage.gd`) into a mini "director" tool: click a stickman, choose actions from a popup, see the script as waypoints/badges in Edit mode, then press Play to run every stickman's queue. Concrete deliverables (from the plan): 1. **Milestone 1 — Navigation:** `walk_to(target)`, `is_walking()`, `arrived`, `walk_speed`. 2. **Milestone 2 — Queue:** `action_queue`, `queue_action`, `clear_queue`, `get_queue`, `remove_action`, `insert_action`, `queue_size`. 3. **Milestone 3 — Director UI:** "Direct" palette button, stickman click detection, action popup (Walk To / Speak / Wait / Ragdoll / Recover), text + duration dialogs. 4. **Milestone 4 — Waypoint visuals:** dots, dashed lines, action badges, order numbers; visible in Edit, hidden in Play. 5. **Milestone 5 — Runner:** `start_queue`, `stop_queue`, `is_queue_running`, `action_started` / `action_finished` / `queue_finished`. --- ## 2. Key architectural decisions (with rationale) ### D1. Navigation is **a real nav mesh** (`NavigationAgent2D` + `NavigationRegion2D`). Per the user's decision, 3a builds the navigation mesh now. The sandbox world is given a code-built `NavigationRegion2D` carrying a procedural `NavigationPolygon` generated from the placed `TerrainBlock` footprints, and each `StickmanRig` gets a `NavigationAgent2D` child that `walk_to` drives. **Nav-mesh generation approach — chosen: per-block polygon decomposition (manual `NavigationPolygon`).** Three options were considered: - **(a) union of top surfaces into one outline** — requires extracting/merging top edges of arbitrary rotated concave blocks; error-prone. - **(b) coarse grid re-baked on change** — robust but coarse (blocky paths, grid-resolution tuning) and requires marching-squares boundary tracing. - **(c) simplified axis-aligned coverings** — cheap but ignores rotation (a rotated ramp's AABB spans empty air), poor fidelity. The chosen approach is **per-block polygon triangulation into one manual `NavigationPolygon`**: each `TerrainBlock`'s world-space polygon (already sanitized by `TerrainUtils`: grid-snapped, simplified, clockwise, simple — concave allowed, no holes) is convex-decomposed and triangulated into convex navigation polygons. Rationale: - **Robust to concavity** — `Geometry2D.decompose_polygon_in_convex()` (with fan triangulation) handles any simple concave block (e.g. the concave `step` template), and `TerrainBlock` already relies on the polygon being clean/convex-decomposable (`BUILD_SOLIDS`). - **Robust to rotation/scale** — each block's polygon is transformed to world space via `block.transform * p` before triangulation. - **Deterministic + non-deprecated** — uses only `NavigationPolygon.set_vertices()` / `add_polygon()` and stable `Geometry2D` helpers. It avoids the deprecated `make_polygons_from_outlines()` and the experimental `NavigationServer2D.bake_from_source_geometry_data()` rasterized baker (which needs `baking_rect`/`cell_size` tuning and source-geometry setup). - **Re-bake is trivial** — build a fresh `NavigationPolygon` from the current block set and reassign `region.navigation_polygon` (reassignment re-syncs the region with the `NavigationServer2D`). **Agent placement (critical correctness detail):** the `NavigationAgent2D` is a child of the rig **at the feet**, local position `(0, +385)` (= `-FOOT_OFFSET`), so the agent's global position is on the nav mesh (ground level). The agent never paths through air: its `target_position` and `get_next_path_position()` are both ground-level global coordinates (the whole `NavigationAgent2D` path API is global-space in Godot 4.2+), and the rig translates those ground points into root movement via `+FOOT_OFFSET`. The agent and region both live on the **default navigation map, layer 1**, so no explicit map/layer assignment is required. ### D2. `walk_to(target)` treats `target` as a **feet/ground destination**. The rig root (`Master`) sits at the hips; its feet rest ~385 px below (rig-local `+Y`). The director clicks the **ground**, so the intuitive contract is "feet land where I click." `walk_to(target)` sets the agent target to `target` directly (a ground point), and the rig converts each ground-level path point back to a root position with `FOOT_OFFSET := Vector2(0.0, -385.0)` (matches `StageSpawner.STICKMAN_FOOT_OFFSET`). The waypoint dot is drawn at the stored `target` (ground level). The foot offset lives in exactly one place — the rig, which owns its own geometry (see D1 for the agent-at-feet detail). ### D3. Play mode **runs the director script**; it no longer auto-ragdolls stickmen. Today `_enter_play_mode()` calls `rig.set_ragdoll(true)` on every stickman (a physics sandbox). That is incompatible with "walk / speak in Play." Phase 3a changes Play to: - keep stickmen **ANIMATED** and call `start_queue()` on each; - `ragdoll`/`recover` become explicit queue actions (a stickman only falls when directed). Props still unfreeze and tumble in Play (physics fun is preserved and can knock a *directed* ragdoll). This is a deliberate behavior change to the existing sandbox. ### D4. Runner = **explicit state machine advanced in `_physics_process`** (not `await` coroutines). The rig already uses `_physics_process` for momentum + rest detection and uses explicit state enums (`RigState`). An `await`-based runner is awkward to interrupt cleanly (`stop_queue()` while awaiting `arrived` / `ragdoll_rested` / `state_changed` requires racing multiple signals). A small state machine (`RunnerState` + `ActionPhase`) driven in `_physics_process` is trivially interruptible and matches the codebase style. Signals (`arrived`, `action_started`, `action_finished`, `queue_finished`) are still emitted so the plan's public contract holds. ### D5. Ragdoll rest is **exposed** for the runner; recovery waits for `ANIMATED`. The ragdoll action must "wait for rest" (plan). `_update_rest_detection()` already computes `at_rest` but currently gates on `auto_recover` and never publishes the result. We refactor it to record `_ragdoll_at_rest` **regardless of `auto_recover`**, expose `is_ragdoll_at_rest()`, and keep auto-recovery exactly as-is. The `recover` action calls `request_recovery()` then waits for `state == RigState.ANIMATED` (the existing `state_changed` signal). `auto_recover` stays `false` in Play (the director owns recovery). ### D6. Speech bubble = world-space `Node2D` child of the rig, drawn in code. No speech UI exists. A `SpeechBubble extends Node2D` drawn via `_draw()` + `ThemeDB.fallback_font` avoids `Control`-in-world-space scale/pivot pitfalls and matches the project's `_draw()`-heavy style. It is a child of the rig root at a fixed upward offset, so it follows the figure as it walks and scales with the camera (world-space). ### D7. Waypoint visualization = a **new** `StageDirectorVisuals` drawn layer. A dedicated `Node2D` layer (mirroring `StageGrid` / `StageGizmos`) owns director visuals. `StageGizmos` stays focused on selection/hover/rotate. The visuals node reads each rig's queue and redraws on a dirty flag set by `queue_changed` + object/mode signals. ### D8. Multiple stickmen = per-rig queues + per-rig runners (no shared state). Every `StickmanRig` owns its own `action_queue`, runner state, and signals. `SandboxStage` simply calls `start_queue()` on each rig on Play. This is already structurally supported (rigs are independent `Node2D` children of `World`). ### D9. Queues are **not persisted** in 3a. The plan doesn't mention serialization; `settings.json` only stores grid/snap. Queues live in memory and are lost on scene reload. Save/Load is a later phase (ROADMAP). --- ## 3. Coordinate conventions (verified against the codebase) - **World Y is DOWN** (standard Godot 2D). Confirmed by `physics_test_harness.gd` (`GROUND_TOP_Y = 0.0`, `KNOCK_UP_VELOCITY = (0,-450)` = "up"), the rig's `STAND_POSE` (head at `y=-614`, feet at `y≈+380..390`), and `StageSpawner.STICKMAN_FOOT_OFFSET = (0,-385)` (root placed 385 px *above* the feet cursor). - **Rig root = hips.** Feet ≈ 385 px below root. Head ≈ 614 px above root. - **Camera:** `Camera2D` at `(0,-400)`, zoom `(0.5,0.5)`, middle-drag pan, wheel zoom (`min_zoom 0.1` / `max_zoom 6.0`). --- ## 4. File-by-file changes ### 4.1 `scripts/stickman_rig.gd` (primary changes) Add navigation/walking, speech, action queue, and runner. All public, strictly typed, null-guarded, `push_warning` prefixed `"StickmanRig: "` (matching existing style). **New signals:** ```gdscript signal arrived # walk_to reached its destination signal action_started(action: Dictionary, index: int) signal action_finished(action: Dictionary, index: int) signal queue_finished # queue ran to completion (not on stop) signal queue_changed # any mutation (queue_action/insert/remove/clear) signal speech_finished # bubble auto-hid after speak() ``` **New enums:** ```gdscript enum RunnerState { IDLE, EXECUTING } enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING } ``` **New constants:** ```gdscript const FOOT_OFFSET := Vector2(0.0, -385.0) # feet -> root (ground point -> hips) const NAV_AGENT_LOCAL_POS := Vector2(0.0, 385.0) # == -FOOT_OFFSET; agent sits at the feet const ARRIVE_DISTANCE := 8.0 # fallback px to root destination const NAV_PATH_DESIRED_DISTANCE := 8.0 # agent: "reached a path point" radius const NAV_TARGET_DESIRED_DISTANCE := 12.0 # agent: "reached target" radius const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0) # rig-local anchor above the head ``` **New exports / state:** ```gdscript @export var walk_speed: float = 300.0 var action_queue: Array[Dictionary] = [] var _runner_state: RunnerState = RunnerState.IDLE var _action_phase: ActionPhase = ActionPhase.NONE var _current_index: int = -1 var _stop_requested: bool = false var _nav_agent: NavigationAgent2D = null # child at NAV_AGENT_LOCAL_POS (feet) var _walking: bool = false var _walk_target_feet: Vector2 = Vector2.ZERO # stored feet/ground destination var _walk_speed_current: float = 300.0 var _walk_mode: String = "nav" # "nav" (follow mesh) | "direct" (off-mesh straight line) var _walk_done: bool = false var _ragdoll_at_rest: bool = false # set once at first rest after entering ragdoll var _speech_bubble: SpeechBubble = null var _speech_active: bool = false var _speech_time_left: float = 0.0 var _phase_timer: float = 0.0 # WAIT duration countdown ``` **`_ready()` addition — build the agent:** ```gdscript _nav_agent = NavigationAgent2D.new() _nav_agent.name = "NavigationAgent2D" _nav_agent.position = NAV_AGENT_LOCAL_POS # feet level (on the nav mesh) _nav_agent.path_desired_distance = NAV_PATH_DESIRED_DISTANCE _nav_agent.target_desired_distance = NAV_TARGET_DESIRED_DISTANCE _nav_agent.path_max_distance = 100.0 _nav_agent.max_speed = walk_speed _nav_agent.avoidance_enabled = false # no RVO in 3a (see tech-debt #13) add_child(_nav_agent) ``` (The agent and the stage's `NavigationRegion2D` share the **default navigation map, layer 1**, so no `set_navigation_map`/layer wiring is needed.) **Navigation / walking API:** ```gdscript ## Start walking so the feet land at `target` (world/ground space). `speed <= 0` uses ## walk_speed. No-op (push_warning) unless state == ANIMATED. func walk_to(target: Vector2, speed: float = -1.0) -> void func is_walking() -> bool ``` `walk_to` behavior: 1. Guard `state == RigState.ANIMATED`. 2. `_walk_target_feet = target`. 3. `_nav_agent.target_position = target` (global ground point — see D1). 4. Set facing from horizontal delta (`dx < -0.5` → `FacingProfile.LEFT` + play `walk_left`; `dx > 0.5` → `FacingProfile.RIGHT` + play `walk_right`; vertical-only → keep facing, play `walk_right`). 5. `_anim_player.play(name)` (walk anims are authored `LOOP_LINEAR`, so they loop). 6. `_walking = true`, `_walk_done = false`. Per-frame `_update_walking(delta)` (added to `_physics_process`). **Walk-loop order (hybrid policy, 2026-08-29 follow-up):** map-sync guard → forced path query → reachability branch (nav vs direct) → shared arrive fallback: - If `state != ANIMATED` → `_cancel_walking()` (stop anim + clear agent path, no `arrived`). - **Map-sync guard:** if `NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map()) == 0` → return early (defer all nav reads until the map has actually synchronized). An unsynced agent reports an empty, finished path, which would otherwise end the walk after one `move_toward` step (~5 px). - **Forced path query:** `var next_feet: Vector2 = _nav_agent.get_next_path_position()` — call **before** any reachability / finished check. This forces the agent's internal `_update_navigation()`, which re-queries the map when the stored path is empty (`set_target_position` resets it via `_request_repath`). The read-only `get_current_navigation_path()` accessor alone **never triggers a repath**, so checking it directly would leave the path empty forever and every walk would be misjudged. - **Reachability branch — `if _nav_agent.is_target_reachable():`** - **NAV branch (target on the mesh):** set `_walk_mode = "nav"`. If `_nav_agent.is_navigation_finished()` → `_finish_walk("finished")`. Else `root_target = next_feet + FOOT_OFFSET` (follow the path as before). - **DIRECT branch (off-mesh waypoint — the new behavior):** set `_walk_mode = "direct"`. `root_target = _walk_target_feet + FOOT_OFFSET` — steer **straight at the waypoint the user clicked**, ignoring the nav mesh. **No `push_warning`**: an off-mesh waypoint is now a normal, supported case; log it only via `_walk_dbg(...)`. - **Move:** `global_position = global_position.move_toward(root_target, _walk_speed_current * delta)` (both branches converge on their own `root_target`). - **Shared arrive fallback (both branches):** `if global_position.distance_to(_walk_target_feet + FOOT_OFFSET) <= ARRIVE_DISTANCE` → `_finish_walk("arrive")`. This is the **convergence guarantee** for the DIRECT branch (direct steering reaches the waypoint exactly, so the original "moves a little then stops" symptom cannot return) and a backstop for the NAV branch. `_finish_walk` still emits `arrived`, so the runner never hangs. The `<= ARRIVE_DISTANCE` check is a root-distance comparison — `move_toward` can overshoot by at most one step, but the branch already returned via `_finish_walk` the frame it crossed the threshold, so no overshoot special-casing is required. > **Off-mesh waypoint behavior (changed 2026-08-29 — this supersedes the earlier "finish in > place" policy):** a `walk_to` target that `is_target_reachable()` reports false (off the nav > mesh, or no mesh at all) is now **walked to directly in a straight line** at `walk_speed` — > it is **not** rejected with a warning and the rig does **not** stand still. This guarantees > the stickman always walks to the waypoint the user clicked. The DIRECT branch replaces the > old "warn + `_finish_walk("unreachable")` in place" behavior; the `_walk_path_grace` counter > is therefore **removed** (the map-sync guard + forced path query make it unnecessary). > `_finish_walk()` keeps its internal `reason: String` param (now only `"finished"` / > `"arrive"`) used by the debug trace. **Waypoint capture decision (2026-08-29):** the sandbox does **not** validate or snap the waypoint onto the nav mesh at capture time (`_handle_direct_click` keeps the raw click position). The waypoint dot is drawn exactly where the user clicked; when that point is off-mesh, the rig walks straight there (DIRECT branch). This keeps the authored target the source of truth and avoids silently moving the user's waypoint. `_finish_walk(reason: String = "")`: stop the animation, re-apply `STAND_POSE` markers (`_restore_standing_markers()`), `_walk_done = true`, `_walking = false`, `arrived.emit()`. Once finished, stop calling `get_next_path_position()` (avoids jitter per the agent docs). The `reason` param is internal (used only by the debug trace). `_cancel_walking()`: stop the animation, `_nav_agent.target_position = _nav_agent.global_position` (clears the path), `_walking = false`, `_walk_done = false` (no `arrived`). **Ragdoll interaction:** `_enter_ragdoll()` additionally calls `_cancel_walking()` so a ragdolled rig has no stale walk/path state. The agent is a passive helper node (not a physics body) — it does not interfere with the ragdoll network, and `_update_walking`'s `state != ANIMATED` guard prevents it being read while ragdolled. > The `walk_left`/`walk_right` animations key the IK targets in-place and carry a discrete > `.:facing_profile` track, so playing the matching clip both swings limbs and (re)sets the > facing profile/z-order/head-flip. Root translation composes with the in-place limb > animation. Movement is **kinematic** (`global_position.move_toward` in `_physics_process`; > the rig is a plain `Node2D`, no `CharacterBody2D`), so no `NavigationAgent2D.velocity` / > `velocity_computed` RVO handling is used (avoidance is disabled). **Speech API:** ```gdscript ## Show the speech bubble with `text` for `duration` seconds; auto-hides and emits ## speech_finished. Lazily creates the SpeechBubble child on first use. func speak(text: String, duration: float) -> void ``` `_update_speech(delta)` (added to `_physics_process`) counts down `_speech_time_left` and on expiry hides the bubble, sets `_speech_active = false`, emits `speech_finished`. **Queue API:** ```gdscript func queue_action(action: Dictionary) -> void # appends; emits queue_changed func clear_queue() -> void # empties; emits queue_changed func get_queue() -> Array[Dictionary] # returns action_queue.duplicate() func remove_action(index: int) -> void # bounds-checked; emits queue_changed func insert_action(index: int, action: Dictionary) -> void # clamps index; emits queue_changed func queue_size() -> int ``` **Runner API:** ```gdscript func start_queue() -> void # IDLE -> EXECUTING; empty queue emits queue_finished immediately func stop_queue() -> void # aborts current action; IDLE; does NOT emit queue_finished func is_queue_running() -> bool # _runner_state == EXECUTING ``` **Ragdoll rest refactor** (`_update_rest_detection` + `_enter_ragdoll`): - `_enter_ragdoll()` also resets `_ragdoll_at_rest = false`. - In `_update_rest_detection`, compute `at_rest` as today; when `at_rest` first becomes true set `_ragdoll_at_rest = true` (and optionally emit `ragdoll_rested`, but the runner polls). The existing `auto_recover` gate and timer/stabilize logic are unchanged. - New public query: `func is_ragdoll_at_rest() -> bool` (returns `_ragdoll_at_rest`; only meaningful in `RAGDOLL`). **`_physics_process` ordering (final):** ```gdscript _track_momentum(delta) _update_rest_detection(delta) _update_walking(delta) _update_speech(delta) _update_runner(delta) ``` ### 4.2 `scripts/stickman_speech_bubble.gd` (NEW) `class_name SpeechBubble extends Node2D` — a world-space bubble drawn in `_draw()`. ```gdscript const FONT_SIZE := 28 const PADDING := Vector2(14.0, 10.0) const TAIL_HEIGHT := 12.0 const MAX_WIDTH := 320.0 const BG_COLOR := Color(1.0, 1.0, 1.0, 0.95) const BORDER_COLOR := Color(0.0, 0.0, 0.0, 0.6) const TEXT_COLOR := Color(0.0, 0.0, 0.0, 1.0) var _text: String = "" func show_text(text: String) -> void # store, visible = true, queue_redraw() func hide_bubble() -> void # visible = false func _draw() -> void ``` `_draw()` measures text with `ThemeDB.fallback_font.get_string_size(_text, HORIZONTAL_ALIGNMENT_LEFT, MAX_WIDTH, FONT_SIZE)`, draws a rounded-rect background + a downward tail triangle centered on the rig-local origin (the origin is the anchor above the head), then `draw_string(...)` the text. `visible = false` by default. No hit-testing. ### 4.3 `scripts/stage_director_visuals.gd` (NEW) `class_name StageDirectorVisuals extends Node2D` — Edit-mode director overlay. ```gdscript const WAYPOINT_RADIUS_PX := 6.0 const WAYPOINT_COLOR := Color(0.2, 0.5, 1.0) # blue const WAYPOINT_OUTLINE := Color.WHITE const DASH_COLOR := Color(1.0, 1.0, 1.0, 0.6) const DASH_WIDTH_PX := 2.0 const DASH_LENGTH_PX := 6.0 const DASH_GAP_PX := 4.0 const NUMBER_COLOR := Color.WHITE const ICON_COLOR := Color(1.0, 1.0, 1.0, 0.9) const ICON_SIZE_PX := 12.0 var camera: Camera2D = null var world: Node2D = null var enabled: bool = true var _dirty: bool = true func set_enabled(value: bool) -> void # visible = value; queue_redraw() func mark_dirty() -> void # _dirty = true func _process(_delta: float) -> void # if _dirty: _dirty=false; queue_redraw() func _draw() -> void func _zoom() -> float # maxf(camera.zoom.x, 0.0001) (screen-constant sizes) func _collect_rigs() -> Array[StickmanRig] # world children filtered `is StickmanRig` ``` Drawing rules (per rig, in queue order `i = 0..n-1`): - **walk_to** → blue dot with white outline at `action["target"]` (ground point), drawn with radius `WAYPOINT_RADIUS_PX / _zoom()`; order number `i+1` drawn beside it. - **Dashed line** connecting consecutive walk_to dots (`draw_dashed_line`, `DASH_*` constants divided by `_zoom()`); also a dashed line from the rig's current feet position to the first walk_to dot. - **speak / wait / ragdoll / recover** → a small badge (speech bubble / clock / X / up-arrow glyph + its order number) drawn at the **stickman's position at that point in the sequence** — i.e. the position derived by simulating the queue: start at the rig's current feet position, then for each `walk_to` update the "current position" to that waypoint; a non-walk action anchors to the current position *at the time the action is reached* (see the anchoring rule in §8). Multiple consecutive non-walk badges at the same point stack with a small `(0, -28)` world-unit offset per badge (upward) so they don't overlap. - All sizes divided by `_zoom()` so markers stay screen-constant while panning/zooming (matches `StageGizmos`). ### 4.4 `scripts/sandbox_stage.gd` (changes) **New preload:** ```gdscript const STAGE_DIRECTOR_VISUALS := preload("res://scripts/stage_director_visuals.gd") ``` **New constants (popup item ids):** ```gdscript const ACT_WALK := 0 const ACT_SPEAK := 1 const ACT_WAIT := 2 const ACT_RAGDOLL := 3 const ACT_RECOVER := 4 ``` **New state:** ```gdscript var _direct_mode: bool = false var _direct_button: Button = null var _action_popup: PopupMenu = null var _context_rig: StickmanRig = null # the stickman being directed var _pending_walk_target: bool = false # "Walk To" awaiting a stage click var _speak_dialog: AcceptDialog = null var _speak_edit: LineEdit = null var _wait_dialog: AcceptDialog = null var _wait_spin: SpinBox = null var _director_visuals: StageDirectorVisuals = null var _nav_region: NavigationRegion2D = null # code-built navigation region (child of stage) var _nav_dirty: bool = true # re-bake pending (set on terrain place/move/rotate/delete) ``` **Navigation region (`_build_navigation()`, called in `_ready` after the world is available):** ```gdscript func _build_navigation() -> void: _nav_region = NavigationRegion2D.new() _nav_region.name = "NavigationRegion2D" add_child(_nav_region) # child of SandboxStage (NOT World, so it is never _rebake_navigation() # hit-tested / selected / deleted as a World child) ``` The region sits at the stage origin `(0,0)`. Because `World` is at identity, world coordinates equal region-local coordinates (see §3). **Nav-mesh generation (`_rebake_navigation()`, per D1):** ```gdscript func _rebake_navigation() -> void: var verts := PackedVector2Array() var triangles: Array[PackedInt32Array] = [] for child: Node in _world.get_children(): var block := child as TerrainBlock if block == null: continue var pts := PackedVector2Array() for p: Vector2 in block.polygon_points: pts.append(block.transform * p) # block-local -> world (== region-local) var pieces := Geometry2D.decompose_polygon_in_convex(pts) if pieces.is_empty(): pieces = [pts] # fallback: assume convex for piece: PackedVector2Array in pieces: var base: int = verts.size() verts.append_array(piece) for i: int in range(1, piece.size() - 1): triangles.append(PackedInt32Array([base, base + i, base + i + 1])) var poly := NavigationPolygon.new() poly.set_vertices(verts) # winding must be consistent; if the for tri: PackedInt32Array in triangles: # headless pathing test fails, reverse poly.add_polygon(tri) # the winding of every triangle. _nav_region.navigation_polygon = poly # reassignment re-syncs with the server ``` Notes: - Only `TerrainBlock` children of `World` are baked. The placement ghost is reparented into `_ghost_holder` (not `World`), so it is **never** part of the nav mesh. - `NavigationPolygon` and the region both use the **default navigation map + cell size** (leave `cell_size` at its default; do not override the map's cell size in 3a). If the headless pathing test finds no paths, check `NavigationServer2D.map_get_cell_size(map)` vs. `NavigationPolygon.cell_size`. **Re-bake hooks (dirty-flag approach):** - `_place_at(...)`: after a successful spawn, `if node is TerrainBlock: _nav_dirty = true`. - `_on_transform_committed(nodes)` (existing `StageGizmos.transform_committed` handler): if any node in `nodes` is a `TerrainBlock` → `_nav_dirty = true` (covers move **and** rotate, since the rotate ring also emits `transform_committed` on drag end). - `delete_selected()`: if any deleted node is a `TerrainBlock` → `_nav_dirty = true`. - `_process(...)`: `if _nav_dirty: _nav_dirty = false; _rebake_navigation()` (coalesces a burst of changes into one bake; terrain changes are discrete events, not per-frame). The nav mesh is only consumed in Play (agents path during `walk_to`), so re-baking on Edit-mode terrain edits is safe and cheap (few blocks). **`_ready()`:** add `_build_navigation()` and `_build_director_visuals()` (creates `DirectorVisualsLayer`, sets `camera`/`world`, `add_child`) after the gizmo layer. UI already code-built, so no `.tscn` change is required (see §4.6). **`_build_ui()` additions (after the palette loop, before grid controls):** - `_direct_button` toggle `Button`, text `"Direct"`, `toggled → _on_direct_toggled`. - `_action_popup = PopupMenu.new()` added to the `UI` CanvasLayer (not the hbox) with items `Walk To` / `Speak` / `Wait` / `Ragdoll` / `Recover` (ids above); `id_pressed → _on_action_popup_id_pressed`. - `_speak_dialog` (`AcceptDialog`, title "Speak") + `_speak_edit` (`LineEdit`, expand fill, placeholder "Say something…"); `confirmed → _on_speak_confirmed`. - `_wait_dialog` (`AcceptDialog`, title "Wait") + `_wait_spin` (`SpinBox`, 0.1–10 s, step 0.1, default 1.0); `confirmed → _on_wait_confirmed`. **Mutual exclusivity:** `_on_direct_toggled(pressed)` sets `_direct_mode`, and when on, calls `set_placement_mode("")` + `_selection.clear_selection()` + cancels pending target. `_on_palette_toggled` sets `_direct_mode = false` + `_direct_button.set_pressed_no_signal(false)`. **Click detection (Edit, left-click):** in `_handle_world_click`, insert a direct-mode branch **before** the gizmo/placement/selection branches: ```gdscript if _direct_mode: _handle_direct_click(mb, world_pos) return ``` `_handle_direct_click`: - If `_pending_walk_target` and `_context_rig` valid → append `{"type":"walk_to","target":world_pos}` to `_context_rig`; clear pending + context. - Else `var hit := _selection.hit_test(world_pos)`; if `hit is STICKMAN_RIG` → `_context_rig = hit`, position `_action_popup` at the mouse and `popup()`. Non-stickman clicks are ignored. **Popup handler `_on_action_popup_id_pressed(id)`** (guards `_context_rig` valid): - `ACT_WALK` → `_pending_walk_target = true`. - `ACT_SPEAK` → clear `_speak_edit`, `_speak_dialog.popup_centered()`. - `ACT_WAIT` → `_wait_spin.value = 1.0`, `_wait_dialog.popup_centered()`. - `ACT_RAGDOLL` → `queue_action({"type":"ragdoll"})`. - `ACT_RECOVER` → `queue_action({"type":"recover"})`. **Dialog confirmations:** - `_on_speak_confirmed` → `queue_action({"type":"speak","text":_speak_edit.text,"duration":2.0})`. - `_on_wait_confirmed` → `queue_action({"type":"wait","duration":_wait_spin.value})`. **Escape handling** (`_unhandled_key_input` `KEY_ESCAPE` branch), in priority order: 1. cancel `_pending_walk_target` + clear `_context_rig`; 2. else if `_direct_mode` → exit direct mode (`_direct_button.set_pressed_no_signal(false)`); 3. else existing placement/selection clears. **Mode changes:** - `_enter_edit_mode()`: for each stickman `stop_queue()` **then** `snap_to_standing()`; `_director_visuals.set_enabled(true)`. - `_enter_play_mode()`: hide/clear director UI (`_action_popup.hide()`, `_pending_walk_target=false`, `_context_rig=null`, exit direct mode), `_director_visuals.set_enabled(false)`; unfreeze props as today; then for each stickman `rig.auto_recover = false; rig.start_queue()` (replacing the old auto-`set_ragdoll(true)`). **Signal wiring for visuals:** - `_on_selection_changed` unchanged. New: in `object_placed` handling (or `_place_at`), if `node is STICKMAN_RIG` connect `node.queue_changed → _director_visuals.mark_dirty` and `_director_visuals.mark_dirty()`. `object_deleted` → `mark_dirty()`. `set_mode` → `_director_visuals.set_enabled(...)`. - Add `_direct_button.visible` to `_set_build_controls_visible(...)`. ### 4.5 `scripts/stage_gizmos.gd` **No changes.** Director visuals live in `StageDirectorVisuals` (D7). Gizmos remain the selection/hover/rotate layer. ### 4.6 `scenes/sandbox_stage.tscn` **No change required.** All stage UI is built in code (`_build_ui`), so the "Direct" button is added programmatically (consistent with the existing palette/grid/snap controls). The scene already has only `SandboxStage`, `Camera2D`, and `World` — the visuals layer and popup are instantiated at runtime. ### 4.7 Other files `stage_spawner.gd`, `stage_selection.gd`, `stickman_factory.gd`, `stk_rig_adapter.gd`, `master_rig.tscn` — **unchanged.** --- ## 5. Action data model Action dictionary shapes (GDScript `Dictionary`; `type` is the discriminator): | type | required keys | optional keys | | ----------- | -------------------------------------- | ------------------- | | `"walk_to"` | `"target": Vector2` (feet destination) | `"speed": float` | | `"speak"` | `"text": String` | `"duration": float` | | `"wait"` | `"duration": float` | — | | `"ragdoll"` | — | — | | `"recover"` | — | — | `action_queue` is an `Array[Dictionary]`; index order = execution order = visual order. Z-order/queue position is by array index (no separate "id"). Unknown `type` in `_begin_action` → `push_warning` + the action is skipped (treated as completed). --- ## 6. Runner state machine (Milestone 5) ``` start_queue() IDLE ───────────────────────────────► EXECUTING ▲ │ │ queue empty → queue_finished │ per action: action_started(action, i) │ │ ├─ walk_to → walk_to(); wait for _walk_done │ stop_queue() (abort, no signal) │ ├─ speak → speak(); wait for !_speech_active │ │ ├─ wait → countdown _phase_timer └──────────────────────────────────────┤ ├─ ragdoll → set_ragdoll(true); wait is_ragdoll_at_rest() │ └─ recover → request_recovery(); wait state==ANIMATED │ action_finished(action, i) │ └─ index past end → queue_finished → IDLE ``` Implementation notes: - `start_queue()`: no-op if already `EXECUTING`; empty queue → emit `queue_finished`, return. - `_update_runner(delta)` (in `_physics_process`) advances per `ActionPhase`: - `NONE` → `_advance_to_next_action()` (emit `action_started`, `_begin_action`). - `WALKING` → complete when `_walk_done`. - `SPEAKING` → complete when `not _speech_active`. - `WAITING` → `_phase_timer -= delta`; complete at `<= 0`. - `RAGDOLLING` → complete when `is_ragdoll_at_rest()`. - `RECOVERING` → complete when `state == RigState.ANIMATED`. - `stop_queue()`: `_stop_requested = true`, `_cancel_walking()` + hide speech, `_runner_state = IDLE`, `_action_phase = NONE`, `_current_index = -1`. Does **not** emit `queue_finished` (the queue was aborted, not completed). Does not force a ragdoll out of `RAGDOLL` (EDIT re-entry handles that via `snap_to_standing`). **Ragdoll / recover integration with the existing state machine (Q3):** - The runner only starts walking/speaking when the rig is `ANIMATED`; `walk_to` self-guards. - `ragdoll` action → `set_ragdoll(true)` (existing instant handoff) → wait for `is_ragdoll_at_rest()` (new, auto-recover independent). - `recover` action → `request_recovery()` (no-op if not ragdolled) → wait for `state == ANIMATED` (existing `state_changed` → `_on_stand_up_finished`). - If `stop_queue()` or EDIT re-entry interrupts mid-ragdoll, `snap_to_standing()` (existing) resets the rig. --- ## 7. UI flow (Milestone 3) 1. Press **"Direct"** (toggle). Palette spawn modes are cleared (mutually exclusive). 2. Click a stickman → `StageSelection.hit_test` → if stickman, open `_action_popup` at cursor. 3. Choose: - **Walk To** → enters pending mode; status hint ("Click stage for walk target — Esc to cancel"). - **Speak** → text dialog → append `speak` action. - **Wait** → duration dialog → append `wait` action. - **Ragdoll / Recover** → append immediately. 4. Pending **Walk To**: next left click on the stage appends `{"type":"walk_to","target":click_pos}`; **Esc** (and optionally right-click) cancels. 5. Waypoints/badges update immediately via `queue_changed → mark_dirty`. 6. Press **Play** → waypoints hide, all stickmen run their queues; input frozen (existing `current_mode != EDIT` guards + hidden build controls). Popup/dialog construction follows the code-built `CanvasLayer` pattern already in `_build_ui()`. `AcceptDialog` is used (matches editor conventions; no custom modal needed). Focus: `_speak_edit.grab_focus()` when the speak dialog opens (accessibility rule). --- ## 8. Waypoint & action-badge visual design (Milestone 4) Every action in a queue is shown in Edit mode. **Walk actions** render as waypoint dots with dotted connectors; **non-walk actions** (speak / wait / ragdoll / recover) render as small floating badges at the stickman's position *at that moment in the sequence*. **Badge anchoring rule (per the user's decision):** > Every action happens where the stickman is at that moment. A non-walk action's badge > anchors to the stickman's position **at that point in the sequence** — the position > resulting from the most recent preceding `walk_to`, or the stickman's current (Edit-mode) > position if no `walk_to` precedes it. Concretely, `StageDirectorVisuals` computes an anchor point by simulating the queue: 1. `current_pos := rig's feet position` (the rig root + `FOOT_OFFSET`). 2. Walk the queue in order; for each action: - if `walk_to`: draw the waypoint dot at `action["target"]`, extend the dashed line from `current_pos` to `target`, then `current_pos := target`. - else: draw the badge at `current_pos` (the stickman's position at that moment), leaving `current_pos` unchanged. Anchoring is therefore **queue-derived**, not live-tracked: if the user drags the stickman in Edit mode, only badges *before the first walk_to* move (they follow the rig's current position); badges after a `walk_to` stay pinned to their waypoint-derived positions. This keeps the visual deterministic and independent of drags mid-script (the authored waypoint is the source of truth). The rig's current feet position is read each redraw, so leading badges do follow Edit-mode drags live. **Visual elements:** - **Waypoint dot:** blue `Color(0.2, 0.5, 1.0)` fill, white outline, radius `WAYPOINT_RADIUS_PX / zoom` (screen-constant). - **Dashed connector:** white `alpha 0.6`, `draw_dashed_line`, width/length/gap divided by zoom, connecting consecutive walk_to dots (and rig start → first dot). - **Badges:** speech bubble (rounded rect + tail), clock (circle + hands), ragdoll ("X"), recover (up arrow); drawn in white `alpha 0.9`, `ICON_SIZE_PX / zoom`; consecutive badges at the same anchor stack upward `(0, -28)/zoom`. - **Order numbers:** `1, 2, 3…` beside each dot/badge, white, via `ThemeDB.fallback_font` `draw_string`. - **Visibility:** `set_enabled(true)` in Edit; `false` in Play. No hit-testing (pure draw). --- ## 9. Edit / Play behavior summary | Concern | EDIT | PLAY | | ------- | ---- | ---- | | Direct tool + popup | active | hidden / cleared | | Waypoint visuals | visible | hidden | | Stickmen | standing, queues editable | run `start_queue()` (walk/speak/ragdoll/recover) | | Props | frozen (kinematic) | unfrozen, fall | | User input on stage | full (place/direct/select) | none (mode guard) | | `auto_recover` | (unchanged) | `false` (director owns recovery) | | Return to EDIT | `stop_queue()` + `snap_to_standing()` | — | --- ## 10. Testing plan ### 10.1 What test infrastructure exists today (findings) - **No GUT addon** (`addons/` is absent in this checkout despite AGENTS.md mentioning the legacy `curved_lines_2d` addon — it is not present on disk). No `res://test/` directory. - **No CLI test command.** `project.godot` has no test autoload; `run/main_scene` is the editor. - **Established pattern: headless scripted smoke tests** — previous phases (per `docs/phase9_*` specs and `BUGS.md`) verified work with **temporary headless `SceneTree` scripts** run against the console build, e.g.: ``` ..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit ``` (the `--headless --check-only --quit` variant; plain `--check-only` hangs on renderer init in 4.7.x). The tester agent profile assumes GUT, but GUT is **not installed** — so the Tester should use the headless-script pattern, not GUT, unless GUT is added first. ### 10.2 Automated checks (Tester) 1. **Parse check** (all changed scripts): `..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit` 2. **Headless queue/runner smoke test** (temporary `SceneTree` script, deleted after): - `StickmanFactory.spawn_from_data(load_stk("res://stickmen/test.stk"))`, `add_child`, await a few frames. - Queue API: `queue_action` ×3 → `queue_size()==3`, `get_queue()` order, `insert_action`, `remove_action`, `clear_queue`. - **Nav-mesh bake:** place a `TerrainBlock` (via `TerrainUtils.spawn_block`) + a `NavigationRegion2D`; run the re-bake; assert `region.navigation_polygon.get_polygon_count() > 0`. Then `walk_to(Vector2(200, -300))` (a ground point) → assert `_nav_agent.is_target_reachable()` is true, step physics frames → assert the root approaches `(200, -300 + FOOT_OFFSET.y)`, `arrived` fires, `is_walking()` becomes false. - Runner: build `[walk, wait(0.2), speak("Hi", 0.2), walk]` → `start_queue()` → step → assert `action_started`/`action_finished` order and a single `queue_finished`, and the final root position ≈ the last walk target. - `[ragdoll]` → step until `is_ragdoll_at_rest()`; `[recover]` → step until `state == ANIMATED`; assert `state_changed` emissions. - `stop_queue()` mid-walk → assert `is_queue_running()==false`, no `queue_finished`. - **No-nav-mesh guard:** with no terrain/region, `walk_to(...)` completes via the DIRECT branch (emits `arrived`; the rig walks straight to the target) instead of hanging. 3. **Headless visuals smoke test** (optional): instantiate `SandboxStage` or `StageDirectorVisuals` with a rig that has a queue; assert `_collect_rigs()` and that `_draw()` runs without errors (drawing can't be pixel-asserted headlessly). ### 10.3 Manual checklist (F6 on `res://scenes/sandbox_stage.tscn`) 1. Place a Ground + a Stickman (feet on ground). 2. Select **Direct** → click the stickman → popup appears. 3. **Walk To** → click the stage → a blue waypoint dot + number appears; a dashed line from the stickman. 4. Add **Speak** ("Hello!"), **Wait** (1 s), **Walk To** (second point), **Ragdoll**, **Recover** → dots/numbers show in order; the speak/wait badges appear at the stickman's position (before the first Walk To) or at the waypoint that precedes them (after a Walk To). 5. **Esc** cancels a pending Walk-To target; **Esc** again exits Direct mode. 6. Press **Play** → waypoints hide; the stickman walks → speaks → waits → walks → ragdolls → recovers (stands up). No stage input accepted during Play. 7. Press **Edit** → stickman snaps to standing; waypoints reappear; queues preserved. 8. **Multiple stickmen:** place 2–3, direct each with different queues, Play → all act simultaneously and independently. 9. Regression: placement, selection, rotate ring, grid/snap, box-select still work in Edit; props still unfreeze/fall in Play. 10. **Nav-mesh regen:** place a Ground + a Ramp, direct a Walk To across the ramp, Play → the stickman follows the sloped footprint to the target; move/rotate the ramp in Edit → Play → the path follows the new ramp position (proves re-bake on place/move/rotate/delete). ### 10.4 Walk debugging (post-fix diagnostics) Two debug instrumentation gates are shipped, **off by default** (flip the const to `true`): | Gate | Location | Traces | | ---- | -------- | ------ | | `const DEBUG_WALK := false` | `scripts/stickman_rig.gd` | Per-frame walk trace (`[walk] frame=… mode=nav\|direct finished=… reachable=… map_iter=…`) plus one-shot `walk_to` / `_finish_walk` / `_cancel_walking` / runner prints. | | `const DEBUG_STAGE := false` | `scripts/sandbox_stage.gd` | `[stage] walk_to captured target=…`, `[nav] baked verts=… polys=…`, `[stage] PLAY rigs=…`. | These are diagnostic-only and are never enabled in production. --- ## 11. Open questions for the user **Resolved after user review (2026-08-29):** 1. **Navigation approach** — **RESOLVED (with change).** The user chose **build the nav mesh now** (not direct steering). The spec now uses `NavigationAgent2D` + a code-built `NavigationRegion2D` with a per-block-decomposed `NavigationPolygon` (§2 D1, §4.1, §4.4). 2. **Play-mode semantics** — **RESOLVED.** Play runs the director script (stickmen start ANIMATED; `ragdoll`/`recover` are explicit actions). 3. **`walk_to` target semantics** — **RESOLVED.** `target` = feet/ground destination; the rig applies `FOOT_OFFSET` internally (§2 D2). 4. **Speech bubble rendering** — **RESOLVED.** World-space `Node2D` + `_draw()` bubble. 5. **Non-walk action badges** — **RESOLVED (with change).** The user chose: every action happens where the stickman is at that moment — non-walk actions render as floating badges anchored to the stickman's position at that point in the sequence (§8). 6. **Persistence** — **RESOLVED.** Queues are in memory only (no serialization) in 3a. **Remaining open questions:** none blocking. (Minor implementation tunables are noted inline: triangle winding for the nav mesh, badge stack spacing, `NAV_*_DISTANCE` values.) --- ## 12. Out of scope (3a) - **Slope-aware walking physics** — the stickman follows the nav path across a ramp/stair footprint but stays visually upright (no body tilt to the slope, no physics sliding); deferred (tech-debt #13). - **Dynamic obstacle avoidance** — `avoidance_enabled = false`; stickmen path through props and each other (deferred; tech-debt #13). - Queue serialization / Save-Load of director scripts. - Idle/other animations beyond `walk_left`/`walk_right`. - Editor (`stickman_editor`) integration — sandbox-only.