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
+75
View File
@@ -300,6 +300,43 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
`set_ragdoll(false)` during `RAGDOLL` routes through `_start_recovery()`; repeated `set_ragdoll(false)` during `RAGDOLL` routes through `_start_recovery()`; repeated
`set_ragdoll` calls are idempotent. `is_in_ragdoll()` stays `set_ragdoll` calls are idempotent. `is_in_ragdoll()` stays
`state == RigState.RAGDOLL` (so `RECOVERING` reads as "Stickman"). `state == RigState.RAGDOLL` (so `RECOVERING` reads as "Stickman").
- **Phase 3a director functionality:** adds navigation/walking, speech, an action queue, and a
queue-runner state machine. Enums `RunnerState { IDLE, EXECUTING }` and `ActionPhase { NONE,
WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }`. Constants `FOOT_OFFSET := (0, -385)`
(feet → root; matches `StageSpawner.STICKMAN_FOOT_OFFSET`), `NAV_AGENT_LOCAL_POS := (0, 385)`
(`== -FOOT_OFFSET`), `ARRIVE_DISTANCE` / `NAV_PATH_DESIRED_DISTANCE` / `NAV_TARGET_DESIRED_DISTANCE`
/ `SPEECH_BUBBLE_OFFSET`. New signals `arrived`, `action_started(action, index)`,
`action_finished(action, index)`, `queue_finished` (on completion, not stop), `queue_changed`
(any queue mutation), `speech_finished`. Exports `walk_speed` (300.0). Public API: `walk_to(
target, speed = -1.0)` (feet/ground destination; no-op unless `state == ANIMATED`), `is_walking()`,
`speak(text, duration)` (lazily creates a `SpeechBubble` child at `SPEECH_BUBBLE_OFFSET`, auto-hides
+ emits `speech_finished`), the queue API `queue_action` / `clear_queue` / `get_queue` /
`remove_action` / `insert_action` / `queue_size` (all mutations emit `queue_changed`), and the
runner API `start_queue` / `stop_queue` / `is_queue_running`. `_ready()` builds a
`NavigationAgent2D` child at `NAV_AGENT_LOCAL_POS` (feet, on the ground-level nav mesh;
`avoidance_enabled = false`, `max_speed = walk_speed`, shared default nav map layer 1). A
`_physics_process` ordering of `_track_momentum` → `_update_rest_detection` → `_update_walking`
→ `_update_speech` → `_update_runner` drives the runner state machine per `ActionPhase`
(`walk_to` → wait for `_walk_done`; `speak` → wait for `!_speech_active`; `wait` → `_phase_timer`
countdown; `ragdoll` → `set_ragdoll(true)` then wait `is_ragdoll_at_rest()`; `recover` →
`request_recovery()` then wait `state == ANIMATED`). `is_ragdoll_at_rest()` is a new public
query exposing the rest result **regardless of `auto_recover`** (the runner polls it; auto-recovery
logic is unchanged). `_enter_ragdoll()` additionally calls `_cancel_walking()` (no stale walk/path
state) and resets `_ragdoll_at_rest = false`. **Walk fix (2026-08-29, hybrid policy):**
`_update_walking` defers all nav reads until `NavigationServer2D.map_get_iteration_id(...) != 0`
(map-sync guard), forces the path query via `get_next_path_position()` **before** any empty-path /
finished check (the read-only `get_current_navigation_path()` alone never triggers a
repath), then branches on `is_target_reachable()`: an **on-mesh target** follows the nav path
(`_walk_mode = "nav"`), while an **off-mesh/unreachable target** switches to **direct
straight-line steering** toward the clicked waypoint (`_walk_mode = "direct"`, root target =
waypoint + `FOOT_OFFSET`) — a supported case with **no** `push_warning` (the old "warn + finish
in place" policy was replaced; the rig never stands still at a waypoint). There is **no**
`_walk_path_grace` variable (the map-sync guard + forced path query replace it) and **no
unreachable warning**; the debug trace now carries a `mode=nav|direct` field. Off-by-default
diagnostics: `DEBUG_WALK` + `_walk_dbg()` (rig) and `DEBUG_STAGE` + `_stage_dbg()` (sandbox_stage).
Walking is kinematic
(`global_position.move_toward`); movement composes with the `walk_left`/`walk_right` in-place limb
animations (each has a discrete `.:facing_profile` track).
- `scripts/stickman_factory.gd` — `class_name StickmanFactory`, `extends RefCounted`; a **static - `scripts/stickman_factory.gd` — `class_name StickmanFactory`, `extends RefCounted`; a **static
factory** and the **runtime entry point** (Phase 9, **not used by the editor**) that turns a factory** and the **runtime entry point** (Phase 9, **not used by the editor**) that turns a
`.stk` file into a live, rigged `master_rig.tscn` instance: `.stk` file into a live, rigged `master_rig.tscn` instance:
@@ -562,6 +599,44 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
Grid/Snap/Size controls, status label. The build controls (spawn palette + Grid/Snap/Size) are Grid/Snap/Size controls, status label. The build controls (spawn palette + Grid/Snap/Size) are
hidden in PLAY via `_set_build_controls_visible()`, called from `_enter_edit_mode()` / hidden in PLAY via `_set_build_controls_visible()`, called from `_enter_edit_mode()` /
`_enter_play_mode()` (the mode toggle and status label stay visible). `_enter_play_mode()` (the mode toggle and status label stay visible).
- **Phase 3a director tool:** adds a **"Direct"** palette toggle button (mutually exclusive with
placement) and a `PopupMenu` (`_action_popup`, items `Walk To`/`Speak`/`Wait`/`Ragdoll`/`Recover`,
ids `ACT_WALK`…`ACT_RECOVER`) opened by clicking a stickman (`_handle_direct_click`); speak/wait
`AcceptDialog`s append `speak`/`wait` actions; `Walk To` enters a pending target-capture mode whose
next stage click appends `{"type":"walk_to","target":world_pos}` and which **Esc** cancels (Esc
priority: pending target → exit direct mode → existing placement clears). Builds a code-built
`NavigationRegion2D` (`_build_navigation()`, child of the stage **not** `World` so it is never
hit-tested) carrying a procedural `NavigationPolygon` from per-`TerrainBlock` convex decomposition
(`_rebake_navigation()`, `Geometry2D.decompose_polygon_in_convex` + fan triangulation, world-space
via `block.transform * p`); a `_nav_dirty` flag re-bakes once per frame (`_process`) on terrain
place / move / rotate (`transform_committed`) / delete. Instantiates a `StageDirectorVisuals`
overlay (`_build_director_visuals()`). **Play mode change (D3):** `_enter_play_mode()` no longer
auto-ragdolls stickmen — it now sets each rig `auto_recover = false` and calls `start_queue()`;
`ragdoll`/`recover` are explicit queue actions; props still unfreeze. `_enter_edit_mode()`
`stop_queue()`s then `snap_to_standing()`s stickmen and re-enables the visuals. Wires
`node.queue_changed → _director_visuals.mark_dirty` when a stickman is placed; `object_deleted` →
`mark_dirty()`. `_set_build_controls_visible()` also hides the Direct button in PLAY.
- `scripts/stickman_speech_bubble.gd` — `class_name SpeechBubble`, `extends Node2D`; a **world-space
speech bubble** drawn in `_draw()` (Phase 3a, **not used by the editor**). Child of a `StickmanRig`
at `SPEECH_BUBBLE_OFFSET` (above the head), so it follows the figure and scales with the camera.
Consts `FONT_SIZE`/`PADDING`/`TAIL_HEIGHT`/`MAX_WIDTH`/`BG_COLOR`/`BORDER_COLOR`/`TEXT_COLOR`.
Measures text via `ThemeDB.fallback_font.get_string_size(...)`, draws a rounded-rect background +
a downward tail triangle centered on the rig-local origin, then `draw_string(...)`; `visible = false`
by default, no hit-testing. Public API: `show_text(text)` (store, `visible = true`, `queue_redraw()`),
`hide_bubble()`. Driven by `StickmanRig.speak()`; the rig owns hiding + the `speech_finished` signal.
- `scripts/stage_director_visuals.gd` — `class_name StageDirectorVisuals`, `extends Node2D`; the
**Edit-mode director overlay** (Phase 3a, **not used by the editor**), mirroring `StageGrid`/
`StageGizmos`. State `camera`/`world`/`enabled`/`_dirty`; public `set_enabled(value)` /
`mark_dirty()`. `_process()` redraws on a dirty flag; `_draw()` reads each rig's queue via
`_collect_rigs()` (direct `World` children filtered `is StickmanRig`). Per action in order:
`walk_to` → a blue waypoint dot (`WAYPOINT_RADIUS_PX / _zoom()`) with white outline + order number at
`action["target"]`; dashed connectors (`draw_dashed_line`, `DASH_*` / `_zoom()`) between consecutive
dots (and from the rig's current feet position to the first dot); non-walk actions (`speak`/`wait`/
`ragdoll`/`recover`) → a badge (speech bubble / clock / X / up-arrow glyph + order number) anchored
at the **stickman's position at that point in the sequence** — derived by simulating the queue
(start at rig feet + `FOOT_OFFSET`; each `walk_to` advances the anchor; non-walk anchors at the
current position), consecutive badges stacking `(0, -28)/zoom`. All sizes divided by `_zoom()` so
markers stay screen-constant; pure `_draw()`, no hit-testing; hidden in PLAY via `set_enabled(false)`.
- `scripts/stage_spawner.gd` — `class_name StageSpawner`, `extends RefCounted`; registry-driven - `scripts/stage_spawner.gd` — `class_name StageSpawner`, `extends RefCounted`; registry-driven
spawner (Phase 2). A `_registry: Array[Dictionary]` maps ids to terrain/prop/stickman templates; spawner (Phase 2). A `_registry: Array[Dictionary]` maps ids to terrain/prop/stickman templates;
adding a type = appending an entry (no hard-coded id `match`). Reuses `TerrainUtils.spawn_block`, adding a type = appending an entry (no hard-coded id `match`). Reuses `TerrainUtils.spawn_block`,
+9
View File
@@ -114,3 +114,12 @@ When applying .stk v1.4 data to master*rig.tscn, shapes are severely distorted:G
## Stickman editor (Phase 9 Round 7) ## Stickman editor (Phase 9 Round 7)
> ADDED — 2026 Round 7: implemented per docs/phase9_round7_feature_spec.md; the test harness now exposes **six** draggable IK handles. `IK_HANDLE_PATHS` gains `"Head"` (`IK_Targets/Head`, the `SkeletonModification2DLookAt` aim point) and `"Torso"` (`IK_Targets/Torso`, whose child `RemoteTransform2D` moves the hip bone). Dragging the **Torso** handle moves **bones only** — the marker's `RemoteTransform2D` translates the hip bone and the whole skeleton + `Body/*` visuals follow rigidly, while the limb/head targets stay put (dragging the figure away from them stretches the limbs toward the stationary targets, per user decision). Dragging the **Head** handle drives the Head bone's LookAt rotation (clamped at the authored ~55° constraint); `Body/Head` follows. `_handle_color()` colors the head marker yellow (`HANDLE_COLOR_HEAD`) and the torso marker magenta (`HANDLE_COLOR_TORSO`); hands stay green, feet blue. The IK overlay additionally draws a null-guarded semi-transparent yellow aim line from the Head bone origin to the head marker (visual aid for the LookAt test). Verified with a 17-assertion headless test (Torso moved by (60, 40) → `Skeleton2D/Torso` and `Body/*` translate by exactly (60, 40); Head marker moved → Head bone + `Body/Head` rotate). > ADDED — 2026 Round 7: implemented per docs/phase9_round7_feature_spec.md; the test harness now exposes **six** draggable IK handles. `IK_HANDLE_PATHS` gains `"Head"` (`IK_Targets/Head`, the `SkeletonModification2DLookAt` aim point) and `"Torso"` (`IK_Targets/Torso`, whose child `RemoteTransform2D` moves the hip bone). Dragging the **Torso** handle moves **bones only** — the marker's `RemoteTransform2D` translates the hip bone and the whole skeleton + `Body/*` visuals follow rigidly, while the limb/head targets stay put (dragging the figure away from them stretches the limbs toward the stationary targets, per user decision). Dragging the **Head** handle drives the Head bone's LookAt rotation (clamped at the authored ~55° constraint); `Body/Head` follows. `_handle_color()` colors the head marker yellow (`HANDLE_COLOR_HEAD`) and the torso marker magenta (`HANDLE_COLOR_TORSO`); hands stay green, feet blue. The IK overlay additionally draws a null-guarded semi-transparent yellow aim line from the Head bone origin to the head marker (visual aid for the LookAt test). Verified with a 17-assertion headless test (Torso moved by (60, 40) → `Skeleton2D/Torso` and `Body/*` translate by exactly (60, 40); Head marker moved → Head bone + `Body/Head` rotate).
## Sandbox Stage — Director Tool (Phase 3a)
> FIXED — 2026-08-29: `walk_to` no longer stops after a few pixels. `_update_walking` now defers all nav reads until `NavigationServer2D.map_get_iteration_id(...) != 0` (map-sync guard), forces the path query via `get_next_path_position()` before any empty-path/finished check, and consumes the empty-path grace (`_walk_path_grace = 2`) only after map sync. Off-by-default diagnostics added: `DEBUG_WALK` + `_walk_dbg()` in `stickman_rig.gd`, `DEBUG_STAGE` + `_stage_dbg()` in `sandbox_stage.gd`. Verified with a 44-assertion headless regression suite. **Follow-up (2026-08-29):** 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** — on-mesh targets follow the nav path (`_walk_mode = "nav"`), off-mesh/unreachable targets switch to straight-line direct steering toward the clicked waypoint (`_walk_mode = "direct"`, root target = waypoint + `FOOT_OFFSET`). No `push_warning` on off-mesh (a supported case, logged only via `_walk_dbg`); `_walk_path_grace` removed (map-sync guard + forced path query replace it); the debug trace now includes `mode=nav|direct`.
1. **`walk_to` stops after a few pixels.** A stickman directed to walk stops ~5 px into the move and then either sits still or is treated as finished, instead of walking to the target.
- **Symptom:** in Play, the stickman advances one `move_toward` step then stops; the walk never reaches its target.
- **Root cause:** `_update_walking` checked `is_navigation_finished()` before the map had synchronized. An unsynced `NavigationAgent2D` (map iteration id `0`) reports an empty, already-finished path, so the walk was ended after a single step. Additionally, `get_current_navigation_path()` alone never triggers a path computation — only `get_next_path_position()` forces the agent's internal `_update_navigation()` to re-query the map for a fresh target, so the empty-path guard would misjudge an off-mesh target as "unreachable" immediately.
- **Fix:** (1) defer all nav reads until `map_get_iteration_id(...) != 0`; (2) call `get_next_path_position()` **before** the empty-path / finished checks so the path is actually computed; (3) make walking **hybrid** — an on-mesh target follows the nav path (`_walk_mode = "nav"`), while an off-mesh/unreachable target (path empty **or** `is_target_reachable()` false) switches to **direct straight-line steering** toward the clicked waypoint (`_walk_mode = "direct"`, root target = waypoint + `FOOT_OFFSET`), so the stickman always reaches the waypoint the user clicked; (4) **no** `push_warning` for an off-mesh waypoint (a supported case — logged only via `_walk_dbg`); the original "warn + finish in place" policy was itself reported as "stickman stands still with a waypoint" and was superseded by this DIRECT branch; (5) `_finish_walk(reason: String)` internal param for the debug trace. The `_walk_path_grace` counter is **removed** — the map-sync guard + forced path query replace it.
+76 -1
View File
@@ -444,7 +444,82 @@ Clicking a palette button enters **placement mode**, which shows a translucent *
**Status bar:** shows `Mode: EDIT/PLAY | Objects: N | Selected: <name or count>` and updates live on spawn, selection, deletion, and mode changes. **Status bar:** shows `Mode: EDIT/PLAY | Objects: N | Selected: <name or count>` and updates live on spawn, selection, deletion, and mode changes.
> **Extendability contract:** the spawner uses a `Dictionary` registry (no hard-coded `match` on ids), the `World` container accepts any `Node2D`, gizmos work on any object via `global_position`/`global_rotation`, and the root exposes `mode_changed` / `object_placed` / `object_selected` / `object_deselected` / `object_deleted` signals — all hooks for the future Action Queue, Trigger, and Save/Load phases. Ramps/stairs can be placed and props/ragdolls will slide on them, but stickmen do **not** autonomously walk up/down them yet (planned for a later phase with `NavigationAgent2D` + IK). > **Extendability contract:** the spawner uses a `Dictionary` registry (no hard-coded `match` on ids), the `World` container accepts any `Node2D`, gizmos work on any object via `global_position`/`global_rotation`, and the root exposes `mode_changed` / `object_placed` / `object_selected` / `object_deselected` / `object_deleted` signals — all hooks for the future Action Queue, Trigger, and Save/Load phases. Ramps/stairs can be placed and props/ragdolls will slide on them, and (Phase 3a) stickmen now walk up/down them via `NavigationAgent2D` — see §19.
### 19. Director Tool (Phase 3a)
The **Director Tool** (Phase 3a) turns the Sandbox Stage into a mini director's workspace: 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 action queue. It is **not wired into the editor** — run via **F6** on `res://scenes/sandbox_stage.tscn`.
| File | Purpose |
|---|---|
| `res://scripts/stickman_rig.gd` | Extended with navigation/walking, speech, an action queue, and the queue runner state machine (see API below). |
| `res://scripts/stickman_speech_bubble.gd` | `class_name SpeechBubble`, `extends Node2D` — a world-space speech bubble drawn via `_draw()` (`ThemeDB.fallback_font`), a child of the rig above the head. |
| `res://scripts/stage_director_visuals.gd` | `class_name StageDirectorVisuals`, `extends Node2D` — Edit-mode director overlay (waypoint dots, dashed connectors, action badges, order numbers); hidden in Play. |
| `res://scripts/sandbox_stage.gd` | Extended with the **Direct** palette button, the action popup + speak/wait dialogs, a code-built `NavigationRegion2D` re-baked on terrain edits, and Play mode now starting each stickman's queue. |
**Direct tool workflow (Edit):**
1. Press the **Direct** toggle button (mutually exclusive with palette placement). A status hint prompts "Click a stickman".
2. **Left-click a stickman** → an action popup opens at the cursor with **Walk To / Speak / Wait / Ragdoll / Recover**.
3. Choose an action:
- **Walk To** → enters pending mode; the **next left-click on the stage** appends `{"type":"walk_to","target":click_pos}`. **Esc** cancels the pending target.
- **Speak** → a text dialog (`AcceptDialog` + `LineEdit`); confirms append `{"type":"speak","text":...,"duration":2.0}`.
- **Wait** → a duration dialog (`SpinBox`, 0.110 s); confirms append `{"type":"wait","duration":...}`.
- **Ragdoll / Recover** → append `{"type":"ragdoll"}` / `{"type":"recover"}` immediately.
4. Waypoints/badges update immediately (`queue_changed` → dirty flag). **Esc** exits Direct mode.
**Action queue semantics:**
- Each `StickmanRig` owns its own `action_queue: Array[Dictionary]`; index order = execution order = visual order (no separate ids). Unknown `type``push_warning` + the action is skipped (treated as completed).
- Action shapes (the `type` key 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"` | — | — |
- Queues are **in-memory only** in 3a (no serialization; lost on scene reload).
**Waypoint & badge visuals (Edit only):** `StageDirectorVisuals` reads each rig's queue and, per action in order, draws a **blue waypoint dot** (white outline, order number) at each `walk_to` target, **dashed connectors** between consecutive dots (and from the rig's current feet position to the first dot), and **badges** (speech bubble / clock / X / up-arrow glyph + order number) for non-walk actions anchored to the **stickman's position at that point in the sequence** — the position derived by simulating the queue (start at the rig's feet; each `walk_to` advances the anchor; a non-walk action anchors at the position when it is reached). Consecutive badges at the same point stack upward. All sizes divide by the camera zoom so markers stay screen-constant. Pure `_draw()` — no hit-testing. Hidden in Play.
**Play execution:**
- **Play mode now runs the director script** — stickmen stay **ANIMATED** and each rig's `start_queue()` is called (previously they auto-ragdolled on Play). `ragdoll` / `recover` are now **explicit queue actions**; a stickman only falls when directed. Props still unfreeze and tumble (and can knock a *directed* ragdoll). `auto_recover = false` in Play (the director owns recovery).
- On **return to Edit**: each stickman `stop_queue()` then `snap_to_standing()`, and the waypoint overlay is re-enabled.
- Multiple stickmen act simultaneously and independently (per-rig queues + per-rig runners, no shared state).
**Nav-mesh behavior:**
- A code-built `NavigationRegion2D` (child of the stage, not `World`, so it is never hit-tested) carries a procedural `NavigationPolygon` generated by per-`TerrainBlock` convex decomposition (`Geometry2D.decompose_polygon_in_convex` + fan triangulation) of each block's world-space polygon — robust to concavity and rotation.
- **Auto re-bake** on any terrain edit: the nav mesh is marked dirty on terrain place / move / rotate (`transform_committed`) / delete and re-baked once per frame (coalescing bursts) in `_process`.
- Each stickman's `NavigationAgent2D` is a child of the rig **at the feet** (local `(0, +385)` = `-FOOT_OFFSET`), so it sits on the ground-level mesh; agent and region share the default navigation map, layer 1.
- `walk_to(target)` treats `target` as a **feet/ground destination**; the rig converts ground-level path points back to root positions with `FOOT_OFFSET := (0, -385)`. Walking is **hybrid**: an **on-mesh target** follows the nav path (`_walk_mode = "nav"`), while an **off-mesh / unreachable target** (empty path **or** `is_target_reachable() == false`) switches to **direct straight-line steering** toward the clicked waypoint (`_walk_mode = "direct"`, root target = waypoint + `FOOT_OFFSET`) — it is **not** rejected with a warning and the rig does **not** stand still. Nav reads are deferred until the map has synced (`map_get_iteration_id(...) != 0`) and the path query is forced via `get_next_path_position()` before any reachability/finished check, so a fresh target is never misjudged as finished/unreachable after a single step.
- Pathing is kinematic (`global_position.move_toward`); `avoidance_enabled = false` in 3a, so stickmen path through props and each other (deferred — see `docs/tech_debt_and_optimizations.md` #13).
- **Walk/nav debugging (off by default):** `const DEBUG_WALK` in `stickman_rig.gd` (per-frame walk trace with `mode=nav|direct`, plus one-shot `walk_to`/finish/cancel prints) and `const DEBUG_STAGE` in `sandbox_stage.gd` (`[stage]` target capture, `[nav]` baked verts/polys, `[stage] PLAY rigs`).
**StickmanRig director API surface:**
| Member | Signature | Behavior |
|---|---|---|
| `RunnerState` / `ActionPhase` | `enum { IDLE, EXECUTING }` / `enum { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }` | The queue-runner state machine advanced in `_physics_process`. |
| `FOOT_OFFSET` | `const := Vector2(0.0, -385.0)` | Feet → root (ground point → hips). |
| `walk_speed` | `@export var walk_speed: float = 300.0` | Kinematic walk speed. |
| `walk_to` | `func walk_to(target: Vector2, speed: float = -1.0) -> void` | Start walking so the feet land at `target`; no-op unless `state == ANIMATED`. |
| `is_walking` | `func is_walking() -> bool` | Whether a walk is in progress. |
| `speak` | `func speak(text: String, duration: float) -> void` | Show a `SpeechBubble` for `duration` s; auto-hides + emits `speech_finished`. |
| `queue_action` / `clear_queue` / `get_queue` / `remove_action` / `insert_action` / `queue_size` | — | The mutable action queue; all mutations emit `queue_changed`. |
| `start_queue` / `stop_queue` / `is_queue_running` | — | Runner control. `stop_queue()` aborts without emitting `queue_finished`. |
| `is_ragdoll_at_rest` | `func is_ragdoll_at_rest() -> bool` | Whether the ragdoll has rested (independent of `auto_recover`); the runner waits on it for the `ragdoll` action. |
| `arrived` | `signal arrived` | `walk_to` reached its destination. |
| `action_started` / `action_finished` | `signal(action: Dictionary, index: int)` | Emitted per action as the runner begins/completes it. |
| `queue_finished` | `signal queue_finished` | The queue ran to completion (not on stop). |
| `queue_changed` | `signal queue_changed` | Any queue mutation — drives the visuals dirty flag. |
| `speech_finished` | `signal speech_finished` | The speech bubble auto-hid after `speak()`. |
`recover` waits for `state == RigState.ANIMATED` via the existing `state_changed` signal; `_enter_ragdoll()` calls `_cancel_walking()` so a ragdolled rig has no stale walk/path state.
## File format (`.stk`) ## File format (`.stk`)
+4 -3
View File
@@ -38,6 +38,7 @@ A kid-friendly (ages 1015) interactive physics sandbox where users build envi
| **Phase 1** | Physics & Ragdolls | Terrain shapes, prop physics, and Kinematic-to-Ragdoll swap | `RigidBody2D` + `PinJoint2D` | | **Phase 1** | Physics & Ragdolls | Terrain shapes, prop physics, and Kinematic-to-Ragdoll swap | `RigidBody2D` + `PinJoint2D` |
| **Phase 2** | Play / Edit Engine | World building harness, object spawner, selection & transform gizmos | State Machine & Canvas Layer | | **Phase 2** | Play / Edit Engine | World building harness, object spawner, selection & transform gizmos | State Machine & Canvas Layer |
| **Phase 3** | Character Actions | Waypoint pathfinding, speech bubbles, animation/action queuing | `NavigationAgent2D` + Action Runner | | **Phase 3** | Character Actions | Waypoint pathfinding, speech bubbles, animation/action queuing | `NavigationAgent2D` + Action Runner |
| **Phase 3a** (✅) | Core Director Functionality | Direct tool + action popup, per-stickman action queues, waypoint/badge visuals, nav mesh + runner | `NavigationAgent2D` + `NavigationRegion2D` + Action Runner |
| **Phase 4** | Visual Logic | Sensor zones, trigger-action mapping, interactive props | Event Bus & Logic Nodes | | **Phase 4** | Visual Logic | Sensor zones, trigger-action mapping, interactive props | Event Bus & Logic Nodes |
| **Phase 5** | UX Polish & Saving | Radial context menus, sidebar palette, JSON scene serialization | UI Theme & JSON Parser | | **Phase 5** | UX Polish & Saving | Radial context menus, sidebar palette, JSON scene serialization | UI Theme & JSON Parser |
@@ -56,9 +57,9 @@ A kid-friendly (ages 1015) interactive physics sandbox where users build envi
3. Add hover, selection outline, and 2D transform gizmos (translate, rotate, delete) active during `EDIT` mode. 3. Add hover, selection outline, and 2D transform gizmos (translate, rotate, delete) active during `EDIT` mode.
### Phase 3: Character Command System ### Phase 3: Character Command System
1. Integrate `NavigationAgent2D` on stickmen for walking across user-built terrain. 1. Integrate `NavigationAgent2D` on stickmen for walking across user-built terrain. *(✅ Phase 3a: code-built `NavigationRegion2D` re-baked on terrain edits; `walk_to` paths across placed blocks.)*
2. Build a directive queue manager (`WalkTo`, `PlayAnim`, `SayText`, `Wait`). 2. Build a directive queue manager (`WalkTo`, `PlayAnim`, `SayText`, `Wait`). *(✅ Phase 3a: `walk_to` / `speak` / `wait` / `ragdoll` / `recover` actions + per-rig action queue + queue runner.)*
3. Add floating UI speech bubbles tied to the `Head` bone transform. 3. Add floating UI speech bubbles tied to the `Head` bone transform. *(✅ Phase 3a: `SpeechBubble`, a world-space `_draw()` bubble child of the rig above the head.)*
### Phase 4: Logic & Triggers ### Phase 4: Logic & Triggers
1. Create `TriggerArea2D` nodes with visual boundaries visible in Edit Mode. 1. Create `TriggerArea2D` nodes with visual boundaries visible in Edit Mode.
+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. | | 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. | | 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. | | 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) | | 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) | | 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) | | 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) | | 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) | | 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) | | 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-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 | 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-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. |
--- ---
+177
View File
@@ -0,0 +1,177 @@
# Phase 3a: Core Director Functionality
## 1. Overview
Phase 3a enables directors to click a stickman, choose actions from a popup menu, and watch those actions execute in sequence when Play is pressed.
---
## 2. Milestone 1: Navigation Foundation
**Goal:** The stickman can walk to a target position on the stage.
**Deliverables:**
- `NavigationAgent2D` added as a child of `StickmanRig`
- `walk_to(target: Vector2)` method
- `is_walking()` method
- `arrived` signal
- `walk_speed` export variable (default 300)
**Test:**
1. Place a stickman on the stage.
2. Call `stickman.walk_to(Vector2(200, -300))`.
3. Stickman walks to the target.
---
## 3. Milestone 2: Action Queue Data Model
**Goal:** Store and manage a list of actions per stickman.
**Deliverables:**
- `action_queue: Array[Dictionary]` stored in `StickmanRig`
- `queue_action(action: Dictionary)`
- `clear_queue()`
- `get_queue()`
- `remove_action(index: int)`
- `insert_action(index: int, action: Dictionary)`
- `queue_size()`
**Action Types:**
```gdscript
{ "type": "walk_to", "target": Vector2, "speed": 300.0 }
{ "type": "speak", "text": "", "duration": 2.0 }
{ "type": "wait", "duration": 1.0 }
{ "type": "ragdoll" }
{ "type": "recover" }
```
**Test:**
Manually create a queue: Walk → Wait → Speak.
stickman.get_queue() returns the list.
## 4. Milestone 3: Director Tool UI
**Goal:** Click a stickman → popup menu → choose an action.
**Deliverables:**
- "Direct" tool button in the SandboxStage palette
- Click detection on stickmen in Edit mode
- Action popup menu (Walk To, Speak, Wait, Ragdoll, Recover)
- "Walk To" → click stage → waypoint appears
- "Speak" → text input dialog
- "Wait" → duration input dialog
**Popup Options:**
- 🚶 Walk To (enter target mode)
- 💬 Speak (open text dialog)
- ⏳ Wait (open duration dialog)
- 💥 Ragdoll (add immediately)
- 🔄 Recover (add immediately)
**Test:**
- Click "Direct" tool.
- Click a stickman → popup appears.
- Click "Walk To" → click stage → waypoint appears.
- Action is appended to the queue.
## 5. Milestone 4: Waypoint Visual System
**Goal:** Show the director's script visually on the stage in Edit mode.
**Deliverables:**
- Waypoint markers (colored dots at walk targets)
- Dotted lines connecting waypoints in order
- Action icons (speech bubble, clock, ragdoll)
- Order numbers (1, 2, 3…)
- Hide on Play, Show on Edit
**Visual Design:**
- Waypoint: Blue dot with white outline
- Dotted line: Dashed white line (alpha 0.6)
- Speech icon: Small speech bubble
- Wait icon: Small clock symbol
- Ragdoll icon: Small "X" symbol
**Test:**
- Direct: Walk → Wait → Speak → Walk.
- Switch to Edit mode.
- Waypoints, dotted lines, icons, and numbers appear in order.
## 6. Milestone 5: Action Runner
**Goal:** Execute the action queue in sequence when Play is pressed.
**Deliverables:**
- `start_queue()`
- `stop_queue()`
- `is_queue_running()`
- `action_started signal`
- `action_finished signal`
- `queue_finished signal`
_State Machine:_
```
IDLE → start_queue() → EXECUTING → action completes → next action
→ queue empty → emit queue_finished
→ stop_queue() → IDLE
```
**_Action Execution:_**
- walk_to → NavigationAgent2D → wait for arrived
- speak → Show speech bubble → wait for duration
- wait → Wait for duration
- ragdoll → set_ragdoll(true) → wait for rest
- recover → request_recovery() → wait for finish
**_Test:_**
- Create queue: Walk → Wait 2s → Speak "Hello!" → Walk.
- Press Play.
- Stickman walks → waits → speaks → walks.
- All signals emit at correct times.
## 7. Acceptance Criteria
- [ ] Stickman walks to a target when directed.
- [ ] Action queue stores and manages actions.
- [ ] Director Tool UI allows action selection via click popup.
- [ ] Waypoints and dotted lines visible in Edit mode.
- [ ] Action Runner executes queue in Play mode.
- [ ] No user input is accepted during Play.
- [ ] Multiple stickmen can act simultaneously.
## 8. File Changes
File Changes
- scripts/stickman_rig.gd Add NavigationAgent2D, action queue, action runner
- scripts/sandbox_stage.gd Add "Direct" tool, click detection, waypoint visualization
- scripts/stage_gizmos.gd Add waypoint markers and dotted line rendering
- scenes/sandbox_stage.tscn Add "Direct" tool button
+5 -5
View File
@@ -1,12 +1,12 @@
[gd_scene format=3] [gd_scene format=3 uid="uid://tkbhhidf8nht"]
[ext_resource type="Script" path="res://scripts/sandbox_stage.gd" id="1_stage"] [ext_resource type="Script" uid="uid://dkhhg81ft70mm" path="res://scripts/sandbox_stage.gd" id="1_stage"]
[node name="SandboxStage" type="Node2D"] [node name="SandboxStage" type="Node2D" unique_id=1704308946]
script = ExtResource("1_stage") script = ExtResource("1_stage")
[node name="Camera2D" type="Camera2D" parent="."] [node name="Camera2D" type="Camera2D" parent="." unique_id=770572167]
position = Vector2(0, -400) position = Vector2(0, -400)
zoom = Vector2(0.5, 0.5) zoom = Vector2(0.5, 0.5)
[node name="World" type="Node2D" parent="."] [node name="World" type="Node2D" parent="." unique_id=1413544363]
+235 -4
View File
@@ -18,6 +18,7 @@ const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
const STAGE_GIZMOS := preload("res://scripts/stage_gizmos.gd") const STAGE_GIZMOS := preload("res://scripts/stage_gizmos.gd")
const STAGE_GRID := preload("res://scripts/stage_grid.gd") const STAGE_GRID := preload("res://scripts/stage_grid.gd")
const STICKMAN_RIG := preload("res://scripts/stickman_rig.gd") const STICKMAN_RIG := preload("res://scripts/stickman_rig.gd")
const STAGE_DIRECTOR_VISUALS := preload("res://scripts/stage_director_visuals.gd")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Enums # Enums
@@ -48,6 +49,21 @@ const DEFAULT_GRID_SIZE := 15.0
const MIN_GRID_SIZE := 1.0 const MIN_GRID_SIZE := 1.0
const MAX_GRID_SIZE := 100.0 const MAX_GRID_SIZE := 100.0
## Director action-popup item ids (Phase 3a).
const ACT_WALK := 0
const ACT_SPEAK := 1
const ACT_WAIT := 2
const ACT_RAGDOLL := 3
const ACT_RECOVER := 4
## Debug gate for the Phase 3a stage trace. Ship OFF.
const DEBUG_STAGE := false
## Prints a `[stage] `-prefixed message only when DEBUG_STAGE is on.
func _stage_dbg(msg: String) -> void:
if DEBUG_STAGE:
print("[stage] ", msg)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Exported properties # Exported properties
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -105,6 +121,24 @@ var _authored: Dictionary = {}
## net for engines where a same-frame freeze + teleport does not stick). ## net for engines where a same-frame freeze + teleport does not stick).
var _restore_frames_left: int = 0 var _restore_frames_left: int = 0
# ---------------------------------------------------------------------------
# Director state (Phase 3a)
# ---------------------------------------------------------------------------
var _direct_mode: bool = false
var _direct_button: Button = null
var _action_popup: PopupMenu = null
var _context_rig: StickmanRig = null
var _pending_walk_target: bool = false
var _speak_dialog: AcceptDialog = null
var _speak_edit: LineEdit = null
var _wait_dialog: AcceptDialog = null
var _wait_spin: SpinBox = null
var _director_visuals = null # StageDirectorVisuals (preloaded)
var _nav_region: NavigationRegion2D = null
var _nav_dirty: bool = true
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Lifecycle # Lifecycle
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -120,6 +154,8 @@ func _ready() -> void:
_build_grid_layer() _build_grid_layer()
_build_gizmo_layer() _build_gizmo_layer()
_build_director_visuals()
_build_navigation()
_build_ghost_holder() _build_ghost_holder()
_build_ui() _build_ui()
_apply_grid_settings() _apply_grid_settings()
@@ -127,6 +163,9 @@ func _ready() -> void:
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
if _nav_dirty:
_nav_dirty = false
_rebake_navigation()
if _ghost != null and is_instance_valid(_ghost) and current_mode == StageMode.EDIT: if _ghost != null and is_instance_valid(_ghost) and current_mode == StageMode.EDIT:
_update_ghost_position() _update_ghost_position()
@@ -166,7 +205,14 @@ func _unhandled_key_input(event: InputEvent) -> void:
if current_mode == StageMode.EDIT: if current_mode == StageMode.EDIT:
delete_selected() delete_selected()
KEY_ESCAPE: KEY_ESCAPE:
if _placement_id != "": if _pending_walk_target:
_pending_walk_target = false
_context_rig = null
_refresh_status()
elif _direct_mode:
_direct_mode = false
_direct_button.set_pressed_no_signal(false)
elif _placement_id != "":
set_placement_mode("") set_placement_mode("")
elif not _selection.get_selected().is_empty(): elif not _selection.get_selected().is_empty():
_selection.clear_selection() _selection.clear_selection()
@@ -191,6 +237,10 @@ func _handle_world_click(mb: InputEventMouseButton) -> void:
if current_mode != StageMode.EDIT: if current_mode != StageMode.EDIT:
return return
var world_pos := _camera.get_global_mouse_position() var world_pos := _camera.get_global_mouse_position()
if _direct_mode:
if mb.pressed:
_handle_direct_click(world_pos)
return
if mb.pressed: if mb.pressed:
if _gizmos.hit_test(world_pos) != STAGE_GIZMOS.Handle.NONE: if _gizmos.hit_test(world_pos) != STAGE_GIZMOS.Handle.NONE:
_gizmos.begin_drag(world_pos) _gizmos.begin_drag(world_pos)
@@ -238,10 +288,12 @@ func set_mode(mode: StageMode) -> void:
func _enter_edit_mode() -> void: func _enter_edit_mode() -> void:
# Snap stickmen straight back to their standing pose/position (no stand-up # Snap stickmen straight back to their standing pose/position (no stand-up
# tween glide). # tween glide), stopping any director queue first.
for node: Node2D in _world_children_selectable(): for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG: if node is STICKMAN_RIG:
(node as STICKMAN_RIG).snap_to_standing() var rig := node as STICKMAN_RIG
rig.stop_queue()
rig.snap_to_standing()
# Freeze every prop (kinematic) so nothing keeps falling in EDIT — including # Freeze every prop (kinematic) so nothing keeps falling in EDIT — including
# any object that may not be in the authored map. freeze_mode is set BEFORE # any object that may not be in the authored map. freeze_mode is set BEFORE
# freeze so the body freezes directly as kinematic, never via the static # freeze so the body freezes directly as kinematic, never via the static
@@ -256,6 +308,7 @@ func _enter_edit_mode() -> void:
# so re-assert the authored transform on the next few physics frames. # so re-assert the authored transform on the next few physics frames.
_restore_frames_left = 3 _restore_frames_left = 3
_gizmos.set_enabled(true) _gizmos.set_enabled(true)
_director_visuals.set_enabled(true)
_apply_grid_settings() _apply_grid_settings()
_set_build_controls_visible(true) _set_build_controls_visible(true)
@@ -264,16 +317,27 @@ func _enter_play_mode() -> void:
_gizmos.set_enabled(false) _gizmos.set_enabled(false)
_selection.clear_selection() _selection.clear_selection()
set_placement_mode("") set_placement_mode("")
if _action_popup != null:
_action_popup.hide()
_pending_walk_target = false
_context_rig = null
_direct_mode = false
if _direct_button != null:
_direct_button.set_pressed_no_signal(false)
_director_visuals.set_enabled(false)
_apply_grid_settings() _apply_grid_settings()
_set_build_controls_visible(false) _set_build_controls_visible(false)
for node: Node2D in _world_children_selectable(): for node: Node2D in _world_children_selectable():
if node is RigidBody2D: if node is RigidBody2D:
(node as RigidBody2D).freeze = false (node as RigidBody2D).freeze = false
var rig_count := 0
for node: Node2D in _world_children_selectable(): for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG: if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG var rig := node as STICKMAN_RIG
rig.auto_recover = false rig.auto_recover = false
rig.set_ragdoll(true) rig.start_queue()
rig_count += 1
_stage_dbg("PLAY rigs=%d" % rig_count)
func _save_object_state(node: Node2D) -> void: func _save_object_state(node: Node2D) -> void:
@@ -328,6 +392,12 @@ func _place_at(world_pos: Vector2) -> void:
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
body.freeze = true body.freeze = true
_save_object_state(node) _save_object_state(node)
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
rig.queue_changed.connect(_director_visuals.mark_dirty)
_director_visuals.mark_dirty()
if node is TerrainBlock:
_nav_dirty = true
object_placed.emit(node) object_placed.emit(node)
_refresh_status() _refresh_status()
_spawn_ghost() _spawn_ghost()
@@ -426,11 +496,17 @@ func delete_selected() -> void:
var selected := _selection.get_selected().duplicate() var selected := _selection.get_selected().duplicate()
if selected.is_empty(): if selected.is_empty():
return return
var nav_changed := false
for node: Node2D in selected: for node: Node2D in selected:
if is_instance_valid(node): if is_instance_valid(node):
if node is TerrainBlock:
nav_changed = true
_clear_object_state(node) _clear_object_state(node)
node.queue_free() node.queue_free()
_selection.clear_selection() _selection.clear_selection()
if nav_changed:
_nav_dirty = true
_director_visuals.mark_dirty()
object_deleted.emit(selected) object_deleted.emit(selected)
_refresh_status() _refresh_status()
@@ -452,6 +528,8 @@ func _refresh_status() -> void:
else: else:
sel_text = "%d objects" % sel.size() sel_text = "%d objects" % sel.size()
_status_label.text = "Mode: %s | Objects: %d | Selected: %s" % [mode_text, count, sel_text] _status_label.text = "Mode: %s | Objects: %d | Selected: %s" % [mode_text, count, sel_text]
if _pending_walk_target:
_status_label.text += " | Click stage for walk target (Esc to cancel)"
if _mode_button != null: if _mode_button != null:
_mode_button.set_pressed_no_signal(current_mode == StageMode.PLAY) _mode_button.set_pressed_no_signal(current_mode == StageMode.PLAY)
_mode_button.text = "Play" if current_mode == StageMode.EDIT else "Edit" _mode_button.text = "Play" if current_mode == StageMode.EDIT else "Edit"
@@ -484,6 +562,12 @@ func _build_ui() -> void:
hbox.add_child(btn) hbox.add_child(btn)
_palette_buttons[id] = btn _palette_buttons[id] = btn
_direct_button = Button.new()
_direct_button.text = "Direct"
_direct_button.toggle_mode = true
_direct_button.toggled.connect(_on_direct_toggled)
hbox.add_child(_direct_button)
_grid_check = CheckBox.new() _grid_check = CheckBox.new()
_grid_check.text = "Grid" _grid_check.text = "Grid"
_grid_check.button_pressed = _show_grid _grid_check.button_pressed = _show_grid
@@ -514,6 +598,36 @@ func _build_ui() -> void:
_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT _status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
hbox.add_child(_status_label) hbox.add_child(_status_label)
_action_popup = PopupMenu.new()
_action_popup.add_item("🚶 Walk To", ACT_WALK)
_action_popup.add_item("💬 Speak", ACT_SPEAK)
_action_popup.add_item("⏳ Wait", ACT_WAIT)
_action_popup.add_item("💥 Ragdoll", ACT_RAGDOLL)
_action_popup.add_item("🔄 Recover", ACT_RECOVER)
_action_popup.id_pressed.connect(_on_action_popup_id_pressed)
ui.add_child(_action_popup)
_speak_dialog = AcceptDialog.new()
_speak_dialog.title = "Speak"
_speak_dialog.confirmed.connect(_on_speak_confirmed)
_speak_edit = LineEdit.new()
_speak_edit.placeholder_text = "Say something…"
_speak_edit.custom_minimum_size = Vector2(240.0, 0.0)
_speak_dialog.add_child(_speak_edit)
_speak_dialog.register_text_enter(_speak_edit)
ui.add_child(_speak_dialog)
_wait_dialog = AcceptDialog.new()
_wait_dialog.title = "Wait"
_wait_dialog.confirmed.connect(_on_wait_confirmed)
_wait_spin = SpinBox.new()
_wait_spin.min_value = 0.1
_wait_spin.max_value = 10.0
_wait_spin.step = 0.1
_wait_spin.value = 1.0
_wait_dialog.add_child(_wait_spin)
ui.add_child(_wait_dialog)
func _build_gizmo_layer() -> void: func _build_gizmo_layer() -> void:
_gizmos = STAGE_GIZMOS.new() _gizmos = STAGE_GIZMOS.new()
@@ -536,6 +650,56 @@ func _build_ghost_holder() -> void:
_ghost_holder.name = "PlacementGhost" _ghost_holder.name = "PlacementGhost"
add_child(_ghost_holder) add_child(_ghost_holder)
func _build_director_visuals() -> void:
_director_visuals = STAGE_DIRECTOR_VISUALS.new()
_director_visuals.name = "DirectorVisualsLayer"
_director_visuals.camera = _camera
_director_visuals.world = _world
add_child(_director_visuals)
## Code-built NavigationRegion2D child of the stage (NOT World, so it is never
## selected/hit-tested). Sits at the stage origin; World is at identity so world
## coordinates equal region-local coordinates.
func _build_navigation() -> void:
_nav_region = NavigationRegion2D.new()
_nav_region.name = "NavigationRegion2D"
add_child(_nav_region)
_rebake_navigation()
## Rebuild the navigation mesh from every TerrainBlock child of World: each
## block's world-space polygon is convex-decomposed and fan-triangulated into
## one manual NavigationPolygon.
func _rebake_navigation() -> void:
if _nav_region == null or not is_instance_valid(_nav_region):
return
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)
var pieces := Geometry2D.decompose_polygon_in_convex(pts)
if pieces.is_empty():
pieces = [pts]
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)
for tri: PackedInt32Array in triangles:
poly.add_polygon(tri)
_nav_region.navigation_polygon = poly
if DEBUG_STAGE:
print("[nav] baked verts=%d polys=%d" % [verts.size(), triangles.size()])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Signal handlers # Signal handlers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -546,6 +710,9 @@ func _on_mode_toggled(pressed: bool) -> void:
func _on_palette_toggled(pressed: bool, id: String) -> void: func _on_palette_toggled(pressed: bool, id: String) -> void:
if pressed: if pressed:
_direct_mode = false
if _direct_button != null:
_direct_button.set_pressed_no_signal(false)
set_placement_mode(id) set_placement_mode(id)
elif _placement_id == id: elif _placement_id == id:
set_placement_mode("") set_placement_mode("")
@@ -585,6 +752,68 @@ func _on_hover_changed(node: Node2D) -> void:
func _on_transform_committed(nodes: Array[Node2D]) -> void: func _on_transform_committed(nodes: Array[Node2D]) -> void:
for node: Node2D in nodes: for node: Node2D in nodes:
_save_object_state(node) _save_object_state(node)
if node is TerrainBlock:
_nav_dirty = true
# ---------------------------------------------------------------------------
# Director tool (Phase 3a)
# ---------------------------------------------------------------------------
func _on_direct_toggled(pressed: bool) -> void:
_direct_mode = pressed
if pressed:
set_placement_mode("")
_selection.clear_selection()
_pending_walk_target = false
_context_rig = null
_refresh_status()
func _handle_direct_click(world_pos: Vector2) -> void:
if _pending_walk_target and _context_rig != null and is_instance_valid(_context_rig):
_stage_dbg("walk_to captured target=(%.1f, %.1f)" % [world_pos.x, world_pos.y])
_context_rig.queue_action({ "type": "walk_to", "target": world_pos })
_pending_walk_target = false
_context_rig = null
_refresh_status()
return
var hit := _selection.hit_test(world_pos)
if hit is STICKMAN_RIG:
_context_rig = hit as StickmanRig
var mouse := get_viewport().get_mouse_position()
_action_popup.popup(Rect2i(Vector2i(mouse), Vector2i.ZERO))
func _on_action_popup_id_pressed(id: int) -> void:
if _context_rig == null or not is_instance_valid(_context_rig):
return
match id:
ACT_WALK:
_pending_walk_target = true
_refresh_status()
ACT_SPEAK:
_speak_edit.text = ""
_speak_dialog.popup_centered()
_speak_edit.grab_focus()
ACT_WAIT:
_wait_spin.value = 1.0
_wait_dialog.popup_centered()
ACT_RAGDOLL:
_context_rig.queue_action({ "type": "ragdoll" })
ACT_RECOVER:
_context_rig.queue_action({ "type": "recover" })
func _on_speak_confirmed() -> void:
if _context_rig == null or not is_instance_valid(_context_rig):
return
_context_rig.queue_action({ "type": "speak", "text": _speak_edit.text, "duration": 2.0 })
func _on_wait_confirmed() -> void:
if _context_rig == null or not is_instance_valid(_context_rig):
return
_context_rig.queue_action({ "type": "wait", "duration": _wait_spin.value })
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
@@ -630,6 +859,8 @@ func _set_build_controls_visible(visible: bool) -> void:
_grid_size_spin.visible = visible _grid_size_spin.visible = visible
if _grid_size_label != null: if _grid_size_label != null:
_grid_size_label.visible = visible _grid_size_label.visible = visible
if _direct_button != null:
_direct_button.visible = visible
## Direct Node2D children of World, excluding the ragdoll body container. ## Direct Node2D children of World, excluding the ragdoll body container.
+129
View File
@@ -0,0 +1,129 @@
class_name StageDirectorVisuals
extends Node2D
## StageDirectorVisuals - Edit-mode director overlay (Phase 3a).
##
## Draws each stickman's action queue as walk-to waypoint dots with dashed
## connectors plus small badges for speak/wait/ragdoll/recover, with order
## numbers. Pure drawing; no hit-testing. Visible in EDIT, hidden in PLAY.
const STICKMAN_RIG := preload("res://scripts/stickman_rig.gd")
const WAYPOINT_RADIUS_PX := 6.0
const WAYPOINT_COLOR := Color(0.2, 0.5, 1.0)
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 NUMBER_FONT_SIZE_PX := 16.0
const ICON_COLOR := Color(1.0, 1.0, 1.0, 0.9)
const ICON_SIZE_PX := 12.0
const BADGE_STACK_STEP := Vector2(0.0, -28.0)
var camera: Camera2D = null
var world: Node2D = null
var enabled: bool = true
var _dirty: bool = true
func set_enabled(value: bool) -> void:
enabled = value
visible = value
queue_redraw()
func mark_dirty() -> void:
_dirty = true
func _process(_delta: float) -> void:
if _dirty:
_dirty = false
queue_redraw()
func _zoom() -> float:
if camera != null and is_instance_valid(camera):
return maxf(camera.zoom.x, 0.0001)
return 1.0
func _collect_rigs() -> Array[StickmanRig]:
var result: Array[StickmanRig] = []
if world == null or not is_instance_valid(world):
return result
for child: Node in world.get_children():
if child is STICKMAN_RIG:
result.append(child as StickmanRig)
return result
func _draw() -> void:
if not enabled:
return
var zoom := _zoom()
for rig: StickmanRig in _collect_rigs():
_draw_rig_queue(rig, zoom)
func _draw_rig_queue(rig: StickmanRig, zoom: float) -> void:
var queue := rig.get_queue()
if queue.is_empty():
return
# Feet position = rig root - FOOT_OFFSET (FOOT_OFFSET maps feet -> root).
var current := rig.global_position - STICKMAN_RIG.FOOT_OFFSET
var stack := 0
for i: int in queue.size():
var action: Dictionary = queue[i]
if String(action.get("type", "")) == "walk_to":
var target: Vector2 = action.get("target", current)
_draw_dashed(current, target, zoom)
_draw_waypoint(target, zoom, str(i + 1))
current = target
stack = 0
else:
var anchor := current + BADGE_STACK_STEP * float(stack) / zoom
_draw_badge(anchor, String(action.get("type", "")), zoom, str(i + 1))
stack += 1
func _draw_dashed(from: Vector2, to: Vector2, zoom: float) -> void:
var dash := DASH_LENGTH_PX / zoom
var gap := DASH_GAP_PX / zoom
var dir := from.direction_to(to)
var total := from.distance_to(to)
var dist := 0.0
while dist < total:
var start := from + dir * dist
var len := minf(dash, total - dist)
draw_line(start, start + dir * len, DASH_COLOR, DASH_WIDTH_PX / zoom, true)
dist += dash + gap
func _draw_waypoint(pos: Vector2, zoom: float, number: String) -> void:
var radius := WAYPOINT_RADIUS_PX / zoom
draw_circle(pos, radius, WAYPOINT_COLOR)
draw_arc(pos, radius, 0.0, TAU, 32, WAYPOINT_OUTLINE, 2.0 / zoom, true)
_draw_number(pos + Vector2(radius + 6.0 / zoom, 0.0), number, zoom)
func _draw_number(pos: Vector2, number: String, zoom: float) -> void:
draw_string(ThemeDB.fallback_font, pos, number, HORIZONTAL_ALIGNMENT_LEFT, -1.0, int(NUMBER_FONT_SIZE_PX / zoom), NUMBER_COLOR)
func _draw_badge(anchor: Vector2, type: String, zoom: float, number: String) -> void:
var s := ICON_SIZE_PX / zoom
match type:
"speak":
var bw := s * 1.6
var bh := s * 1.1
draw_rect(Rect2(anchor - Vector2(bw, bh) * 0.5, Vector2(bw, bh)), ICON_COLOR, true)
draw_colored_polygon(PackedVector2Array([
anchor + Vector2(-s * 0.25, bh * 0.5),
anchor + Vector2(s * 0.25, bh * 0.5),
anchor + Vector2(0.0, bh * 0.5 + s * 0.5),
]), ICON_COLOR)
"wait":
draw_arc(anchor, s, 0.0, TAU, 32, ICON_COLOR, 2.0 / zoom, true)
draw_line(anchor, anchor + Vector2(0.0, -s * 0.7), ICON_COLOR, 2.0 / zoom, true)
draw_line(anchor, anchor + Vector2(s * 0.5, 0.0), ICON_COLOR, 2.0 / zoom, true)
"ragdoll":
draw_line(anchor + Vector2(-s, -s) * 0.5, anchor + Vector2(s, s) * 0.5, ICON_COLOR, 2.0 / zoom, true)
draw_line(anchor + Vector2(s, -s) * 0.5, anchor + Vector2(-s, s) * 0.5, ICON_COLOR, 2.0 / zoom, true)
"recover":
var base := anchor + Vector2(0.0, s * 0.6)
var tip := anchor + Vector2(0.0, -s * 0.6)
draw_line(base, tip, ICON_COLOR, 2.0 / zoom, true)
draw_line(tip, tip + Vector2(-s * 0.5, s * 0.4), ICON_COLOR, 2.0 / zoom, true)
draw_line(tip, tip + Vector2(s * 0.5, s * 0.4), ICON_COLOR, 2.0 / zoom, true)
_draw_number(anchor + Vector2(s, -s), number, zoom)
+1
View File
@@ -0,0 +1 @@
uid://dsmvfj2hn3h3p
+422 -9
View File
@@ -25,6 +25,12 @@ enum BendDirection { NORMAL, INVERTED }
## IK targets to the standing pose before returning to ANIMATED. ## IK targets to the standing pose before returning to ANIMATED.
enum RigState { ANIMATED, RAGDOLL, RECOVERING } enum RigState { ANIMATED, RAGDOLL, RECOVERING }
## Director action-runner execution state (Phase 3a).
enum RunnerState { IDLE, EXECUTING }
## Which kind of queued action is currently executing (Phase 3a).
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Constants # Constants
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -142,6 +148,33 @@ const IK_TARGET_PATHS: Dictionary = {
"Right_Leg": "IK_Targets/Right_Leg", "Right_Leg": "IK_Targets/Right_Leg",
} }
# ---------------------------------------------------------------------------
# Director / navigation constants (Phase 3a)
# ---------------------------------------------------------------------------
## Feet -> root translation (a ground point -> the hip/root position).
const FOOT_OFFSET := Vector2(0.0, -385.0)
## The navigation agent sits at the feet, on the nav mesh.
const NAV_AGENT_LOCAL_POS := Vector2(0.0, 385.0)
## Fallback arrival distance (px) to the root destination.
const ARRIVE_DISTANCE := 8.0
const NAV_PATH_DESIRED_DISTANCE := 8.0
const NAV_TARGET_DESIRED_DISTANCE := 12.0
## Rig-local anchor for the speech bubble, above the head.
const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
## Debug gate for the Phase 3a walk/runner trace. Ship OFF.
const DEBUG_WALK := false
## Prints a `[walk] `-prefixed message only when DEBUG_WALK is on.
func _walk_dbg(msg: String) -> void:
if DEBUG_WALK:
print("[walk] ", msg)
## Preloaded (not a class_name type) so this script compiles even when the
## editor's global class cache is stale.
const SPEECH_BUBBLE_SCRIPT := preload("res://scripts/stickman_speech_bubble.gd")
## Ragdoll body definitions, ordered parent-before-child. `node_path` is ## Ragdoll body definitions, ordered parent-before-child. `node_path` is
## Skeleton2D-relative for bones and rig-root-relative for the head visual. ## Skeleton2D-relative for bones and rig-root-relative for the head visual.
## `kind` is "bone" (capsule along a Bone2D) or "visual" (circle at Body/Head). ## `kind` is "bone" (capsule along a Bone2D) or "visual" (circle at Body/Head).
@@ -216,6 +249,10 @@ const RAGDOLL_JOINTS: Array[Dictionary] = [
## ragdoll stays down until request_recovery() is called manually. ## ragdoll stays down until request_recovery() is called manually.
@export var auto_recover: bool = true @export var auto_recover: bool = true
@export_group("Director")
## Walk speed (px/s) used by walk_to when no per-action speed is given.
@export var walk_speed: float = 300.0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Signals # Signals
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -231,6 +268,17 @@ signal bend_flag_changed(joint: String, flipped: bool)
## the RigState enum value. ## the RigState enum value.
signal state_changed(new_state: int) signal state_changed(new_state: int)
# ---------------------------------------------------------------------------
# Director signals (Phase 3a)
# ---------------------------------------------------------------------------
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 queue mutation
signal speech_finished # bubble auto-hid after speak()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Internal state # Internal state
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -265,6 +313,38 @@ var _stabilize_timer: float = 0.0
var _captured_pose: Dictionary = {} # { String : {pos, rot, half} } (rig-local) var _captured_pose: Dictionary = {} # { String : {pos, rot, half} } (rig-local)
var _stand_up_tween: Tween = null var _stand_up_tween: Tween = null
# ---------------------------------------------------------------------------
# Director / navigation state (Phase 3a)
# ---------------------------------------------------------------------------
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
## NavigationAgent2D is a plain Node (not Node2D): it has no `position`; it
## derives its agent position from its parent Node2D's global position. This
## anchor sits at the feet (NAV_AGENT_LOCAL_POS) so the agent paths from
## ground level.
var _nav_anchor: Node2D = null
var _walking: bool = false
var _walk_target_feet: Vector2 = Vector2.ZERO
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
var _speech_bubble = null # SpeechBubble (preloaded script)
var _speech_active: bool = false
var _speech_time_left: float = 0.0
var _phase_timer: float = 0.0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Lifecycle # Lifecycle
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -305,6 +385,25 @@ func _ready() -> void:
# setters only stored values). # setters only stored values).
_nodes_ready = true _nodes_ready = true
_apply_profile() _apply_profile()
# Build the navigation agent at the feet (Phase 3a). It shares the default
# navigation map/layer with the stage's NavigationRegion2D.
# NavigationAgent2D is a plain Node: it has no `position`; it derives its
# agent position from its parent Node2D's global position, so we anchor it
# under a Node2D placed at the feet.
_nav_anchor = Node2D.new()
_nav_anchor.name = "NavigationAgentAnchor"
_nav_anchor.position = NAV_AGENT_LOCAL_POS
add_child(_nav_anchor)
_nav_agent = NavigationAgent2D.new()
_nav_agent.name = "NavigationAgent2D"
_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
_nav_anchor.add_child(_nav_agent)
_prev_global_pos = global_position _prev_global_pos = global_position
_prev_global_rot = global_rotation _prev_global_rot = global_rotation
@@ -312,6 +411,9 @@ func _ready() -> void:
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
_track_momentum(delta) _track_momentum(delta)
_update_rest_detection(delta) _update_rest_detection(delta)
_update_walking(delta)
_update_speech(delta)
_update_runner(delta)
func _track_momentum(delta: float) -> void: func _track_momentum(delta: float) -> void:
@@ -340,6 +442,9 @@ func _update_rest_detection(delta: float) -> void:
_rest_timer = 0.0 _rest_timer = 0.0
_stabilize_timer = 0.0 _stabilize_timer = 0.0
return return
# Publish rest regardless of auto_recover (the director runner polls it).
if not _ragdoll_at_rest:
_ragdoll_at_rest = true
if not auto_recover: if not auto_recover:
_rest_timer = 0.0 _rest_timer = 0.0
return return
@@ -446,15 +551,7 @@ func snap_to_standing() -> void:
_destroy_ragdoll() _destroy_ragdoll()
else: else:
_cancel_recovery() _cancel_recovery()
# Set the IK targets directly to the standing pose (no tween). _restore_standing_markers()
for marker_name: String in STAND_POSE:
var marker := _get_ik_marker(marker_name)
if marker == null:
continue
var target: Dictionary = STAND_POSE[marker_name]
marker.position = target.get("pos", marker.position)
if marker_name == "Torso":
marker.rotation = target.get("rot", marker.rotation)
# Re-show the kinematic puppet and re-enable IK. # Re-show the kinematic puppet and re-enable IK.
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null: if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
_skeleton.modification_stack.enabled = true _skeleton.modification_stack.enabled = true
@@ -465,6 +562,18 @@ func snap_to_standing() -> void:
state_changed.emit(int(state)) state_changed.emit(int(state))
## Writes STAND_POSE onto the 6 IK-target markers (no tween, no state change).
func _restore_standing_markers() -> void:
for marker_name: String in STAND_POSE:
var marker := _get_ik_marker(marker_name)
if marker == null:
continue
var target: Dictionary = STAND_POSE[marker_name]
marker.position = target.get("pos", marker.position)
if marker_name == "Torso":
marker.rotation = target.get("rot", marker.rotation)
## Applies the same velocity delta to every ragdoll body via a mass-scaled ## Applies the same velocity delta to every ragdoll body via a mass-scaled
## central impulse, preserving the ragdoll's internal structure. No-op outside ## central impulse, preserving the ragdoll's internal structure. No-op outside
## RAGDOLL mode. Used by the physics harness "Knock Up" button. ## RAGDOLL mode. Used by the physics harness "Knock Up" button.
@@ -570,6 +679,8 @@ func _enter_ragdoll() -> void:
if _skeleton == null or _body_container == null: if _skeleton == null or _body_container == null:
push_warning("StickmanRig: cannot enter ragdoll; missing rig nodes.") push_warning("StickmanRig: cannot enter ragdoll; missing rig nodes.")
return return
_cancel_walking()
_ragdoll_at_rest = false
# Instant handoff: stop the player without resetting it (keep_state) and # Instant handoff: stop the player without resetting it (keep_state) and
# build the ragdoll from the CURRENT solved bone positions while the IK # build the ragdoll from the CURRENT solved bone positions while the IK
# stack is still enabled (disabling it first would revert the bones to the # stack is still enabled (disabling it first would revert the bones to the
@@ -911,3 +1022,305 @@ func _destroy_ragdoll() -> void:
_ragdoll_root.queue_free() _ragdoll_root.queue_free()
_ragdoll_root = null _ragdoll_root = null
_ragdoll_bodies.clear() _ragdoll_bodies.clear()
# ---------------------------------------------------------------------------
# Navigation / walking (Phase 3a)
# ---------------------------------------------------------------------------
## 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:
if state != RigState.ANIMATED:
push_warning("StickmanRig: walk_to ignored; not ANIMATED.")
return
_walk_target_feet = target
_walk_speed_current = speed if speed > 0.0 else walk_speed
_nav_agent.max_speed = _walk_speed_current
_nav_agent.target_position = target
_walk_dbg("walk_to target=(%.1f, %.1f) speed=%.1f root=(%.1f, %.1f) feet=(%.1f, %.1f)" % [
target.x, target.y, _walk_speed_current,
global_position.x, global_position.y,
_nav_anchor.global_position.x, _nav_anchor.global_position.y,
])
var dx := target.x - global_position.x
var anim_name: String
if dx < -0.5:
set_facing_profile(FacingProfile.LEFT)
anim_name = "walk_left"
elif dx > 0.5:
set_facing_profile(FacingProfile.RIGHT)
anim_name = "walk_right"
else:
anim_name = "walk_right"
if _anim_player != null and is_instance_valid(_anim_player) and _anim_player.has_animation(anim_name):
_anim_player.play(anim_name)
_walking = true
_walk_done = false
func is_walking() -> bool:
return _walking
func _update_walking(delta: float) -> void:
if not _walking:
return
if state != RigState.ANIMATED:
_cancel_walking()
return
# Defer all nav reads until the map has actually synchronized. An unsynced
# agent reports an empty, finished path (map iteration id == 0), which would
# otherwise end the walk after a single move_toward step (~5 px).
if NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map()) == 0:
_walk_dbg("sync pending")
return
# Ask the agent for its next waypoint FIRST. This forces the agent's internal
# path update (_update_navigation), which re-queries the map whenever 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.
var next_feet := _nav_agent.get_next_path_position()
var root_target: Vector2
if _nav_agent.is_target_reachable():
# NAV branch: the target lies on the mesh — follow the path to it.
_walk_mode = "nav"
if _nav_agent.is_navigation_finished():
_finish_walk("finished")
return
root_target = next_feet + FOOT_OFFSET
else:
# DIRECT branch: an off-mesh waypoint is now a normal, supported case —
# steer straight at the clicked point, ignoring the nav mesh.
if _walk_mode != "direct":
_walk_dbg("off-mesh waypoint: switching to direct steering")
_walk_mode = "direct"
root_target = _walk_target_feet + FOOT_OFFSET
global_position = global_position.move_toward(root_target, _walk_speed_current * delta)
if global_position.distance_to(_walk_target_feet + FOOT_OFFSET) <= ARRIVE_DISTANCE:
_finish_walk("arrive")
return
_walk_dbg("frame=%d idx=%d mode=%s root=(%.1f, %.1f) feet=(%.1f, %.1f) target=(%.1f, %.1f) dist=%.1f finished=%s reachable=%s final=(%.1f, %.1f) pts=%d next=(%.1f, %.1f) map_iter=%d" % [
Engine.get_physics_frames(),
_current_index,
_walk_mode,
global_position.x, global_position.y,
_nav_anchor.global_position.x, _nav_anchor.global_position.y,
_walk_target_feet.x, _walk_target_feet.y,
global_position.distance_to(_walk_target_feet + FOOT_OFFSET),
str(_nav_agent.is_navigation_finished()),
str(_nav_agent.is_target_reachable()),
_nav_agent.get_final_position().x, _nav_agent.get_final_position().y,
_nav_agent.get_current_navigation_path().size(),
next_feet.x, next_feet.y,
NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map()),
])
func _finish_walk(reason: String = "") -> void:
if DEBUG_WALK:
var map_iter := -1
if _nav_agent != null:
map_iter = NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map())
_walk_dbg("finish reason=%s root=(%.1f, %.1f) dist_to_target=%.1f map_iter=%d" % [
reason,
global_position.x, global_position.y,
global_position.distance_to(_walk_target_feet + FOOT_OFFSET),
map_iter,
])
if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop()
_restore_standing_markers()
_walk_done = true
_walking = false
arrived.emit()
func _cancel_walking() -> void:
_walk_dbg("cancel (state=%s)" % RigState.keys()[state])
if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop()
if _nav_agent != null and _nav_anchor != null:
_nav_agent.target_position = _nav_anchor.global_position
_walking = false
_walk_done = false
# ---------------------------------------------------------------------------
# Speech (Phase 3a)
# ---------------------------------------------------------------------------
## 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:
if _speech_bubble == null or not is_instance_valid(_speech_bubble):
_speech_bubble = SPEECH_BUBBLE_SCRIPT.new()
_speech_bubble.name = "SpeechBubble"
_speech_bubble.position = SPEECH_BUBBLE_OFFSET
add_child(_speech_bubble)
_speech_bubble.show_text(text)
_speech_active = true
_speech_time_left = maxf(duration, 0.0)
func _update_speech(delta: float) -> void:
if not _speech_active:
return
_speech_time_left -= delta
if _speech_time_left <= 0.0:
_hide_speech()
speech_finished.emit()
func _hide_speech() -> void:
_speech_active = false
_speech_time_left = 0.0
if _speech_bubble != null and is_instance_valid(_speech_bubble):
_speech_bubble.hide_bubble()
# ---------------------------------------------------------------------------
# Action queue (Phase 3a)
# ---------------------------------------------------------------------------
func queue_action(action: Dictionary) -> void:
action_queue.append(action)
queue_changed.emit()
func clear_queue() -> void:
action_queue.clear()
queue_changed.emit()
func get_queue() -> Array[Dictionary]:
return action_queue.duplicate()
func remove_action(index: int) -> void:
if index < 0 or index >= action_queue.size():
push_warning("StickmanRig: remove_action index %d out of range." % index)
return
action_queue.remove_at(index)
queue_changed.emit()
func insert_action(index: int, action: Dictionary) -> void:
action_queue.insert(clampi(index, 0, action_queue.size()), action)
queue_changed.emit()
func queue_size() -> int:
return action_queue.size()
# ---------------------------------------------------------------------------
# Runner state machine (Phase 3a)
# ---------------------------------------------------------------------------
## Start running the queue. Empty queue emits queue_finished immediately.
func start_queue() -> void:
if _runner_state == RunnerState.EXECUTING:
return
if DEBUG_WALK:
print("[runner] start_queue size=%d" % action_queue.size())
if action_queue.is_empty():
queue_finished.emit()
return
_runner_state = RunnerState.EXECUTING
_action_phase = ActionPhase.NONE
_current_index = -1
_stop_requested = false
## Abort the current action and return to IDLE. Does not emit queue_finished.
func stop_queue() -> void:
_stop_requested = true
_cancel_walking()
_hide_speech()
_runner_state = RunnerState.IDLE
_action_phase = ActionPhase.NONE
_current_index = -1
func is_queue_running() -> bool:
return _runner_state == RunnerState.EXECUTING
func is_ragdoll_at_rest() -> bool:
return _ragdoll_at_rest
func _update_runner(delta: float) -> void:
if _runner_state != RunnerState.EXECUTING:
return
match _action_phase:
ActionPhase.NONE:
_advance_to_next_action()
ActionPhase.WALKING:
if _walk_done:
_finish_action()
ActionPhase.SPEAKING:
if not _speech_active:
_finish_action()
ActionPhase.WAITING:
_phase_timer -= delta
if _phase_timer <= 0.0:
_finish_action()
ActionPhase.RAGDOLLING:
if is_ragdoll_at_rest():
_finish_action()
ActionPhase.RECOVERING:
if state == RigState.ANIMATED:
_finish_action()
func _advance_to_next_action() -> void:
_current_index += 1
if _current_index >= action_queue.size():
_runner_state = RunnerState.IDLE
_action_phase = ActionPhase.NONE
_current_index = -1
queue_finished.emit()
return
var action := action_queue[_current_index]
if DEBUG_WALK:
print("[runner] start idx=%d type=%s" % [_current_index, String(action.get("type", ""))])
action_started.emit(action, _current_index)
_begin_action(action)
func _begin_action(action: Dictionary) -> void:
match String(action.get("type", "")):
"walk_to":
_action_phase = ActionPhase.WALKING
_walk_done = false
walk_to(action.get("target", Vector2.ZERO), float(action.get("speed", -1.0)))
if not _walking:
# walk_to self-guarded (e.g. not ANIMATED): complete immediately
# so the runner never hangs.
_walk_done = true
"speak":
_action_phase = ActionPhase.SPEAKING
speak(String(action.get("text", "")), float(action.get("duration", 2.0)))
"wait":
_phase_timer = float(action.get("duration", 0.0))
_action_phase = ActionPhase.WAITING
"ragdoll":
_action_phase = ActionPhase.RAGDOLLING
set_ragdoll(true)
"recover":
_action_phase = ActionPhase.RECOVERING
request_recovery()
_:
push_warning("StickmanRig: unknown action type '%s'; skipped." % String(action.get("type", "")))
_finish_action()
func _finish_action() -> void:
if DEBUG_WALK and _current_index >= 0 and _current_index < action_queue.size():
print("[runner] finish idx=%d type=%s" % [_current_index, String(action_queue[_current_index].get("type", ""))])
if _current_index >= 0 and _current_index < action_queue.size():
action_finished.emit(action_queue[_current_index], _current_index)
_action_phase = ActionPhase.NONE
+50
View File
@@ -0,0 +1,50 @@
class_name SpeechBubble
extends Node2D
## SpeechBubble - World-space speech bubble drawn in _draw() (Phase 3a).
##
## A child of the rig root at a fixed upward offset (SPEECH_BUBBLE_OFFSET), so
## it follows the figure and scales with the camera. Pure drawing, no
## hit-testing. Hidden by default.
const FONT_SIZE := 28
const PADDING := Vector2(14.0, 10.0)
const TAIL_HEIGHT := 12.0
const TAIL_WIDTH := 16.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 = ""
var _bg_style: StyleBoxFlat
func _init() -> void:
visible = false
_bg_style = StyleBoxFlat.new()
_bg_style.bg_color = BG_COLOR
_bg_style.border_color = BORDER_COLOR
_bg_style.set_border_width_all(2)
_bg_style.set_corner_radius_all(8)
func show_text(text: String) -> void:
_text = text
visible = true
queue_redraw()
func hide_bubble() -> void:
visible = false
func _draw() -> void:
if _text.is_empty():
return
var font := ThemeDB.fallback_font
var text_size := font.get_string_size(_text, HORIZONTAL_ALIGNMENT_LEFT, MAX_WIDTH, FONT_SIZE)
var box_size := text_size + PADDING * 2.0
var box := Rect2(Vector2(-box_size.x * 0.5, -TAIL_HEIGHT - box_size.y), box_size)
draw_style_box(_bg_style, box)
draw_colored_polygon(PackedVector2Array([
Vector2(-TAIL_WIDTH * 0.5, -TAIL_HEIGHT),
Vector2(TAIL_WIDTH * 0.5, -TAIL_HEIGHT),
Vector2(0.0, 0.0),
]), BG_COLOR)
draw_string(font, box.position + PADDING, _text, HORIZONTAL_ALIGNMENT_LEFT, MAX_WIDTH, FONT_SIZE, TEXT_COLOR)
+1
View File
@@ -0,0 +1 @@
uid://b22w8sfbhd0y8