45 KiB
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.mdScope: 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):
- Milestone 1 — Navigation:
walk_to(target),is_walking(),arrived,walk_speed. - Milestone 2 — Queue:
action_queue,queue_action,clear_queue,get_queue,remove_action,insert_action,queue_size. - Milestone 3 — Director UI: "Direct" palette button, stickman click detection, action popup (Walk To / Speak / Wait / Ragdoll / Recover), text + duration dialogs.
- Milestone 4 — Waypoint visuals: dots, dashed lines, action badges, order numbers; visible in Edit, hidden in Play.
- 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 concavesteptemplate), andTerrainBlockalready 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 * pbefore triangulation. - Deterministic + non-deprecated — uses only
NavigationPolygon.set_vertices()/add_polygon()and stableGeometry2Dhelpers. It avoids the deprecatedmake_polygons_from_outlines()and the experimentalNavigationServer2D.bake_from_source_geometry_data()rasterized baker (which needsbaking_rect/cell_sizetuning and source-geometry setup). - Re-bake is trivial — build a fresh
NavigationPolygonfrom the current block set and reassignregion.navigation_polygon(reassignment re-syncs the region with theNavigationServer2D).
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/recoverbecome 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'sSTAND_POSE(head aty=-614, feet aty≈+380..390), andStageSpawner.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:
Camera2Dat(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:
- Guard
state == RigState.ANIMATED. _walk_target_feet = target._nav_agent.target_position = target(global ground point — see D1).- Set facing from horizontal delta (
dx < -0.5→FacingProfile.LEFT;dx > 0.5→FacingProfile.RIGHT; vertical-only → keep facing), then play the canonicalwalk_rightclip (root-mirrored viaMaster.scale.x = -1for LEFT;walk_leftis not used at runtime). _anim_player.play(name)(walk anims are authoredLOOP_LINEAR, so they loop)._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, noarrived). - 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 onemove_towardstep (~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_positionresets it via_request_repath). The read-onlyget_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"). Elseroot_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. Nopush_warning: an off-mesh waypoint is now a normal, supported case; log it only via_walk_dbg(...).
- NAV branch (target on the mesh): set
- Move:
global_position = global_position.move_toward(root_target, _walk_speed_current * delta)(both branches converge on their ownroot_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.
- FOOT_OFFSET) <= ARRIVE_DISTANCE
Off-mesh waypoint behavior (changed 2026-08-29 — this supersedes the earlier "finish in place" policy): a
walk_totarget thatis_target_reachable()reports false (off the nav mesh, or no mesh at all) is now walked to directly in a straight line atwalk_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_gracecounter is therefore removed (the map-sync guard + forced path query make it unnecessary)._finish_walk()keeps its internalreason: Stringparam (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_rightanimation keys the IK targets in-place;walk_to()sets the facing profile explicitly (LEFTroot-mirrors the rig viaMaster.scale.x = -1and plays the samewalk_rightclip mirrored;RIGHT/FORWARDplay it unmirrored —walk_leftis no longer used at runtime). Root translation composes with the in-place limb animation. Movement is kinematic (global_position.move_towardin_physics_process; the rig is a plainNode2D, noCharacterBody2D), so noNavigationAgent2D.velocity/velocity_computedRVO 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, computeat_restas today; whenat_restfirst becomes true set_ragdoll_at_rest = true(and optionally emitragdoll_rested, but the runner polls). The existingauto_recovergate and timer/stabilize logic are unchanged. - New public query:
func is_ragdoll_at_rest() -> bool(returns_ragdoll_at_rest; only meaningful inRAGDOLL).
_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 radiusWAYPOINT_RADIUS_PX / _zoom(); order numberi+1drawn 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_toupdate 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 (matchesStageGizmos).
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
TerrainBlockchildren ofWorldare baked. The placement ghost is reparented into_ghost_holder(notWorld), so it is never part of the nav mesh. NavigationPolygonand the region both use the default navigation map + cell size (leavecell_sizeat its default; do not override the map's cell size in 3a). If the headless pathing test finds no paths, checkNavigationServer2D.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)(existingStageGizmos.transform_committedhandler): if any node innodesis aTerrainBlock→_nav_dirty = true(covers move and rotate, since the rotate ring also emitstransform_committedon drag end).delete_selected(): if any deleted node is aTerrainBlock→_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_buttontoggleButton, text"Direct",toggled → _on_direct_toggled._action_popup = PopupMenu.new()added to theUICanvasLayer (not the hbox) with itemsWalk 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_targetand_context_rigvalid → append{"type":"walk_to","target":world_pos}to_context_rig; clear pending + context. - Else
var hit := _selection.hit_test(world_pos); ifhit is STICKMAN_RIG→_context_rig = hit, position_action_popupto the right of the clicked stickman (_world_to_screen(hit.global_position)+ 24 px) andpopup(). 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:
- cancel
_pending_walk_target+ clear_context_rig; - else if
_direct_mode→ exit direct mode (_direct_button.set_pressed_no_signal(false)); - else existing placement/selection clears.
Mode changes:
_enter_edit_mode(): for each stickmanstop_queue()thensnap_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 stickmanrig.auto_recover = false; rig.start_queue()(replacing the old auto-set_ragdoll(true)).
Signal wiring for visuals:
_on_selection_changedunchanged. New: inobject_placedhandling (or_place_at), ifnode is STICKMAN_RIGconnectnode.queue_changed → _director_visuals.mark_dirtyand_director_visuals.mark_dirty().object_deleted→mark_dirty().set_mode→_director_visuals.set_enabled(...).- Add
_direct_button.visibleto_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 alreadyEXECUTING; empty queue → emitqueue_finished, return._update_runner(delta)(in_physics_process) advances perActionPhase:NONE→_advance_to_next_action()(emitaction_started,_begin_action).WALKING→ complete when_walk_done.SPEAKING→ complete whennot _speech_active.WAITING→_phase_timer -= delta; complete at<= 0.RAGDOLLING→ complete whenis_ragdoll_at_rest().RECOVERING→ complete whenstate == RigState.ANIMATED.
stop_queue():_stop_requested = true,_cancel_walking()+ hide speech,_runner_state = IDLE,_action_phase = NONE,_current_index = -1. Does not emitqueue_finished(the queue was aborted, not completed). Does not force a ragdoll out ofRAGDOLL(EDIT re-entry handles that viasnap_to_standing).
Ragdoll / recover integration with the existing state machine (Q3):
- The runner only starts walking/speaking when the rig is
ANIMATED;walk_toself-guards. ragdollaction →set_ragdoll(true)(existing instant handoff) → wait foris_ragdoll_at_rest()(new, auto-recover independent).recoveraction →request_recovery()(no-op if not ragdolled) → wait forstate == ANIMATED(existingstate_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)
- Press "Direct" (toggle). Palette spawn modes are cleared (mutually exclusive).
- Click a stickman →
StageSelection.hit_test→ if stickman, open_action_popupto the right of the clicked stickman (its world position converted to screen + 24 px). - Choose:
- Walk To → enters pending mode; status hint ("Click stage for walk target — Esc to cancel").
- Speak → text dialog → append
speakaction. - Wait → duration dialog → append
waitaction. - Ragdoll / Recover → append immediately.
- Pending Walk To: next left click on the stage appends
{"type":"walk_to","target":click_pos}; Esc (and optionally right-click) cancels. - Waypoints/badges update immediately via
queue_changed → mark_dirty. - Press Play → waypoints hide, all stickmen run their queues; input frozen (existing
current_mode != EDITguards + 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 nowalk_toprecedes it.
Concretely, StageDirectorVisuals computes an anchor point by simulating the queue:
current_pos := rig's feet position(the rig root +FOOT_OFFSET).- Walk the queue in order; for each action:
- if
walk_to: draw the waypoint dot ataction["target"], extend the dashed line fromcurrent_postotarget, thencurrent_pos := target. - else: draw the badge at
current_pos(the stickman's position at that moment), leavingcurrent_posunchanged.
- if
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, radiusWAYPOINT_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, viaThemeDB.fallback_fontdraw_string. - Visibility:
set_enabled(true)in Edit;falsein 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 legacycurved_lines_2daddon — it is not present on disk). Nores://test/directory. - No CLI test command.
project.godothas no test autoload;run/main_sceneis the editor. - Established pattern: headless scripted smoke tests — previous phases (per
docs/phase9_*specs andBUGS.md) verified work with temporary headlessSceneTreescripts run against the console build, e.g.:(the..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit--headless --check-only --quitvariant; plain--check-onlyhangs 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)
- Parse check (all changed scripts):
..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit - Headless queue/runner smoke test (temporary
SceneTreescript, 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(viaTerrainUtils.spawn_block) + aNavigationRegion2D; run the re-bake; assert `region.navigation_polygon.get_polygon_count()0
. Thenwalk_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 → assertaction_started/action_finishedorder and a singlequeue_finished, and the final root position ≈ the last walk target. [ragdoll]→ step untilis_ragdoll_at_rest();[recover]→ step untilstate == ANIMATED; assertstate_changedemissions.stop_queue()mid-walk → assertis_queue_running()==false, noqueue_finished.- No-nav-mesh guard: with no terrain/region,
walk_to(...)completes via the DIRECT branch (emitsarrived; the rig walks straight to the target) instead of hanging.
- Headless visuals smoke test (optional): instantiate
SandboxStageorStageDirectorVisualswith 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)
- Place a Ground + a Stickman (feet on ground).
- Select Direct → click the stickman → popup appears.
- Walk To → click the stage → a blue waypoint dot + number appears; a dashed line from the stickman.
- 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).
- Esc cancels a pending Walk-To target; Esc again exits Direct mode.
- Press Play → waypoints hide; the stickman walks → speaks → waits → walks → ragdolls → recovers (stands up). No stage input accepted during Play.
- Press Edit → stickman snaps to standing; waypoints reappear; queues preserved.
- Multiple stickmen: place 2–3, direct each with different queues, Play → all act simultaneously and independently.
- Regression: placement, selection, rotate ring, grid/snap, box-select still work in Edit; props still unfreeze/fall in Play.
- 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):
- Navigation approach — RESOLVED (with change). The user chose build the nav mesh
now (not direct steering). The spec now uses
NavigationAgent2D+ a code-builtNavigationRegion2Dwith a per-block-decomposedNavigationPolygon(§2 D1, §4.1, §4.4). - Play-mode semantics — RESOLVED. Play runs the director script (stickmen start
ANIMATED;
ragdoll/recoverare explicit actions). walk_totarget semantics — RESOLVED.target= feet/ground destination; the rig appliesFOOT_OFFSETinternally (§2 D2).- Speech bubble rendering — RESOLVED. World-space
Node2D+_draw()bubble. - 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).
- 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.