Files

45 KiB
Raw Permalink Blame History

Phase 3a — Core Director Functionality (Implementation Specification)

Status: Draft for review — Architect deliverable (research + spec only; no code written). Source plan: docs/archive/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/editors/stage/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/characters/stickman/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:

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:

enum RunnerState { IDLE, EXECUTING }
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }

New constants:

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:

@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:

_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:

## 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; dx > 0.5 → FacingProfile.RIGHT; vertical-only → keep facing), then play the canonical walk_right clip (root-mirrored via Master.scale.x = -1 for LEFT; walk_left is not used at runtime).
  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_walkstill emitsarrived, so the runner never hangs. The <= ARRIVE_DISTANCEcheck is a root-distance comparison —move_towardcan 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 canonical walk_right animation keys the IK targets in-place; walk_to() sets the facing profile explicitly (LEFT root-mirrors the rig via Master.scale.x = -1 and plays the same walk_right clip mirrored; RIGHT/FORWARD play it unmirrored — walk_left is no longer used at runtime). 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:

## 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:

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:

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):

_track_momentum(delta)
_update_rest_detection(delta)
_update_walking(delta)
_update_speech(delta)
_update_runner(delta)

4.2 scripts/speech/stickman_speech_bubble.gd (NEW)

class_name SpeechBubble extends Node2D — a world-space bubble drawn in _draw().

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/stage_director_visuals.gd (NEW)

class_name StageDirectorVisuals extends Node2D — Edit-mode director overlay.

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/editors/stage/sandbox_stage.gd (changes)

New preload:

const STAGE_DIRECTOR_VISUALS := preload("res://scripts/stage/stage_director_visuals.gd")

New constants (popup item ids):

const ACT_WALK := 0
const ACT_SPEAK := 1
const ACT_WAIT := 2
const ACT_RAGDOLL := 3
const ACT_RECOVER := 4

New state:

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):

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):

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:

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 to the right of the clicked stickman (_world_to_screen(hit.global_position) + 24 px) 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/stage_gizmos.gd

No changes. Director visuals live in StageDirectorVisuals (D7). Gizmos remain the selection/hover/rotate layer.

4.6 scenes/stage/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 to the right of the clicked stickman (its world position converted to screen + 24 px).
  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 docs/known_issues.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://content/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), arrivedfires,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/stage/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/characters/stickman/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/editors/stage/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.