Add Phase 3a Core Director Functionality
- Introduced `PHASE_3a_CORE_DIRECTOR.md` detailing the core functionality for directors, including navigation, action queue, UI, waypoint visualization, and action execution. - Implemented `StageDirectorVisuals` for drawing stickman action queues in edit mode, including waypoints and action badges. - Created `SpeechBubble` class for displaying speech bubbles above stickmen, with customizable text and styling.
This commit is contained in:
@@ -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`,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -444,7 +444,82 @@ Clicking a palette button enters **placement mode**, which shows a translucent *
|
||||
|
||||
**Status bar:** shows `Mode: EDIT/PLAY | Objects: N | Selected: <name or count>` and updates live on spawn, selection, deletion, and mode changes.
|
||||
|
||||
> **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`)
|
||||
|
||||
|
||||
+4
-3
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
+235
-4
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dsmvfj2hn3h3p
|
||||
+422
-9
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b22w8sfbhd0y8
|
||||
Reference in New Issue
Block a user