Files
stickman/docs/phase_3a_spec.md
T

45 KiB
Raw Blame History

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 concavityGeometry2D.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:

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.5FacingProfile.LEFT; dx > 0.5FacingProfile.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/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_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/sandbox_stage.gd (changes)

New preload:

const STAGE_DIRECTOR_VISUALS := preload("res://scripts/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.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:

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_RAGDOLLqueue_action({"type":"ragdoll"}).
  • ACT_RECOVERqueue_action({"type":"recover"}).

Dialog confirmations:

  • _on_speak_confirmedqueue_action({"type":"speak","text":_speak_edit.text,"duration":2.0}).
  • _on_wait_confirmedqueue_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_deletedmark_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.tscnunchanged.


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_actionpush_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 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), 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/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 approachRESOLVED (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 semanticsRESOLVED. Play runs the director script (stickmen start ANIMATED; ragdoll/recover are explicit actions).
  3. walk_to target semanticsRESOLVED. target = feet/ground destination; the rig applies FOOT_OFFSET internally (§2 D2).
  4. Speech bubble renderingRESOLVED. World-space Node2D + _draw() bubble.
  5. Non-walk action badgesRESOLVED (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. PersistenceRESOLVED. 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 avoidanceavoidance_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.