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:
@@ -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.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:
|
||||
```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 2–3, direct each with different queues, Play → all act
|
||||
simultaneously and independently.
|
||||
9. Regression: placement, selection, rotate ring, grid/snap, box-select still work in Edit;
|
||||
props still unfreeze/fall in Play.
|
||||
10. **Nav-mesh regen:** place a Ground + a Ramp, direct a Walk To across the ramp, Play →
|
||||
the stickman follows the sloped footprint to the target; move/rotate the ramp in Edit →
|
||||
Play → the path follows the new ramp position (proves re-bake on
|
||||
place/move/rotate/delete).
|
||||
|
||||
### 10.4 Walk debugging (post-fix diagnostics)
|
||||
|
||||
Two debug instrumentation gates are shipped, **off by default** (flip the const to `true`):
|
||||
|
||||
| Gate | Location | Traces |
|
||||
| ---- | -------- | ------ |
|
||||
| `const DEBUG_WALK := false` | `scripts/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.
|
||||
Reference in New Issue
Block a user