From f208127917ce43dc9bc9c9b52c30551964636f0a Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Tue, 1 Sep 2026 08:58:07 -0400 Subject: [PATCH] feat: Implement Phase 4 Trigger Events System - Added a new event-driven system for reactive storytelling, allowing rules like "When X happens, do Y." - Introduced TriggerArea class for placeable sensors in the stage. - Enhanced StickmanRig to emit signals for actions and arrivals. - Updated StageDirectorVisuals to render rules visually with labels and badges. - Modified StageSpawner to support spawning TriggerAreas. - Improved text baseline calculations in speech bubbles and rule labels. - Added tests for text baseline fixes to ensure proper rendering. - Documented the implementation plan for Phase 4 in PHASE_4_TRIGGER_EVENTS.md. - Created a polish plan for Phase 4 in PHASE_4b_POLISH.md. --- .ignore | 3 + AGENTS.md | 40 +- README.md | 55 +++ docs/tech_debt_and_optimizations.md | 2 + plans/PHASE_4_TRIGGER_EVENTS.md | 326 +++++++++++++++ plans/PHASE_4b_POLISH.md | 7 + scripts/prop_block.gd | 17 + scripts/sandbox_stage.gd | 626 +++++++++++++++++++++++++++- scripts/stage_director_visuals.gd | 214 ++++++++++ scripts/stage_selection.gd | 3 + scripts/stage_spawner.gd | 18 + scripts/stickman_rig.gd | 22 +- scripts/stickman_speech_bubble.gd | 3 +- scripts/trigger_area.gd | 50 +++ scripts/trigger_area.gd.uid | 1 + tests/test_text_baseline_fix.gd | 158 +++++++ tests/test_text_baseline_fix.gd.uid | 1 + 17 files changed, 1538 insertions(+), 8 deletions(-) create mode 100644 .ignore create mode 100644 plans/PHASE_4_TRIGGER_EVENTS.md create mode 100644 plans/PHASE_4b_POLISH.md create mode 100644 scripts/trigger_area.gd create mode 100644 scripts/trigger_area.gd.uid create mode 100644 tests/test_text_baseline_fix.gd create mode 100644 tests/test_text_baseline_fix.gd.uid diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..a9deecf --- /dev/null +++ b/.ignore @@ -0,0 +1,3 @@ +PROJECT.md +RIGGING.md +master_rig2.tscn diff --git a/AGENTS.md b/AGENTS.md index a24b37f..ba2796f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -337,6 +337,11 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and 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). + - **Phase 4 triggers:** the `arrived` signal gained a `target: Vector2` payload (emits + `_walk_target_feet`) so the stage can match waypoints for `arrived_at_waypoint` rules; new + `enqueue_reactive(actions: Array[Dictionary]) -> void` appends reactive actions to the action + queue and, if the runner is `IDLE`, resumes at the **first newly-appended action** (no replay of + the already-consumed queue prefix). Sequential Phase 3a queues are untouched. - `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: @@ -510,6 +515,16 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and None: mass 1.0, friction 0.5, bounce 0.05) and recolor fill/outline for non-`NONE`. Unified live-update setters push geometry/color changes to all children (null-guarded for `@tool` editor safety); `_apply_shape()` enables exactly one collision node. + - **Phase 4 collision signal:** new `signal collided(other: Node)`; `_ready()` sets + `contact_monitor = true`, `max_contacts_reported = 8`, and connects the guarded + `body_entered` signal → `_on_body_entered` → `collided.emit(body)`, so **prop-vs-prop** + collisions are reported by the physics engine (the stage's geometric feet-point test covers + stickman-vs-prop separately). +- `scripts/trigger_area.gd` — `class_name TriggerArea`, `extends Node2D`; a **placeable sensor** + (Phase 4, **not used by the editor**). `@export size: Vector2` (default 96×96), `get_area_rect() + -> Rect2` (centered on the node's global position), and `_draw()` (translucent green fill + + dashed border). **No physics and no signals** — it is a pure geometric region evaluated by + `sandbox_stage.gd`'s event engine (`_update_area_entry`) for `entered_area` rules. - `scripts/prop_utils.gd` — `class_name PropUtils`, `extends RefCounted`; static factory (**not used by the editor**): - `create_box(size := Vector2(48,48))` / `create_ball(radius := 24.0)` / @@ -616,6 +631,20 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and `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. + - **Phase 4 triggers & event system:** adds a When→Then **rule system** — `_event_rules: + Array[Dictionary]` of `{id, trigger, actions}` rules (trigger types: `arrived_at_waypoint`, + `action_finished`, `speech_finished`, `entered_area`, `collided`; action types reuse the Phase + 3a set: `walk_to`/`speak`/`wait`/`ragdoll`/`recover`). Rules persist across Play/Edit mode + toggles but are **not** saved to disk (Phase 5). A geometric **event engine** runs in PLAY + (`_update_area_entry` tests a movable's feet/position against each `TriggerArea.get_area_rect()`; + `_update_stickman_prop_collision` tests a stickman's feet point against prop AABBs and vice-versa), + with **edge-triggered** dicts (already-fired events) reset on mode entry. A **rule-builder UI + state machine** (`RuleStep` enum: `IDLE`, `SELECT_TRIGGER`, `TRIGGER_TARGET`, `SELECT_ACTION`, + `ACTION_TARGET`, `ACTION_POSITION`, `PARAMS`) is driven from a **"⚡ When..."** item in the Direct + action popup: trigger sub-menu popup → trigger-target click → rule-action popup → target/position + → "Add another action / Done" popup. **Esc** has highest priority; status-bar hints + toast + messages guide the flow. Rule **label click** → consequence-only edit (replaces the rule, same + `id`); **✕** deletes; `_cleanup_rules_for_nodes` auto-removes rules referencing deleted objects. - `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. @@ -637,6 +666,11 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and (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)`. + - **Phase 4 rule visualization:** `_draw()` also renders `_event_rules` (set via `set_rules()`) — + a **dashed white connector line** from the trigger object to the target, a green **⚡ trigger + badge**, an orange **→ action badge**, a dark label with `rule_summary()` text, and a **✕ delete + icon** (the label + icon are hit-testable via `hit_test_rule()`, `Vector2.INF` sentinel from + `hit_test_waypoint()`); click-label → consequence-only edit, click-✕ → delete. - `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`, @@ -647,6 +681,9 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and `_init`) and applies `STICKMAN_FOOT_OFFSET (0, -385)` so feet land at the cursor. Also exposes a `static get_world_aabb(node)` helper (for a stickman it unions the mounted `Body/*` shape geometry via a recursive `_collect_visual_points()` so the box is centered head-to-feet). + - **Phase 4 area palette entry:** a new `"area"` registry entry (label "Area") → `_spawn_area()` + instantiates a `TriggerArea`; `get_world_aabb()` gains a **duck-typed** `get_area_rect` AABB + branch (if the node responds to `get_area_rect()`, use its `Rect2` as the world bounds). - `scripts/stage_selection.gd` — `class_name StageSelection`, `extends RefCounted`; hover/click/ box selection via geometric world-space AABB hit-testing (Phase 2). `static get_world_aabb` unions a `Polygon2D` child's world points (terrain/props) or, for a stickman rig, recursively @@ -656,7 +693,8 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and smallest area breaks ties. `_is_selectable` excludes the `RagdollBodyContainer` subtree. Signals `hover_changed(node)` / `selection_changed(nodes)`; public API `get_selected`/`get_primary`/ `clear_selection`/`select_only`/`add_to_selection`/`toggle_selection`/`is_selected`/`hit_test`/ - `update_hover`/`box_select`. + `update_hover`/`box_select`. (Phase 4) `get_world_aabb` also handles `TriggerArea` objects via + the same **duck-typed** `get_area_rect` AABB branch, so trigger areas are selectable/hit-testable. - `scripts/stage_gizmos.gd` — `class_name StageGizmos`, `extends Node2D`; hover highlight + selection outlines + a rotate ring (Phase 2). **No translate handle** — objects are dragged directly by the root; `set_targets(nodes: Array[Node2D])` holds the current multi-selection, diff --git a/README.md b/README.md index e273f16..411f7d3 100644 --- a/README.md +++ b/README.md @@ -521,6 +521,61 @@ The **Director Tool** (Phase 3a) turns the Sandbox Stage into a mini director's `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. +### 20. Triggers & Event System (Phase 4) + +**Phase 4** adds **reactive storytelling** to the Sandbox Stage. While Phase 3a gave directors *sequential* control (actions in a fixed order per stickman), Phase 4 adds *reactive* control — **"When X happens, do Y"** — via event rules that span objects. It is **not wired into the editor** — run via **F6** on `res://scenes/sandbox_stage.tscn`. + +The rule concept is **When → Then**: a *trigger* (an event on some object) fires a list of *actions* on a target object. Rules are stored as `_event_rules: Array[Dictionary]` of `{id, trigger, actions}` in `sandbox_stage.gd`, where `trigger` is `{type, target, ...}` and `actions` is an array of Phase 3a action dicts (`{"type":"walk_to","target":...}`, etc.). + +| File | Purpose | +|---|---| +| `res://scripts/trigger_area.gd` | NEW `class_name TriggerArea`, `extends Node2D` — a placeable sensor (`@export size: Vector2`, default 96×96) with `get_area_rect() -> Rect2` and a translucent green `_draw()` fill + dashed border. **No physics, no signals** — it is a pure geometric region evaluated by the stage. | +| `res://scripts/sandbox_stage.gd` | Extended with the rule system: `_event_rules`, the geometric event engine (`_update_area_entry`, `_update_stickman_prop_collision`), the rule-builder UI state machine, and rule auto-cleanup. | +| `res://scripts/stickman_rig.gd` | `arrived` gained a `target: Vector2` payload; new `enqueue_reactive(actions)` appends reactive actions without replaying the queue. | +| `res://scripts/prop_block.gd` | NEW `signal collided(other: Node)` (physics-based, prop-vs-prop). | +| `res://scripts/stage_director_visuals.gd` | Extended with rule visualization + `set_rules()` / `hit_test_rule()` / `hit_test_waypoint()`. | +| `res://scripts/stage_spawner.gd` | New `"area"` palette registry entry (label **"Area"**); duck-typed `get_area_rect` AABB branch in `get_world_aabb`. | +| `res://scripts/stage_selection.gd` | Same duck-typed `get_area_rect` AABB branch for selecting/clicking trigger areas. | + +**Trigger types:** + +| Trigger type | Target | Fires when | +|---|---|---| +| `arrived_at_waypoint` | Stickman | The stickman completes a `walk_to` (the rig's `arrived` signal). | +| `action_finished` | Stickman | A queue action completes (the rig's `action_finished` signal). | +| `speech_finished` | Stickman | A `speak` bubble auto-hides (the rig's `speech_finished` signal). | +| `entered_area` | Trigger area | A movable (stickman/prop) enters the area's `get_area_rect()` (geometric, `_update_area_entry`). | +| `collided` | Stickman/Prop | A stickman's feet point enters another object's AABB (geometric, `_update_stickman_prop_collision`) **or** a prop physically collides with another prop (`PropBlock.collided` physics signal). | + +**Action types** are the Phase 3a set, reused verbatim: `walk_to`, `speak`, `wait`, `ragdoll`, `recover`. Actions always target a **stickman**. + +**Rule-building workflow (Edit mode):** + +1. Press the **Direct** toggle button (mutually exclusive with palette placement) and click a stickman. +2. The Direct action popup now has a **"⚡ When..."** item (in addition to Walk To / Speak / Wait / Ragdoll / Recover). +3. Choose **"⚡ When..."** → a trigger sub-menu popup (arrived / action finished / speech finished / entered area / collided). +4. Pick the **trigger target** (click a stickman, waypoint, trigger area, or prop depending on trigger type). +5. Choose an **action** from a rule-action popup, then optionally its **target/position** (e.g. click a waypoint for `walk_to`). +6. A popup offers **"Add another action"** (repeat the action step) or **"Done"** to commit the rule. +7. **Esc** is the highest-priority cancel at any step. The rule-builder is a state machine (`RuleStep` enum: `IDLE`, `SELECT_TRIGGER`, `TRIGGER_TARGET`, `SELECT_ACTION`, `ACTION_TARGET`, `ACTION_POSITION`, `PARAMS`) with status-bar hints and toast messages. + +**Rule visualization (Edit only):** `StageDirectorVisuals` draws each rule as a **dashed white connector line** from the trigger object to the target, a **green ⚡ trigger badge**, an **orange → action badge**, a dark label with `rule_summary()` text, and a **✕ delete icon**. Clicking the **rule label** reopens the rule for **consequence-only editing** (replaces the rule, keeping the same `id`); clicking the **✕** deletes the rule. Rules are auto-cleaned (`_cleanup_rules_for_nodes`) when any referenced object is deleted. + +**Trigger area placement:** the spawn palette gains an **"Area"** entry (from the `"area"` registry id). Placement works like any other palette item (translucent ghost, grid snap, click to place). Trigger areas are selectable/movable like other objects via the duck-typed `get_area_rect` AABB branch in `stage_selection.gd` / `stage_spawner.gd`. + +**`enqueue_reactive` semantics:** `StickmanRig.enqueue_reactive(actions: Array[Dictionary]) -> void` appends reactive actions to the rig's action queue. If the runner is `IDLE` it **resumes at the first newly-appended action** — the already-consumed prefix of the queue is not replayed. Sequential Phase 3a queues are untouched; reactive actions are a cross-object addition. + +**Prop collision detection (two mechanisms):** + +- **Geometric (stage-driven):** `_update_stickman_prop_collision` tests whether a stickman's **feet point** lies inside another object's world AABB (and vice-versa), edge-triggered so `collided` fires once per entry. +- **Physics (self-driven):** `PropBlock` enables `contact_monitor = true`, `max_contacts_reported = 8`, and connects the guarded `body_entered` signal → `_on_body_entered` → emits `collided(other)`, so **prop-vs-prop** collisions are detected by the physics engine rather than geometry. + +Edge-triggered dictionaries (which events already fired) are reset on Play/Edit mode entry so a fresh simulation run re-arms every rule. + +**Persistence scope:** rules are **in-memory only** — they persist across Play/Edit mode toggles (the stage keeps `_event_rules` alive) but are **not** saved to disk. Disk save is deferred to a future phase. + +**Deferred (Phase 5 and beyond):** `explode_prop` / `spawn_prop` action types, rule **conditions** and AND/OR combinators, rule **variables**, and **disk save** of rules are explicitly out of scope for Phase 4. + ## File format (`.stk`) Files are UTF-8 JSON, pretty-printed with tab indentation. The format is versioned and designed to remain **backward/forward compatible** — new fields can be added without breaking older files. diff --git a/docs/tech_debt_and_optimizations.md b/docs/tech_debt_and_optimizations.md index b15b409..badae4e 100644 --- a/docs/tech_debt_and_optimizations.md +++ b/docs/tech_debt_and_optimizations.md @@ -23,6 +23,7 @@ This document tracks known technical debt, optimization opportunities, and minor | 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) | +| 14 | **Phase 4 event engine is O(rigs×props + areas×movables) per physics frame** — `sandbox_stage.gd` `_update_area_entry()` / `_update_stickman_prop_collision()` run all-pairs geometric tests every physics frame in PLAY. | Low | Open | Fine at sandbox scale, but degrades quadratically with dozens of dynamic objects. Future: add a spatial hash / broadphase grid keyed by world cell to cull candidate pairs before the AABB/feet-point tests. (2026‑08‑30) | --- @@ -56,6 +57,7 @@ This document tracks known technical debt, optimization opportunities, and minor | 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. | +| 2026-08-30 | Phase 4 (Triggers & Event System) implemented: `scripts/trigger_area.gd` (NEW placeable sensor), `sandbox_stage.gd` rule system (`_event_rules`, geometric event engine `_update_area_entry` / `_update_stickman_prop_collision`, rule-builder UI state machine, `_cleanup_rules_for_nodes`), `stickman_rig.gd` (`arrived` gains a `target` payload; new `enqueue_reactive`), `prop_block.gd` (`collided` physics signal), `stage_director_visuals.gd` rule visualization, `stage_spawner.gd` / `stage_selection.gd` `"area"` palette + duck-typed `get_area_rect`. Logged #14 (all-pairs event engine scales O(rigs×props + areas×movables); spatial hash suggested). | --- diff --git a/plans/PHASE_4_TRIGGER_EVENTS.md b/plans/PHASE_4_TRIGGER_EVENTS.md new file mode 100644 index 0000000..401d4fb --- /dev/null +++ b/plans/PHASE_4_TRIGGER_EVENTS.md @@ -0,0 +1,326 @@ +# Phase 4: Triggers & Event System — Implementation Plan + +## 1. Overview + +Phase 4 adds **reactive storytelling** to the Sandbox Stage. While Phase 3a gave directors _sequential_ control (actions in a fixed order), Phase 4 gives them _reactive_ control — **"When X happens, do Y."** + +This unlocks: + +- **Cross-stickman communication** (Stickman A arrives → Stickman B speaks) +- **Environmental reactions** (Stickman reaches crate → crate explodes) +- **Branching narratives** (Arrive at different waypoints → different reactions) + +**Crucially:** Phase 4 **does not** replace or duplicate the Phase 3a Action Queue. Sequential actions for a single stickman (Walk → Wait → Speak → Walk) remain unchanged. Phase 4 adds **cross-object reactivity**. + +--- + +## 2. Core Distinction + +| Feature | Phase 3a (Already Works) | Phase 4 (New) | +| ----------------------------------------------- | ------------------------ | -------------- | +| Walk → Wait → Speak → Walk | ✅ Action Queue | ✅ (unchanged) | +| Waypoint → next action | ✅ Action Runner | ✅ (unchanged) | +| **Stickman A arrives → Stickman B speaks** | ❌ | ✅ NEW | +| **Stickman collides with prop → prop explodes** | ❌ | ✅ NEW | +| **Stickman enters area → all stickmen react** | ❌ | ✅ NEW | +| **Multiple reactions per event** | ❌ | ✅ NEW | + +--- + +## 3. The User's Mental Model + +The director should think of events as **"When → Then"** rules that exist _alongside_ the action queues. + +- **Action Queue:** "Stickman A walks to the crate, waits, then speaks." +- **Event Rule:** "When Stickman A reaches the crate, Stickman B says 'Welcome!'" + +The event rule is **not** part of Stickman A's queue. It's a separate rule that watches Stickman A and reacts independently. + +--- + +## 4. Milestone Breakdown + +| # | Milestone | Description | +| --- | --------------------------- | ------------------------------------------------------- | +| 4.1 | **Event Data Model** | Define rule structure and storage. | +| 4.2 | **Trigger System** | Detect and emit events from stickmen, props, and areas. | +| 4.3 | **Event Rule UI** | Visual rule builder (When → Then workflow). | +| 4.4 | **Trigger Areas** | Placeable `Area2D` sensors. | +| 4.5 | **Prop Interaction Events** | Prop collisions emit events. | +| 4.6 | **Chain Reactions** | Multiple actions per rule, executed in sequence. | +| 4.7 | **Integration & Polish** | Full end-to-end testing, bug fixes, UI polish. | + +--- + +## 5. Milestone 4.3: Event Rule UI + +### 5.1. The "When..." Option + +``` +The existing action popup is extended with a new section: +┌─────────────────────────────────────────────┐ +│ 🚶 Walk To │ +│ 💬 Speak │ +│ ⏳ Wait │ +│ 💥 Ragdoll │ +│ 🔄 Recover │ +│ ───────────────────────────────────────── │ +│ ⚡ When... (opens trigger sub-menu) │ +└─────────────────────────────────────────────┘ +``` + +**Design note:** The "When..." option is visually separated (line divider) to signal it creates a _rule_, not a direct action. + +### 5.2. Trigger Sub-Menu + +``` +When "When..." is clicked, the popup expands/replaces with trigger types: +┌─────────────────────────────────────────────┐ +│ 📍 Arrives at a waypoint │ +│ ✅ Completes any action │ +│ 💬 Finishes speaking │ +│ 🎯 Enters trigger area │ +│ 💥 Collides with something │ +│ ⬅ Back to actions │ +└─────────────────────────────────────────────┘ +``` + +Each trigger type corresponds to a signal emitted by stickmen or the stage. + +### 5.3. The Rule Building Flow (6 Stages) + +``` +┌──────────────┐ +│ STAGE 1 │ → User clicks stickman → popup opens +│ IDLE │ +└──────┬───────┘ +│ Click "When..." +▼ +┌──────────────┐ +│ STAGE 2 │ → User selects trigger type +│ SELECT_TRIGGER│ +└──────┬───────┘ +│ (If trigger needs a target: waypoint, area, prop) +▼ +┌──────────────┐ +│ STAGE 3 │ → User clicks the specific target +│ TARGET_ENTRY │ (e.g., a waypoint dot on the stage) +└──────┬───────┘ +│ +▼ +┌──────────────┐ +│ STAGE 4 │ → User selects the action (Speak, Walk, etc.) +│ SELECT_ACTION│ +└──────┬───────┘ +│ (If action needs a target: a stickman or prop) +▼ +┌──────────────┐ +│ STAGE 5 │ → User clicks the target stickman/prop +│ TARGET_ENTRY2│ +└──────┬───────┘ +│ (If action needs params: text, duration, etc.) +▼ +┌──────────────┐ +│ STAGE 6 │ → User fills in params +│ PARAMS │ +└──────┬───────┘ +│ +▼ +┌──────────────┐ +│ STAGE 7 │ → Rule is stored. Visuals update. +│ DONE │ +└──────────────┘ + +``` + +**Escape** at any stage cancels rule creation and returns to IDLE. + +--- + +## 6. Complete UI Walkthrough: "A Reaches Waypoint → B Speaks" + +### Step 0: Prerequisites (Edit Mode) + +- Stickman A and Stickman B are placed on the stage. +- Stickman A already has a **"Walk To"** action queued (with a visible waypoint marker on the stage), OR the user is about to create one. + +### Step 1: Create Stickman A's Walk (If Not Done) + +| # | User Action | System Response | +| --- | -------------------------------------------------- | ------------------------------------------------------------------------ | +| 1 | Click the **"Direct"** tool button in the palette. | Cursor changes to crosshair. | +| 2 | Click **Stickman A** on the stage. | Action popup appears. | +| 3 | Click **"🚶 Walk To"** in the popup. | Popup closes. Stage enters target placement mode. | +| 4 | Click a spot on the stage. | Blue waypoint dot appears. `walk_to` action added to Stickman A's queue. | + +### Step 2: Create the Reactive Event Rule + +| # | User Action | System Response | +| --- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| 5 | Click the **"Direct"** tool again (if it was deselected). | Cursor changes to crosshair. | +| 6 | Click **Stickman A** on the stage. | Action popup appears with new "When..." option. | +| 7 | Click **"⚡ When..."** | Trigger sub-menu opens. | +| 8 | Click **"📍 Arrives at a waypoint"** | Popup closes. Cursor changes to target selector. Status bar: _"Click the waypoint you want to trigger on."_ | +| 9 | Click **the blue waypoint dot** on the stage. | Waypoint glows green briefly. Popup reappears with trigger confirmed. | +| 10 | Click **"💬 Speak"** | Popup closes. Cursor changes to target selector. Status bar: _"Click the stickman who will speak."_ | +| 11 | Click **Stickman B** on the stage. | Stickman B glows yellow. Text dialog appears. | +| 12 | Type **"Hello there!"** and click **[OK]** | Dialog closes. Toast: _"Rule created!"_ | + +### Step 3: Visual Feedback (Edit Mode) + +| Element | Appearance | Description | +| ---------------- | ------------------------------------------- | --------------------------------------- | +| **Dashed line** | White dashed line | Connects waypoint to Stickman B. | +| **Label** | _"When arrives → Speak 'Hello there!'"_ | Centered on the line. | +| **Color coding** | Green waypoint + orange badge on Stickman B | Shows trigger source and action target. | +| **Rule badge** | ⚡ icon on the label | Hover shows full rule details. | +| **Delete icon** | ✕ on the label | Click to remove rule. | + +### Step 4: Test in Play Mode + +| # | User Action | System Response | +| --- | ------------------------------------- | ----------------------------------------------------------- | +| 13 | Click **"Play"** button. | All queues start executing. | +| 14 | Stickman A walks to waypoint. | Normal Phase 3a behavior. | +| 15 | When Stickman A arrives, event fires. | Stickman B says **"Hello there!"** (speech bubble appears). | + +--- + +## 7. Visual Feedback (Edit Mode Only) + +### 7.1. Rule Visualization Elements + +| Element | Color/Style | Position | +| ------------------- | ----------------------------- | ---------------------- | +| **Trigger badge** | Green, small, "⚡" | Near source object. | +| **Action badge** | Orange, small, "→" | Near target object. | +| **Connection line** | Dashed white, alpha 0.6 | From source to target. | +| **Label** | White text on dark background | Centered on line. | +| **Waypoint glow** | Green (if trigger source) | On the waypoint dot. | + +### 7.2. Rule Interaction + +| Action | Behavior | +| --------------------- | ----------------------------------------- | +| **Click rule label** | Opens edit popup for that rule. | +| **Click delete icon** | Removes the rule (confirmation optional). | +| **Hover rule label** | Shows tooltip with full rule details. | + +### 7.3. What the User Sees on the Stage + +``` +Stickman A Stickman B +┌──────┐ ┌──────┐ +│ 🔵 │ │ 🔵 │ +│ (A) │ │ (B) │ +└──┬───┘ └──┬───┘ +│ │ +│ ════════════╗ │ +│ (When arrives) │ │ +│ ════════════╝ │ +│ ── ── ── ── ── ── ── ── →│ +│ (Then Speak "Got it!") │ +│ │ +┌──┴──────────────────────────────────┐│ +│ 📍 Waypoint 3 ││ +└──────────────────────────────────────┘ + +``` + +--- + +## 8. Event Data Model + +### 8.1. Rule Structure + +Rules are stored in the stage's event registry and have three parts: + +**Trigger** — The "When" part. + +| Trigger Type | Source | Target | Description | +| --------------------- | ------------- | ------------ | ------------------------------------ | +| `arrived_at_waypoint` | Stickman | Waypoint | Stickman completes a walk. | +| `action_finished` | Stickman | Action type | Stickman finishes any action. | +| `speech_finished` | Stickman | (none) | Stickman finishes speaking. | +| `entered_area` | Stickman/Prop | Trigger Area | Object enters a trigger area. | +| `collided` | Stickman/Prop | Object | Object collides with another object. | + +**Action** — The "Then" part. + +| Action Type | Target | Params | +| -------------- | -------- | ----------------------------------- | +| `walk_to` | Stickman | `target` (Vector2) | +| `speak` | Stickman | `text` (String), `duration` (float) | +| `wait` | Stickman | `duration` (float) | +| `ragdoll` | Stickman | (none) | +| `recover` | Stickman | (none) | +| `explode_prop` | Prop | (none) — future | +| `spawn_prop` | Stage | `type` (String) — future | + +### 8.2. Rule Storage + +Rules are stored in `SandboxStage._event_rules` and persist across mode toggles (Play ↔ Edit). They are **not** saved to disk in Phase 4 (deferred to Phase 5). + +### 8.3. Rule Evaluation + +Rules are evaluated **in order** when an event occurs. If a rule's trigger matches the event, its actions are executed. + +--- + +## 9. What's NOT in Phase 4 + +| Feature | Why Excluded | +| ----------------------------------------------------------- | -------------------------------------------------- | +| "When Stickman A arrives → Stickman A speaks" | This is just a sequential queue action (Phase 3a). | +| "When Stickman A finishes walking → Stickman A walks again" | Same as above. | +| Conditions (AND/OR logic) | Too complex for Phase 4; deferred to future. | +| Variables / counters | Deferred to future. | +| Save/Load rules to disk | Phase 5. | +| Edit/Delete actions | Phase 3c. | +| Stickman/Prop selector grids | Phase 3b. | + +--- + +## 10. Acceptance Criteria + +### 10.1. Cross-Stickman Events + +- [ ] **"When..." option appears** in the action popup. +- [ ] **Trigger sub-menu opens** when "When..." is clicked. +- [ ] **Waypoint trigger works:** A arrives → B speaks. +- [ ] **Action completion trigger works:** A finishes speaking → B walks. +- [ ] **Speech completion trigger works:** A finishes speaking → B speaks. +- [ ] **Multiple actions per rule work:** Trigger → B speaks AND C walks. + +### 10.2. Cross-Object Events + +- [ ] **Area trigger works:** A enters area → B speaks. +- [ ] **Prop collision trigger works:** A hits crate → crate explodes. +- [ ] **Prop reaction works:** Trigger → prop explodes (disappears + particles). + +### 10.3. UI & Visuals + +- [ ] **Rule label shows summary:** "When arrives → Speak" text appears. +- [ ] **Delete icon works:** Click ✕ → rule removed. +- [ ] **Clicking rule label opens edit popup:** Click label → popup re-opens. +- [ ] **Rules persist across mode toggles:** Create rule → Play → Edit → rule still there. +- [ ] **Rules are cleaned up** when source/target objects are deleted. + +### 10.4. Backward Compatibility + +- [ ] **Sequential actions still work:** Walk → Wait → Speak → Walk works. +- [ ] **Existing queues unchanged:** Any Phase 3a scene loads and runs. + +--- + +## 11. File Changes Summary + +| File | Changes | +| ----------------------------------- | ------------------------------------------------------------- | +| `scripts/sandbox_stage.gd` | Add `_event_rules`, event evaluation, rule UI state machine. | +| `scripts/stickman_rig.gd` | Emit `arrived`, `action_finished`, `speech_finished` signals. | +| `scripts/stage_director_visuals.gd` | Draw rule lines, labels, and badges. | +| `scripts/trigger_area.gd` | NEW: Placeable `Area2D` sensor. | +| `scripts/prop_block.gd` | Emit `collided` signal. | + +--- diff --git a/plans/PHASE_4b_POLISH.md b/plans/PHASE_4b_POLISH.md new file mode 100644 index 0000000..005891a --- /dev/null +++ b/plans/PHASE_4b_POLISH.md @@ -0,0 +1,7 @@ +# Phase 4: Triggers & Event System — Implementation Plan + +## 1. Overview + +We have a good start with feature implementation, we want to polish some of the features and fix bugs before the next phase. We don't want too much tech debt + +## 2. UI diff --git a/scripts/prop_block.gd b/scripts/prop_block.gd index c08fd28..b635be0 100644 --- a/scripts/prop_block.gd +++ b/scripts/prop_block.gd @@ -88,6 +88,14 @@ var _collision_polygon: CollisionPolygon2D var _collision_shape: CollisionShape2D var _circle_shape: CircleShape2D +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- + +## Emitted when this prop's body makes contact with another physics body. +## `other` is the body that entered contact (Phase 4 trigger source). +signal collided(other: Node) + # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- @@ -96,6 +104,15 @@ func _ready() -> void: _ensure_children() _apply_shape() _apply_style() + # Enable contact reporting so the body_entered signal fires (Phase 4). + contact_monitor = true + max_contacts_reported = 8 + if not body_entered.is_connected(_on_body_entered): + body_entered.connect(_on_body_entered) + + +func _on_body_entered(body: Node) -> void: + collided.emit(body) # --------------------------------------------------------------------------- # Internal build / apply diff --git a/scripts/sandbox_stage.gd b/scripts/sandbox_stage.gd index 0248adf..af69295 100644 --- a/scripts/sandbox_stage.gd +++ b/scripts/sandbox_stage.gd @@ -26,6 +26,9 @@ const STAGE_DIRECTOR_VISUALS := preload("res://scripts/stage_director_visuals.gd enum StageMode { EDIT, PLAY } +## Phase 4 rule-builder state machine steps. +enum RuleStep { IDLE, SELECT_TRIGGER, TRIGGER_TARGET, SELECT_ACTION, ACTION_TARGET, ACTION_POSITION, PARAMS } + # --------------------------------------------------------------------------- # Signals # --------------------------------------------------------------------------- @@ -56,6 +59,28 @@ const ACT_WAIT := 2 const ACT_RAGDOLL := 3 const ACT_RECOVER := 4 +## Phase 4 rule-builder item ids. ACT_WHEN appends to the action popup; the +## TRIG_* ids drive the trigger sub-menu. +const ACT_WHEN := 5 +const TRIG_ARRIVED := 0 +const TRIG_ACTION_FINISHED := 1 +const TRIG_SPEECH_FINISHED := 2 +const TRIG_ENTERED_AREA := 3 +const TRIG_COLLIDED := 4 +const TRIG_BACK := 5 + +## Phase 4 "add another / done" popup item ids. +const RULE_MORE_ADD := 0 +const RULE_MORE_DONE := 1 + +## Phase 4 "done" item id for the rule-action popup (edit mode only). Distinct +## from ACT_WALK..ACT_RECOVER (0..4) so it never collides with an action id. +const RULE_ACTION_DONE := 6 + +## Max distance (px) between an arrival event position and a rule's waypoint for +## the arrived_at_waypoint trigger to match. +const WAYPOINT_MATCH_EPSILON := 24.0 + ## Debug gate for the Phase 3a stage trace. Ship OFF. const DEBUG_STAGE := false @@ -139,6 +164,35 @@ var _director_visuals = null # StageDirectorVisuals (preloaded) var _nav_region: NavigationRegion2D = null var _nav_dirty: bool = true +# --------------------------------------------------------------------------- +# Rule / event system state (Phase 4) +# --------------------------------------------------------------------------- + +## Stored "When X -> do Y" rules. Persist across mode toggles; NOT saved to disk. +var _event_rules: Array[Dictionary] = [] +var _next_rule_id: int = 0 + +## Rule-builder state machine. +var _rule_step: RuleStep = RuleStep.IDLE +var _rule_builder: Dictionary = {} +var _rule_context_rig: StickmanRig = null +var _rule_hint: String = "" +var _rule_editing_id: int = -1 + +## Rule-builder popups (built in _build_ui). +var _trigger_popup: PopupMenu = null +var _rule_action_popup: PopupMenu = null +var _rule_more_popup: PopupMenu = null + +## Edge-trigger bookkeeping for the geometric engine, keyed ":" +## and ":". +var _area_overlap: Dictionary = {} +var _collision_pairs: Dictionary = {} + +## Lightweight toast text + countdown (cleared in _process on expiry). +var _toast_text: String = "" +var _toast_timer: float = 0.0 + # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- @@ -162,18 +216,26 @@ func _ready() -> void: _refresh_status() -func _process(_delta: float) -> void: +func _process(delta: float) -> void: if _nav_dirty: _nav_dirty = false _rebake_navigation() if _ghost != null and is_instance_valid(_ghost) and current_mode == StageMode.EDIT: _update_ghost_position() + if _toast_timer > 0.0: + _toast_timer -= delta + if _toast_timer <= 0.0: + _toast_text = "" + _refresh_status() func _physics_process(_delta: float) -> void: if _restore_frames_left > 0: _restore_frames_left -= 1 _restore_authored_state() + if current_mode == StageMode.PLAY: + _update_area_entry() + _update_stickman_prop_collision() # --------------------------------------------------------------------------- # Input @@ -205,7 +267,9 @@ func _unhandled_key_input(event: InputEvent) -> void: if current_mode == StageMode.EDIT: delete_selected() KEY_ESCAPE: - if _pending_walk_target: + if _rule_step != RuleStep.IDLE: + _cancel_rule_build() + elif _pending_walk_target: _pending_walk_target = false _context_rig = null _refresh_status() @@ -237,6 +301,19 @@ func _handle_world_click(mb: InputEventMouseButton) -> void: if current_mode != StageMode.EDIT: return var world_pos := _camera.get_global_mouse_position() + # Rule-builder click routing is highest priority (Phase 4). + if _rule_step != RuleStep.IDLE: + if mb.pressed: + _handle_rule_click(world_pos) + return + # Rule label / delete icon hit-testing, before gizmo/placement/selection. + var rule_hit: Dictionary = _director_visuals.hit_test_rule(world_pos) + if not rule_hit.is_empty() and mb.pressed: + if rule_hit["part"] == "delete": + _delete_rule(int(rule_hit["id"])) + else: + _begin_edit_rule(int(rule_hit["id"])) + return if _direct_mode: if mb.pressed: _handle_direct_click(world_pos) @@ -311,6 +388,9 @@ func _enter_edit_mode() -> void: _director_visuals.set_enabled(true) _apply_grid_settings() _set_build_controls_visible(true) + # Clear edge-trigger state so PLAY overlaps do not leak stale results. + _area_overlap.clear() + _collision_pairs.clear() func _enter_play_mode() -> void: @@ -327,6 +407,9 @@ func _enter_play_mode() -> void: _director_visuals.set_enabled(false) _apply_grid_settings() _set_build_controls_visible(false) + # Clear edge-trigger state so PLAY overlaps start from a clean slate. + _area_overlap.clear() + _collision_pairs.clear() for node: Node2D in _world_children_selectable(): if node is RigidBody2D: (node as RigidBody2D).freeze = false @@ -395,7 +478,13 @@ func _place_at(world_pos: Vector2) -> void: if node is STICKMAN_RIG: var rig := node as STICKMAN_RIG rig.queue_changed.connect(_director_visuals.mark_dirty) + rig.arrived.connect(_on_rig_arrived.bind(rig)) + rig.action_finished.connect(_on_rig_action_finished.bind(rig)) + rig.speech_finished.connect(_on_rig_speech_finished.bind(rig)) _director_visuals.mark_dirty() + if node is PropBlock: + var prop := node as PropBlock + prop.collided.connect(_on_prop_collided.bind(prop)) if node is TerrainBlock: _nav_dirty = true object_placed.emit(node) @@ -503,6 +592,7 @@ func delete_selected() -> void: nav_changed = true _clear_object_state(node) node.queue_free() + _cleanup_rules_for_nodes(selected) _selection.clear_selection() if nav_changed: _nav_dirty = true @@ -527,9 +617,14 @@ func _refresh_status() -> void: sel_text = String(sel[0].name) else: sel_text = "%d objects" % sel.size() - _status_label.text = "Mode: %s | Objects: %d | Selected: %s" % [mode_text, count, sel_text] + var 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)" + text += " | Click stage for walk target (Esc to cancel)" + if _rule_step != RuleStep.IDLE and not _rule_hint.is_empty(): + text += " | " + _rule_hint + if not _toast_text.is_empty(): + text = _toast_text + " | " + text + _status_label.text = text 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" @@ -604,9 +699,37 @@ func _build_ui() -> void: _action_popup.add_item("⏳ Wait", ACT_WAIT) _action_popup.add_item("💥 Ragdoll", ACT_RAGDOLL) _action_popup.add_item("🔄 Recover", ACT_RECOVER) + _action_popup.add_separator() + _action_popup.add_item("⚡ When...", ACT_WHEN) _action_popup.id_pressed.connect(_on_action_popup_id_pressed) ui.add_child(_action_popup) + _trigger_popup = PopupMenu.new() + _trigger_popup.add_item("📍 Arrives at a waypoint", TRIG_ARRIVED) + _trigger_popup.add_item("✅ Completes any action", TRIG_ACTION_FINISHED) + _trigger_popup.add_item("💬 Finishes speaking", TRIG_SPEECH_FINISHED) + _trigger_popup.add_item("🎯 Enters trigger area", TRIG_ENTERED_AREA) + _trigger_popup.add_item("💥 Collides with something", TRIG_COLLIDED) + _trigger_popup.add_separator() + _trigger_popup.add_item("⬅ Back to actions", TRIG_BACK) + _trigger_popup.id_pressed.connect(_on_trigger_popup_id_pressed) + ui.add_child(_trigger_popup) + + _rule_action_popup = PopupMenu.new() + _rule_action_popup.add_item("🚶 Walk To", ACT_WALK) + _rule_action_popup.add_item("💬 Speak", ACT_SPEAK) + _rule_action_popup.add_item("⏳ Wait", ACT_WAIT) + _rule_action_popup.add_item("💥 Ragdoll", ACT_RAGDOLL) + _rule_action_popup.add_item("🔄 Recover", ACT_RECOVER) + _rule_action_popup.id_pressed.connect(_on_rule_action_popup_id_pressed) + ui.add_child(_rule_action_popup) + + _rule_more_popup = PopupMenu.new() + _rule_more_popup.add_item("➕ Add another action", RULE_MORE_ADD) + _rule_more_popup.add_item("✅ Done", RULE_MORE_DONE) + _rule_more_popup.id_pressed.connect(_on_rule_more_id_pressed) + ui.add_child(_rule_more_popup) + _speak_dialog = AcceptDialog.new() _speak_dialog.title = "Speak" _speak_dialog.confirmed.connect(_on_speak_confirmed) @@ -802,19 +925,514 @@ func _on_action_popup_id_pressed(id: int) -> void: _context_rig.queue_action({ "type": "ragdoll" }) ACT_RECOVER: _context_rig.queue_action({ "type": "recover" }) + ACT_WHEN: + _rule_builder = { + "trigger": { "source": _context_rig.get_instance_id(), "target": -1, "params": {} }, + "actions": [], + } + _rule_editing_id = -1 + _rule_context_rig = _context_rig + _rule_step = RuleStep.SELECT_TRIGGER + _trigger_popup.popup(_mouse_popup_rect()) func _on_speak_confirmed() -> void: + if _rule_step == RuleStep.PARAMS: + _set_rule_action_params({ "text": _speak_edit.text, "duration": 2.0 }) + _open_rule_more_popup() + return 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 _rule_step == RuleStep.PARAMS: + _set_rule_action_params({ "duration": _wait_spin.value }) + _open_rule_more_popup() + return if _context_rig == null or not is_instance_valid(_context_rig): return _context_rig.queue_action({ "type": "wait", "duration": _wait_spin.value }) +# --------------------------------------------------------------------------- +# Rule / event system (Phase 4) +# --------------------------------------------------------------------------- + +## Popup rect at the current mouse position (used by every rule popup). +func _mouse_popup_rect() -> Rect2i: + var mouse := get_viewport().get_mouse_position() + return Rect2i(Vector2i(mouse), Vector2i.ZERO) + + +func _open_rule_action_popup() -> void: + _sync_rule_action_done_item() + _rule_action_popup.popup(_mouse_popup_rect()) + + +## Shows the "✅ Done" item in the rule-action popup only while editing an +## existing rule (otherwise there is no way to save without adding an action). +func _sync_rule_action_done_item() -> void: + var idx: int = _rule_action_popup.get_item_index(RULE_ACTION_DONE) + var editing: bool = _rule_editing_id >= 0 + if editing and idx < 0: + _rule_action_popup.add_item("✅ Done", RULE_ACTION_DONE) + elif not editing and idx >= 0: + _rule_action_popup.remove_item(idx) + + +func _open_rule_more_popup() -> void: + _rule_more_popup.popup(_mouse_popup_rect()) + + +func _on_trigger_popup_id_pressed(id: int) -> void: + if id == TRIG_BACK: + _cancel_rule_build() + return + var trigger: Dictionary = _rule_builder.get("trigger", {}) + trigger["type"] = _trigger_type_string(id) + _rule_builder["trigger"] = trigger + if id == TRIG_ARRIVED or id == TRIG_ENTERED_AREA or id == TRIG_COLLIDED: + _rule_step = RuleStep.TRIGGER_TARGET + match id: + TRIG_ARRIVED: + _rule_hint = "Click the waypoint to trigger on (Esc to cancel)" + TRIG_ENTERED_AREA: + _rule_hint = "Click the trigger area to watch (Esc to cancel)" + TRIG_COLLIDED: + _rule_hint = "Click the prop that will be collided with (Esc to cancel)" + _refresh_status() + else: + _rule_step = RuleStep.SELECT_ACTION + _open_rule_action_popup() + + +func _on_rule_action_popup_id_pressed(id: int) -> void: + if id == RULE_ACTION_DONE: + _finalize_rule() + return + var action := { "type": _action_type_string(id), "target": -1, "params": {} } + var actions: Array = _rule_builder.get("actions", []) + actions.append(action) + _rule_builder["actions"] = actions + _rule_step = RuleStep.ACTION_TARGET + _rule_hint = "Click the stickman who will %s (Esc to cancel)" % _action_verb(id) + _refresh_status() + + +func _on_rule_more_id_pressed(id: int) -> void: + match id: + RULE_MORE_ADD: + _rule_step = RuleStep.SELECT_ACTION + _open_rule_action_popup() + RULE_MORE_DONE: + _finalize_rule() + + +func _trigger_type_string(id: int) -> String: + match id: + TRIG_ARRIVED: + return "arrived_at_waypoint" + TRIG_ACTION_FINISHED: + return "action_finished" + TRIG_SPEECH_FINISHED: + return "speech_finished" + TRIG_ENTERED_AREA: + return "entered_area" + TRIG_COLLIDED: + return "collided" + _: + return "" + + +func _action_type_string(id: int) -> String: + match id: + ACT_WALK: + return "walk_to" + ACT_SPEAK: + return "speak" + ACT_WAIT: + return "wait" + ACT_RAGDOLL: + return "ragdoll" + ACT_RECOVER: + return "recover" + _: + return "" + + +func _action_verb(id: int) -> String: + match id: + ACT_WALK: + return "walk" + ACT_SPEAK: + return "speak" + ACT_WAIT: + return "wait" + ACT_RAGDOLL: + return "ragdoll" + ACT_RECOVER: + return "recover" + _: + return "act" + + +func _handle_rule_click(world_pos: Vector2) -> void: + match _rule_step: + RuleStep.TRIGGER_TARGET: + _handle_trigger_target_click(world_pos) + RuleStep.ACTION_TARGET: + _handle_action_target_click(world_pos) + RuleStep.ACTION_POSITION: + _handle_action_position_click(world_pos) + + +func _handle_trigger_target_click(world_pos: Vector2) -> void: + var trigger: Dictionary = _rule_builder.get("trigger", {}) + match String(trigger.get("type", "")): + "arrived_at_waypoint": + var wp: Vector2 = _director_visuals.hit_test_waypoint(world_pos) + if wp.is_finite(): + trigger["params"] = { "waypoint_pos": wp } + trigger["target"] = -1 + _rule_builder["trigger"] = trigger + _rule_step = RuleStep.SELECT_ACTION + _open_rule_action_popup() + else: + _rule_hint = "Click a waypoint dot (Esc to cancel)" + _refresh_status() + "entered_area": + var hit := _selection.hit_test(world_pos) + if hit is TriggerArea: + trigger["target"] = hit.get_instance_id() + _rule_builder["trigger"] = trigger + _rule_step = RuleStep.SELECT_ACTION + _open_rule_action_popup() + else: + _rule_hint = "Click a trigger area (Esc to cancel)" + _refresh_status() + "collided": + var hit2 := _selection.hit_test(world_pos) + if hit2 is PropBlock: + trigger["target"] = hit2.get_instance_id() + _rule_builder["trigger"] = trigger + _rule_step = RuleStep.SELECT_ACTION + _open_rule_action_popup() + else: + _rule_hint = "Click the prop that will be collided with (Esc to cancel)" + _refresh_status() + + +func _handle_action_target_click(world_pos: Vector2) -> void: + var hit := _selection.hit_test(world_pos) + if not (hit is STICKMAN_RIG): + _refresh_status() + return + var actions: Array = _rule_builder.get("actions", []) + if actions.is_empty(): + return + var action: Dictionary = actions[actions.size() - 1] + action["target"] = (hit as StickmanRig).get_instance_id() + actions[actions.size() - 1] = action + _rule_builder["actions"] = actions + match String(action.get("type", "")): + "walk_to": + _rule_step = RuleStep.ACTION_POSITION + _rule_hint = "Click where %s should walk (Esc to cancel)" % String(hit.name) + _refresh_status() + "speak": + _rule_step = RuleStep.PARAMS + _speak_edit.text = "" + _speak_dialog.popup_centered() + _speak_edit.grab_focus() + "wait": + _rule_step = RuleStep.PARAMS + _wait_spin.value = 1.0 + _wait_dialog.popup_centered() + _: + # ragdoll / recover need no params. + _open_rule_more_popup() + + +func _handle_action_position_click(world_pos: Vector2) -> void: + if _snap_enabled: + world_pos = _snap_to_grid(world_pos) + var actions: Array = _rule_builder.get("actions", []) + if actions.is_empty(): + return + var action: Dictionary = actions[actions.size() - 1] + var params: Dictionary = action.get("params", {}) + params["target"] = world_pos + action["params"] = params + actions[actions.size() - 1] = action + _rule_builder["actions"] = actions + _open_rule_more_popup() + + +## Writes dialog-confirmed params onto the last action in the builder. +func _set_rule_action_params(params: Dictionary) -> void: + var actions: Array = _rule_builder.get("actions", []) + if actions.is_empty(): + return + var action: Dictionary = actions[actions.size() - 1] + action["params"] = params + actions[actions.size() - 1] = action + _rule_builder["actions"] = actions + + +func _finalize_rule() -> void: + var rule: Dictionary = _rule_builder.duplicate(true) + if _rule_editing_id >= 0: + # Replace the existing rule in place (same index). + var idx := -1 + for i: int in _event_rules.size(): + if int(_event_rules[i].get("id", -1)) == _rule_editing_id: + idx = i + break + if idx >= 0: + rule["id"] = _rule_editing_id + _event_rules[idx] = rule + _show_toast("Rule updated!") + else: + rule["id"] = _next_rule_id + _next_rule_id += 1 + _event_rules.append(rule) + _show_toast("Rule created!") + else: + rule["id"] = _next_rule_id + _next_rule_id += 1 + _event_rules.append(rule) + _show_toast("Rule created!") + _director_visuals.set_rules(_event_rules) + _reset_rule_builder() + _refresh_status() + + +func _cancel_rule_build() -> void: + _reset_rule_builder() + if _trigger_popup != null: + _trigger_popup.hide() + if _rule_action_popup != null: + _rule_action_popup.hide() + if _rule_more_popup != null: + _rule_more_popup.hide() + _refresh_status() + + +func _reset_rule_builder() -> void: + _rule_step = RuleStep.IDLE + _rule_builder = {} + _rule_context_rig = null + _rule_hint = "" + _rule_editing_id = -1 + + +func _delete_rule(id: int) -> void: + var before := _event_rules.size() + _event_rules = _event_rules.filter(func(r): return int(r.get("id", -1)) != id) + if _event_rules.size() != before: + _director_visuals.set_rules(_event_rules) + _refresh_status() + + +## Re-enters the builder at SELECT_ACTION pre-populated from the stored rule +## (trigger edits are delete + rebuild, per the approved design). +func _begin_edit_rule(id: int) -> void: + for rule: Dictionary in _event_rules: + if int(rule.get("id", -1)) != id: + continue + var trigger: Dictionary = (rule.get("trigger", {}) as Dictionary).duplicate(true) + var actions: Array = [] + for a: Dictionary in rule.get("actions", []): + actions.append(a.duplicate(true)) + _rule_builder = { "trigger": trigger, "actions": actions } + _rule_editing_id = id + var source_id := int(trigger.get("source", -1)) + if source_id >= 0: + var src := instance_from_id(source_id) + if src is StickmanRig: + _rule_context_rig = src + _rule_step = RuleStep.SELECT_ACTION + _open_rule_action_popup() + return + + +## True when any of trigger.source / trigger.target / an action.target is in ids. +func _rule_references_any(rule: Dictionary, ids: Array[int]) -> bool: + var trigger: Dictionary = rule.get("trigger", {}) + if ids.has(int(trigger.get("source", -1))): + return true + if ids.has(int(trigger.get("target", -1))): + return true + for a: Dictionary in rule.get("actions", []): + if ids.has(int(a.get("target", -1))): + return true + return false + + +func _cleanup_rules_for_nodes(nodes: Array[Node2D]) -> void: + var ids: Array[int] = [] + for n: Node2D in nodes: + ids.append(n.get_instance_id()) + _event_rules = _event_rules.filter(func(r): return not _rule_references_any(r, ids)) + _director_visuals.set_rules(_event_rules) + + +# --------------------------------------------------------------------------- +# Event engine (Phase 4) +# --------------------------------------------------------------------------- + +func _on_rig_arrived(target: Vector2, rig: StickmanRig) -> void: + _handle_event({ "type": "arrived_at_waypoint", "source": rig, "target": null, "position": target, "action": {} }) + + +func _on_rig_action_finished(action: Dictionary, _index: int, rig: StickmanRig) -> void: + _handle_event({ "type": "action_finished", "source": rig, "target": null, "position": rig.global_position, "action": action }) + + +func _on_rig_speech_finished(rig: StickmanRig) -> void: + _handle_event({ "type": "speech_finished", "source": rig, "target": null, "position": rig.global_position, "action": {} }) + + +func _on_prop_collided(other: Node, prop: PropBlock) -> void: + _handle_event({ "type": "collided", "source": prop, "target": other, "position": prop.global_position, "action": {} }) + + +## Iterates every rule in order; every matching rule executes (no short-circuit). +func _handle_event(event: Dictionary) -> void: + for rule: Dictionary in _event_rules: + if _rule_matches(rule, event): + _execute_rule_actions(rule) + + +func _rule_matches(rule: Dictionary, event: Dictionary) -> bool: + var trigger: Dictionary = rule.get("trigger", {}) + var rule_type := String(trigger.get("type", "")) + if rule_type != String(event.get("type", "")): + return false + var source := event.get("source") as Node2D + var target := event.get("target") as Node2D + var source_id := int(trigger.get("source", -1)) + var target_id := int(trigger.get("target", -1)) + match rule_type: + "arrived_at_waypoint": + if not _ids_match(source_id, source): + return false + var params: Dictionary = trigger.get("params", {}) + var waypoint: Vector2 = params.get("waypoint_pos", Vector2.INF) + return (event.get("position", Vector2.INF) as Vector2).distance_to(waypoint) <= WAYPOINT_MATCH_EPSILON + "action_finished": + if not _ids_match(source_id, source): + return false + var trig_params: Dictionary = trigger.get("params", {}) + var want := String(trig_params.get("action_type", "")) + var action: Dictionary = event.get("action", {}) + return want.is_empty() or want == String(action.get("type", "")) + "speech_finished": + return _ids_match(source_id, source) + "entered_area": + return _ids_match(target_id, target) + "collided": + return _ids_match(source_id, source) and _ids_match(target_id, target) + _: + return false + + +func _ids_match(a: int, b: Node2D) -> bool: + return a == -1 or (b != null and is_instance_valid(b) and b.get_instance_id() == a) + + +func _execute_rule_actions(rule: Dictionary) -> void: + for a: Dictionary in rule.get("actions", []): + var rig := instance_from_id(int(a.get("target", -1))) as StickmanRig + if rig == null or not is_instance_valid(rig): + continue + rig.enqueue_reactive([_action_for_rig(a)]) + + +## Converts a rule action into the queue-action shape the runner understands. +func _action_for_rig(a: Dictionary) -> Dictionary: + var params: Dictionary = a.get("params", {}) + var out: Dictionary = { "type": String(a.get("type", "")) } + match String(a.get("type", "")): + "walk_to": + out["target"] = params.get("target", Vector2.ZERO) + "speak": + out["text"] = String(params.get("text", "")) + out["duration"] = float(params.get("duration", 2.0)) + "wait": + out["duration"] = float(params.get("duration", 0.0)) + return out + + +## Geometric overlap: TriggerArea children vs ANIMATED stickmen + unfrozen props. +## Edge-triggered on entry; emits an entered_area event per new overlap. +func _update_area_entry() -> void: + var areas: Array[TriggerArea] = [] + for child: Node in _world.get_children(): + if child is TriggerArea: + areas.append(child as TriggerArea) + if areas.is_empty(): + return + var movables: Array[Node2D] = [] + for node: Node2D in _world_children_selectable(): + if node is STICKMAN_RIG: + var rig := node as STICKMAN_RIG + if rig.state == StickmanRig.RigState.ANIMATED: + movables.append(rig) + elif node is PropBlock and not (node as PropBlock).freeze: + movables.append(node) + for area: TriggerArea in areas: + var area_rect := (area.global_transform * area.get_area_rect()).abs() + for node: Node2D in movables: + var point := _movable_point(node) + var key := "%d:%d" % [area.get_instance_id(), node.get_instance_id()] + var inside: bool = area_rect.has_point(point) + var was: bool = bool(_area_overlap.get(key, false)) + if inside and not was: + _area_overlap[key] = true + _handle_event({ "type": "entered_area", "source": node, "target": area, "position": point, "action": {} }) + elif not inside and was: + _area_overlap[key] = false + + +## Geometric stickman-vs-prop collision (ANIMATED rigs x unfrozen props), +## edge-triggered via the prop's world AABB containing the rig's feet/root point. +func _update_stickman_prop_collision() -> void: + var rigs: Array[StickmanRig] = [] + var props: Array[PropBlock] = [] + for node: Node2D in _world_children_selectable(): + if node is STICKMAN_RIG: + var rig := node as STICKMAN_RIG + if rig.state == StickmanRig.RigState.ANIMATED: + rigs.append(rig) + elif node is PropBlock and not (node as PropBlock).freeze: + props.append(node as PropBlock) + for rig: StickmanRig in rigs: + for prop: PropBlock in props: + var key := "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()] + var feet: Vector2 = rig.global_position - StickmanRig.FOOT_OFFSET + var overlap: bool = STAGE_SELECTION.get_world_aabb(prop).has_point(feet) + var was: bool = bool(_collision_pairs.get(key, false)) + if overlap and not was: + _collision_pairs[key] = true + _handle_event({ "type": "collided", "source": rig, "target": prop, "position": rig.global_position, "action": {} }) + elif not overlap and was: + _collision_pairs[key] = false + + +## Stickman point = feet (root - FOOT_OFFSET); prop point = global position. +func _movable_point(node: Node2D) -> Vector2: + if node is STICKMAN_RIG: + return node.global_position - StickmanRig.FOOT_OFFSET + return node.global_position + + +func _show_toast(msg: String) -> void: + _toast_text = msg + _toast_timer = 2.0 + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/scripts/stage_director_visuals.gd b/scripts/stage_director_visuals.gd index 01a8ea5..42a6d76 100644 --- a/scripts/stage_director_visuals.gd +++ b/scripts/stage_director_visuals.gd @@ -21,11 +21,27 @@ 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) +## Phase 4 rule rendering constants. +const WAYPOINT_HIT_RADIUS_PX := 14.0 +const RULE_TRIGGER_COLOR := Color(0.2, 0.85, 0.3) +const RULE_ACTION_COLOR := Color(1.0, 0.55, 0.15) +const RULE_LABEL_BG := Color(0.0, 0.0, 0.0, 0.7) +const RULE_LABEL_BORDER := Color(1.0, 1.0, 1.0, 0.25) +const RULE_LABEL_FONT_SIZE_PX := 14.0 +const RULE_BADGE_RADIUS_PX := 7.0 +const RULE_DELETE_HIT_PX := 14.0 +const RULE_DELETE_COLOR := Color(1.0, 0.3, 0.3, 0.9) + var camera: Camera2D = null var world: Node2D = null var enabled: bool = true var _dirty: bool = true +## Phase 4 rule rendering: stored rules plus per-frame hit regions for the label +## and delete icon ({"rect": Rect2, "id": int, "part": String}). +var rules: Array[Dictionary] = [] +var _rule_hit_regions: Array[Dictionary] = [] + func set_enabled(value: bool) -> void: enabled = value visible = value @@ -34,6 +50,33 @@ func set_enabled(value: bool) -> void: func mark_dirty() -> void: _dirty = true +func set_rules(r: Array[Dictionary]) -> void: + rules = r + mark_dirty() + + +## Returns the hit region under `world_pos` (label/delete), or an empty dict. +func hit_test_rule(world_pos: Vector2) -> Dictionary: + for region: Dictionary in _rule_hit_regions: + var rect: Rect2 = region.get("rect", Rect2()) + if rect.has_point(world_pos): + return { "id": int(region.get("id", -1)), "part": String(region.get("part", "")) } + return {} + + +## Nearest waypoint dot to `world_pos` within a screen-constant radius, reusing +## the same anchor math as _draw_rig_queue. Returns Vector2.INF on miss. +func hit_test_waypoint(world_pos: Vector2) -> Vector2: + var radius := WAYPOINT_HIT_RADIUS_PX / _zoom() + var best := Vector2.INF + var best_dist := radius + for wp: Vector2 in _collect_waypoints(): + var d := world_pos.distance_to(wp) + if d <= best_dist: + best_dist = d + best = wp + return best + func _process(_delta: float) -> void: if _dirty: _dirty = false @@ -56,9 +99,11 @@ func _collect_rigs() -> Array[StickmanRig]: func _draw() -> void: if not enabled: return + _rule_hit_regions.clear() var zoom := _zoom() for rig: StickmanRig in _collect_rigs(): _draw_rig_queue(rig, zoom) + _draw_rules(zoom) func _draw_rig_queue(rig: StickmanRig, zoom: float) -> void: var queue := rig.get_queue() @@ -127,3 +172,172 @@ func _draw_badge(anchor: Vector2, type: String, zoom: float, number: String) -> 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) + +# --------------------------------------------------------------------------- +# Rule rendering (Phase 4) +# --------------------------------------------------------------------------- + +## Collects every walk_to waypoint position, mirroring _draw_rig_queue's anchor +## math exactly so waypoint hit-testing matches rendering. +func _collect_waypoints() -> Array[Vector2]: + var result: Array[Vector2] = [] + for rig: StickmanRig in _collect_rigs(): + var queue := rig.get_queue() + if queue.is_empty(): + continue + var current := rig.global_position - STICKMAN_RIG.FOOT_OFFSET + 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) + result.append(target) + current = target + return result + + +func _draw_rules(zoom: float) -> void: + for rule: Dictionary in rules: + _draw_rule(rule, zoom) + + +func _draw_rule(rule: Dictionary, zoom: float) -> void: + var trigger: Dictionary = rule.get("trigger", {}) + var trigger_anchor := _rule_trigger_anchor(trigger) + if not trigger_anchor.is_finite(): + return + var action_anchor := _rule_action_anchor(rule, trigger_anchor) + if not action_anchor.is_finite(): + action_anchor = trigger_anchor + + # Dashed white connector (trigger -> action). + if trigger_anchor.distance_to(action_anchor) > 0.5: + _draw_dashed(trigger_anchor, action_anchor, zoom) + + # Badges. + _draw_rule_badge(trigger_anchor, zoom, "⚡", RULE_TRIGGER_COLOR) + _draw_rule_badge(action_anchor, zoom, "→", RULE_ACTION_COLOR) + + # Label at the line midpoint on a dark rounded rect. + var summary := rule_summary(rule) + var mid := (trigger_anchor + action_anchor) * 0.5 + var font := ThemeDB.fallback_font + var font_size := int(RULE_LABEL_FONT_SIZE_PX / zoom) + var text_size := font.get_string_size(summary, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size) + var padding := Vector2(6.0, 4.0) / zoom + var box := Rect2(mid - text_size * 0.5 - padding, text_size + padding * 2.0) + draw_rect(box, RULE_LABEL_BG, true) + draw_rect(box, RULE_LABEL_BORDER, false, 1.0 / zoom) + var baseline := box.position + padding + Vector2(0.0, font.get_ascent(font_size)) + draw_string(font, baseline, summary, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, Color.WHITE) + _rule_hit_regions.append({ "rect": box, "id": int(rule.get("id", -1)), "part": "label" }) + + # Delete icon (✕) right of the label, drawn as two crossing lines. + var del_size := RULE_DELETE_HIT_PX / zoom + var del_center := Vector2(box.end.x + del_size * 0.5 + 4.0 / zoom, box.position.y + box.size.y * 0.5) + var del_rect := Rect2(del_center - Vector2(del_size, del_size) * 0.5, Vector2(del_size, del_size)) + var half := del_size * 0.35 + draw_line(del_center + Vector2(-half, -half), del_center + Vector2(half, half), RULE_DELETE_COLOR, 2.0 / zoom, true) + draw_line(del_center + Vector2(half, -half), del_center + Vector2(-half, half), RULE_DELETE_COLOR, 2.0 / zoom, true) + _rule_hit_regions.append({ "rect": del_rect, "id": int(rule.get("id", -1)), "part": "delete" }) + + +func _draw_rule_badge(anchor: Vector2, zoom: float, glyph: String, color: Color) -> void: + var radius := RULE_BADGE_RADIUS_PX / zoom + draw_circle(anchor, radius, color) + draw_arc(anchor, radius, 0.0, TAU, 32, Color.WHITE, 2.0 / zoom, true) + var font := ThemeDB.fallback_font + var font_size := int(ICON_SIZE_PX / zoom) + var glyph_size := font.get_string_size(glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size) + draw_string(font, anchor + Vector2(-glyph_size.x * 0.5, glyph_size.y * 0.5), glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, Color.WHITE) + + +## Trigger badge anchor: waypoint pos for arrived_at_waypoint, area center for +## entered_area (or source pos fallback), source position otherwise. Vector2.INF +## on unresolved source. +func _rule_trigger_anchor(trigger: Dictionary) -> Vector2: + match String(trigger.get("type", "")): + "arrived_at_waypoint": + var params: Dictionary = trigger.get("params", {}) + return params.get("waypoint_pos", Vector2.INF) + "entered_area": + var area := instance_from_id(int(trigger.get("target", -1))) + if area is Node2D and is_instance_valid(area): + return (area as Node2D).global_position + return _rule_source_position(trigger) + _: + return _rule_source_position(trigger) + + +func _rule_source_position(trigger: Dictionary) -> Vector2: + var src := instance_from_id(int(trigger.get("source", -1))) + if src is Node2D and is_instance_valid(src): + return (src as Node2D).global_position + return Vector2.INF + + +## Action badge anchor: position of the first action's target stickman, else the +## trigger anchor. +func _rule_action_anchor(rule: Dictionary, fallback: Vector2) -> Vector2: + var actions: Array = rule.get("actions", []) + if actions.is_empty(): + return fallback + var target := instance_from_id(int(actions[0].get("target", -1))) + if target is Node2D and is_instance_valid(target): + return (target as Node2D).global_position + return fallback + + +## "When arrives → Speak 'Hello there!'" summary of a rule. +static func rule_summary(rule: Dictionary) -> String: + var trigger: Dictionary = rule.get("trigger", {}) + var verb := _trigger_verb(String(trigger.get("type", ""))) + var actions: Array = rule.get("actions", []) + var descs: Array[String] = [] + for a: Dictionary in actions: + descs.append(_action_desc(a)) + if descs.is_empty(): + return "When %s" % verb + if descs.size() == 1: + return "When %s → %s" % [verb, descs[0]] + if descs.size() == 2: + return "When %s → %s then %s" % [verb, descs[0], descs[1]] + return "When %s → %s (+%d more)" % [verb, descs[0], descs.size() - 1] + + +static func _trigger_verb(type: String) -> String: + match type: + "arrived_at_waypoint": + return "arrives" + "action_finished": + return "completes an action" + "speech_finished": + return "finishes speaking" + "entered_area": + return "enters area" + "collided": + return "collides" + _: + return "triggers" + + +static func _action_desc(action: Dictionary) -> String: + var params: Dictionary = action.get("params", {}) + match String(action.get("type", "")): + "walk_to": + return "Walks" + "speak": + return "Speak '%s'" % String(params.get("text", "")) + "wait": + return "Wait %ss" % _fmt_duration(float(params.get("duration", 0.0))) + "ragdoll": + return "Ragdolls" + "recover": + return "Recovers" + _: + return "Acts" + + +static func _fmt_duration(v: float) -> String: + if v == roundf(v): + return str(int(v)) + return str(v) diff --git a/scripts/stage_selection.gd b/scripts/stage_selection.gd index 5b15918..9865f21 100644 --- a/scripts/stage_selection.gd +++ b/scripts/stage_selection.gd @@ -129,6 +129,9 @@ func box_select(rect: Rect2, additive: bool) -> void: static func get_world_aabb(node: Node2D) -> Rect2: if node == null or not is_instance_valid(node): return Rect2() + # Duck-typed TriggerArea: expose its centered local rect through the transform. + if node.has_method("get_area_rect"): + return node.global_transform * (node.call("get_area_rect") as Rect2) var poly := node.get_node_or_null(NodePath("Polygon2D")) as Polygon2D if poly != null and not poly.polygon.is_empty(): var rect := Rect2(node.to_global(poly.polygon[0]), Vector2.ZERO) diff --git a/scripts/stage_spawner.gd b/scripts/stage_spawner.gd index 994f70a..7de1d3d 100644 --- a/scripts/stage_spawner.gd +++ b/scripts/stage_spawner.gd @@ -15,6 +15,7 @@ const TERRAIN_UTILS := preload("res://scripts/terrain_utils.gd") const PROP_UTILS := preload("res://scripts/prop_utils.gd") const PROP_BLOCK := preload("res://scripts/prop_block.gd") const STICKMAN_FACTORY := preload("res://scripts/stickman_factory.gd") +const TRIGGER_AREA := preload("res://scripts/trigger_area.gd") # --------------------------------------------------------------------------- # Constants @@ -87,6 +88,8 @@ func spawn(id: String, world_position: Vector2) -> Node2D: return _spawn_prop(entry, pos) "stickman": return _spawn_stickman(pos) + "area": + return _spawn_area(pos) _: push_warning("StageSpawner: unknown spawn kind '%s'." % entry.get("kind", "")) return null @@ -98,6 +101,9 @@ func spawn(id: String, world_position: Vector2) -> Node2D: static func get_world_aabb(node: Node2D) -> Rect2: if node == null or not is_instance_valid(node): return Rect2() + # Duck-typed TriggerArea: expose its centered local rect through the transform. + if node.has_method("get_area_rect"): + return node.global_transform * (node.call("get_area_rect") as Rect2) var poly := node.get_node_or_null(NodePath("Polygon2D")) as Polygon2D if poly != null and not poly.polygon.is_empty(): var rect := Rect2(node.to_global(poly.polygon[0]), Vector2.ZERO) @@ -174,6 +180,10 @@ func _build_registry() -> void: "id": "stickman", "label": "Stickman", "kind": "stickman", "spawn_offset": STICKMAN_FOOT_OFFSET, }, + { + "id": "area", "label": "Area", "kind": "area", + "spawn_offset": Vector2.ZERO, + }, ] @@ -213,6 +223,14 @@ func _spawn_prop(entry: Dictionary, world_position: Vector2) -> PropBlock: return PROP_UTILS.spawn_prop(_world, world_position, payload, preset, Vector2.ZERO) +func _spawn_area(world_position: Vector2) -> Node2D: + var area: Node2D = TRIGGER_AREA.new() + area.name = "TriggerArea" + area.position = world_position + _world.add_child(area) + return area + + func _spawn_stickman(world_position: Vector2) -> StickmanRig: if _stickman_data.is_empty(): push_warning("StageSpawner: no stickman data loaded; check '%s'." % DEFAULT_STICKMAN_PATH) diff --git a/scripts/stickman_rig.gd b/scripts/stickman_rig.gd index 7f6ef67..781135a 100644 --- a/scripts/stickman_rig.gd +++ b/scripts/stickman_rig.gd @@ -272,7 +272,7 @@ signal state_changed(new_state: int) # Director signals (Phase 3a) # --------------------------------------------------------------------------- -signal arrived # walk_to reached its destination +signal arrived(target: Vector2) # 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) @@ -1134,7 +1134,7 @@ func _finish_walk(reason: String = "") -> void: _restore_standing_markers() _walk_done = true _walking = false - arrived.emit() + arrived.emit(_walk_target_feet) func _cancel_walking() -> void: @@ -1215,6 +1215,24 @@ func queue_size() -> int: return action_queue.size() +## Append `actions` to the queue and, when the runner is idle, resume execution +## at the first newly-appended action WITHOUT replaying the existing queue. +## Used by the Phase 4 event engine to inject reactive actions onto a stickman. +func enqueue_reactive(actions: Array[Dictionary]) -> void: + if actions.is_empty(): + return + var start := action_queue.size() + action_queue.append_array(actions) + queue_changed.emit() + if _runner_state == RunnerState.IDLE: + # Resume at the first appended action (not at index 0), so any queued + # sequential actions are skipped over rather than replayed. + _current_index = start - 1 + _runner_state = RunnerState.EXECUTING + _action_phase = ActionPhase.NONE + _stop_requested = false + + # --------------------------------------------------------------------------- # Runner state machine (Phase 3a) # --------------------------------------------------------------------------- diff --git a/scripts/stickman_speech_bubble.gd b/scripts/stickman_speech_bubble.gd index aaeea94..26a6ded 100644 --- a/scripts/stickman_speech_bubble.gd +++ b/scripts/stickman_speech_bubble.gd @@ -47,4 +47,5 @@ func _draw() -> void: 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) + var baseline := Vector2(box.position.x + PADDING.x, box.position.y + PADDING.y + font.get_ascent(FONT_SIZE)) + draw_string(font, baseline, _text, HORIZONTAL_ALIGNMENT_LEFT, MAX_WIDTH, FONT_SIZE, TEXT_COLOR) diff --git a/scripts/trigger_area.gd b/scripts/trigger_area.gd new file mode 100644 index 0000000..781daae --- /dev/null +++ b/scripts/trigger_area.gd @@ -0,0 +1,50 @@ +class_name TriggerArea +extends Node2D +## TriggerArea - Placeable rectangular sensor for the Sandbox Stage (Phase 4). +## +## A drawn Node2D (NOT a physics Area2D) that the stage's geometric event engine +## polls for movable overlap. Purely visual plus a size query; no signals, no +## physics. Draws a translucent green fill with a dashed green border so it reads +## as a trigger zone in EDIT mode. World-space child, so no zoom division is +## needed for the dashed border. + +## Half-extents of the rectangular sensor in local space. +@export var size: Vector2 = Vector2(96.0, 96.0): + set(value): + size = value + queue_redraw() + + +## Local-space rectangle (centered on the node origin) used for overlap tests. +func get_area_rect() -> Rect2: + return Rect2(-size * 0.5, size) + + +func _draw() -> void: + var rect := get_area_rect() + draw_rect(rect, Color(0.2, 0.8, 0.3, 0.12), true) + _draw_dashed_rect(rect, Color(0.2, 0.8, 0.3, 0.6), 2.0, 6.0, 4.0) + + +## Dashed border along each edge of `rect` (top/right/bottom/left), drawn with a +## small manual dash loop using draw_line. +func _draw_dashed_rect(rect: Rect2, color: Color, width: float, dash: float, gap: float) -> void: + var tl := rect.position + var tr := rect.position + Vector2(rect.size.x, 0.0) + var br := rect.position + rect.size + var bl := rect.position + Vector2(0.0, rect.size.y) + _draw_dashed_edge(tl, tr, color, width, dash, gap) + _draw_dashed_edge(tr, br, color, width, dash, gap) + _draw_dashed_edge(br, bl, color, width, dash, gap) + _draw_dashed_edge(bl, tl, color, width, dash, gap) + + +func _draw_dashed_edge(from: Vector2, to: Vector2, color: Color, width: float, dash: float, gap: float) -> void: + 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, color, width, true) + dist += dash + gap diff --git a/scripts/trigger_area.gd.uid b/scripts/trigger_area.gd.uid new file mode 100644 index 0000000..03757f2 --- /dev/null +++ b/scripts/trigger_area.gd.uid @@ -0,0 +1 @@ +uid://dfx0hi5ey1auw diff --git a/tests/test_text_baseline_fix.gd b/tests/test_text_baseline_fix.gd new file mode 100644 index 0000000..d17b616 --- /dev/null +++ b/tests/test_text_baseline_fix.gd @@ -0,0 +1,158 @@ +# test_text_baseline_fix.gd +# Headless regression test for two text-baseline drawing fixes: +# 1. scripts/stickman_speech_bubble.gd _draw() text baseline now includes +# font.get_ascent(FONT_SIZE) so glyphs sit inside the bubble rect. +# 2. scripts/stage_director_visuals.gd _draw_rule() rule-label baseline now +# includes font.get_ascent(font_size) so white text sits on the dark rect. +# +# The math is replicated against the real ThemeDB.fallback_font using each +# file's actual constants (read off the script resources, not copied here), so +# the test tracks any future constant/geometry changes and proves the OLD +# (buggy) formula would fail the same containment checks (discriminator). +# +# Run with: +# & "C:\Godot4\Godot_v4.4-stable_win64_console.exe" --headless --script res://tests/test_text_baseline_fix.gd --path . +# +# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL. + +extends SceneTree + +const SpeechBubbleScript := preload("res://scripts/stickman_speech_bubble.gd") +const StageDirectorVisualsScript := preload("res://scripts/stage_director_visuals.gd") + +# Inline constants from stage_director_visuals.gd's _draw_rule() (they are not +# named constants there). RULE_LABEL_FONT_SIZE_PX is read from the script. +const RULE_LABEL_PADDING := Vector2(6.0, 4.0) +const RULE_ZOOM := 1.0 + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("") + print("========================================================") + print(" TEXT BASELINE REGRESSION TEST (headless)") + print("========================================================") + _test_speech_bubble() + _test_rule_label() + print("--------------------------------------------------------") + if _failures == 0: + print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks) + quit(0) + else: + print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks]) + quit(1) + + +# --------------------------------------------------------------------------- +# Bug 1: SpeechBubble._draw() +# --------------------------------------------------------------------------- +func _test_speech_bubble() -> void: + print("") + print("--- SpeechBubble._draw() baseline (bug 1) ---") + var bubble: Node2D = SpeechBubbleScript.new() + root.add_child(bubble) + + # Long enough that, wrapped at MAX_WIDTH=320 @ FONT_SIZE=28, the string + # spans multiple lines (proves the vertical-layout path is exercised). + var text := "Hello there! This is a nice long speech bubble message that wraps over multiple lines, just like a real line of dialogue." + bubble.show_text(text) + + var font := ThemeDB.fallback_font + var text_size := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, SpeechBubbleScript.MAX_WIDTH, SpeechBubbleScript.FONT_SIZE) + var box_size := text_size + SpeechBubbleScript.PADDING * 2.0 + var box := Rect2(Vector2(-box_size.x * 0.5, -SpeechBubbleScript.TAIL_HEIGHT - box_size.y), box_size) + var ascent := font.get_ascent(SpeechBubbleScript.FONT_SIZE) + var descent := font.get_descent(SpeechBubbleScript.FONT_SIZE) + + # Fixed formula (current code in _draw()). + var fixed_baseline := Vector2( + box.position.x + SpeechBubbleScript.PADDING.x, + box.position.y + SpeechBubbleScript.PADDING.y + ascent + ) + # Old (buggy) formula: draw_string(font, box.position + PADDING, ...). + var old_baseline := box.position + SpeechBubbleScript.PADDING + + # Prove the probe string genuinely exceeds the wrap width (and therefore the + # production box is clamped to MAX_WIDTH). Note: in Godot 4.4 + # get_string_size() caps the width at the given width but reports a + # single-line height, so "wrap exercised" is proven via the natural width + # exceeding MAX_WIDTH and the box width equaling MAX_WIDTH. + var natural_width := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, SpeechBubbleScript.FONT_SIZE).x + _check(natural_width > SpeechBubbleScript.MAX_WIDTH, + "SpeechBubble: probe string natural width (%.1f) exceeds MAX_WIDTH (%.1f) -> wrap path exercised" % [natural_width, SpeechBubbleScript.MAX_WIDTH]) + _check(text_size.x <= SpeechBubbleScript.MAX_WIDTH + 0.001, + "SpeechBubble: box width is clamped to MAX_WIDTH (%.1f)" % text_size.x) + + _check(fixed_baseline.y - ascent >= box.position.y - 0.001, + "SpeechBubble: FIXED glyph top (%.2f) is not above box top (%.2f)" % [fixed_baseline.y - ascent, box.position.y]) + _check(fixed_baseline.y + descent <= box.position.y + box.size.y + 0.001, + "SpeechBubble: FIXED glyph bottom (%.2f) is not below box bottom (%.2f)" % [fixed_baseline.y + descent, box.position.y + box.size.y]) + _check(old_baseline.y - ascent < box.position.y - 0.001, + "SpeechBubble: DISCRIMINATOR - OLD formula glyph top (%.2f) IS above box top (%.2f)" % [old_baseline.y - ascent, box.position.y]) + + print(" box=%s text_size=%s ascent=%.2f descent=%.2f" % [box, text_size, ascent, descent]) + print(" fixed_baseline=%s old_baseline=%s" % [fixed_baseline, old_baseline]) + bubble.free() + + +# --------------------------------------------------------------------------- +# Bug 2: StageDirectorVisuals._draw_rule() label baseline +# --------------------------------------------------------------------------- +func _test_rule_label() -> void: + print("") + print("--- StageDirectorVisuals._draw_rule() label baseline (bug 2) ---") + var visuals: Node2D = StageDirectorVisualsScript.new() + root.add_child(visuals) + + # A realistic When->Then rule whose summary exercises two actions. + var rule := { + "id": 1, + "trigger": {"type": "arrived_at_waypoint", "params": {"waypoint_pos": Vector2(100.0, 0.0)}, "source": 0}, + "actions": [ + {"type": "speak", "params": {"text": "Hello there!"}, "target": 0}, + {"type": "wait", "params": {"duration": 5.0}, "target": 0}, + ], + } + var summary := StageDirectorVisualsScript.rule_summary(rule) + + var zoom := RULE_ZOOM + var font := ThemeDB.fallback_font + var font_size := int(StageDirectorVisualsScript.RULE_LABEL_FONT_SIZE_PX / zoom) + var text_size := font.get_string_size(summary, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size) + var padding := RULE_LABEL_PADDING / zoom + var mid := Vector2(50.0, 50.0) + var box := Rect2(mid - text_size * 0.5 - padding, text_size + padding * 2.0) + var ascent := font.get_ascent(font_size) + var descent := font.get_descent(font_size) + + # Fixed formula (current code in _draw_rule()). + var fixed_baseline := box.position + padding + Vector2(0.0, ascent) + # Old (buggy) formula: draw_string(font, box.position + padding, ...). + var old_baseline := box.position + padding + + _check(fixed_baseline.y - ascent >= box.position.y - 0.001, + "RuleLabel: FIXED glyph top (%.2f) is not above box top (%.2f)" % [fixed_baseline.y - ascent, box.position.y]) + _check(fixed_baseline.y + descent <= box.position.y + box.size.y + 0.001, + "RuleLabel: FIXED glyph bottom (%.2f) is not below box bottom (%.2f)" % [fixed_baseline.y + descent, box.position.y + box.size.y]) + _check(old_baseline.y - ascent < box.position.y - 0.001, + "RuleLabel: DISCRIMINATOR - OLD formula glyph top (%.2f) IS above box top (%.2f)" % [old_baseline.y - ascent, box.position.y]) + + print(" summary='%s'" % summary) + print(" box=%s text_size=%s font_size=%d ascent=%.2f descent=%.2f" % [box, text_size, font_size, ascent, descent]) + print(" fixed_baseline=%s old_baseline=%s" % [fixed_baseline, old_baseline]) + visuals.free() + + +func _check(condition: bool, message: String) -> void: + _checks += 1 + if condition: + print("PASS: " + message) + else: + _failures += 1 + print("FAIL: " + message) diff --git a/tests/test_text_baseline_fix.gd.uid b/tests/test_text_baseline_fix.gd.uid new file mode 100644 index 0000000..b877743 --- /dev/null +++ b/tests/test_text_baseline_fix.gd.uid @@ -0,0 +1 @@ +uid://d1c53jmiqs43g