Files
stickman/docs/phase_3a_spec.md
T
ryan bf11a5fab5 Add Phase 3a Core Director Functionality
- Introduced `PHASE_3a_CORE_DIRECTOR.md` detailing the core functionality for directors, including navigation, action queue, UI, waypoint visualization, and action execution.
- Implemented `StageDirectorVisuals` for drawing stickman action queues in edit mode, including waypoints and action badges.
- Created `SpeechBubble` class for displaying speech bubbles above stickmen, with customizable text and styling.
2026-08-30 00:29:30 -04:00

843 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.110 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 23, 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.