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