Add Phase 3a Core Director Functionality

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