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.
This commit is contained in:
@@ -337,6 +337,11 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
|||||||
Walking is kinematic
|
Walking is kinematic
|
||||||
(`global_position.move_toward`); movement composes with the `walk_left`/`walk_right` in-place limb
|
(`global_position.move_toward`); movement composes with the `walk_left`/`walk_right` in-place limb
|
||||||
animations (each has a discrete `.:facing_profile` track).
|
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
|
- `scripts/stickman_factory.gd` — `class_name StickmanFactory`, `extends RefCounted`; a **static
|
||||||
factory** and the **runtime entry point** (Phase 9, **not used by the editor**) that turns a
|
factory** and the **runtime entry point** (Phase 9, **not used by the editor**) that turns a
|
||||||
`.stk` file into a live, rigged `master_rig.tscn` instance:
|
`.stk` file into a live, rigged `master_rig.tscn` instance:
|
||||||
@@ -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
|
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`
|
live-update setters push geometry/color changes to all children (null-guarded for `@tool`
|
||||||
editor safety); `_apply_shape()` enables exactly one collision node.
|
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
|
- `scripts/prop_utils.gd` — `class_name PropUtils`, `extends RefCounted`; static factory
|
||||||
(**not used by the editor**):
|
(**not used by the editor**):
|
||||||
- `create_box(size := Vector2(48,48))` / `create_ball(radius := 24.0)` /
|
- `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
|
`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` →
|
`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.
|
`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
|
- `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`
|
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.
|
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
|
(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
|
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)`.
|
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
|
- `scripts/stage_spawner.gd` — `class_name StageSpawner`, `extends RefCounted`; registry-driven
|
||||||
spawner (Phase 2). A `_registry: Array[Dictionary]` maps ids to terrain/prop/stickman templates;
|
spawner (Phase 2). A `_registry: Array[Dictionary]` maps ids to terrain/prop/stickman templates;
|
||||||
adding a type = appending an entry (no hard-coded id `match`). Reuses `TerrainUtils.spawn_block`,
|
adding a type = appending an entry (no hard-coded id `match`). Reuses `TerrainUtils.spawn_block`,
|
||||||
@@ -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
|
`_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
|
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).
|
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/
|
- `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`
|
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
|
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
|
smallest area breaks ties. `_is_selectable` excludes the `RagdollBodyContainer` subtree. Signals
|
||||||
`hover_changed(node)` / `selection_changed(nodes)`; public API `get_selected`/`get_primary`/
|
`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`/
|
`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 +
|
- `scripts/stage_gizmos.gd` — `class_name StageGizmos`, `extends Node2D`; hover highlight +
|
||||||
selection outlines + a rotate ring (Phase 2). **No translate handle** — objects are dragged
|
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,
|
directly by the root; `set_targets(nodes: Array[Node2D])` holds the current multi-selection,
|
||||||
|
|||||||
@@ -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.
|
`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`)
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -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) |
|
| 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) |
|
| 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) |
|
| 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-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 | 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-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). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|
||||||
|
---
|
||||||
@@ -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
|
||||||
@@ -88,6 +88,14 @@ var _collision_polygon: CollisionPolygon2D
|
|||||||
var _collision_shape: CollisionShape2D
|
var _collision_shape: CollisionShape2D
|
||||||
var _circle_shape: CircleShape2D
|
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
|
# Lifecycle
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -96,6 +104,15 @@ func _ready() -> void:
|
|||||||
_ensure_children()
|
_ensure_children()
|
||||||
_apply_shape()
|
_apply_shape()
|
||||||
_apply_style()
|
_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
|
# Internal build / apply
|
||||||
|
|||||||
+622
-4
@@ -26,6 +26,9 @@ const STAGE_DIRECTOR_VISUALS := preload("res://scripts/stage_director_visuals.gd
|
|||||||
|
|
||||||
enum StageMode { EDIT, PLAY }
|
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
|
# Signals
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -56,6 +59,28 @@ const ACT_WAIT := 2
|
|||||||
const ACT_RAGDOLL := 3
|
const ACT_RAGDOLL := 3
|
||||||
const ACT_RECOVER := 4
|
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.
|
## Debug gate for the Phase 3a stage trace. Ship OFF.
|
||||||
const DEBUG_STAGE := false
|
const DEBUG_STAGE := false
|
||||||
|
|
||||||
@@ -139,6 +164,35 @@ var _director_visuals = null # StageDirectorVisuals (preloaded)
|
|||||||
var _nav_region: NavigationRegion2D = null
|
var _nav_region: NavigationRegion2D = null
|
||||||
var _nav_dirty: bool = true
|
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 "<area_id>:<node_id>"
|
||||||
|
## and "<rig_id>:<prop_id>".
|
||||||
|
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
|
# Lifecycle
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -162,18 +216,26 @@ func _ready() -> void:
|
|||||||
_refresh_status()
|
_refresh_status()
|
||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
if _nav_dirty:
|
if _nav_dirty:
|
||||||
_nav_dirty = false
|
_nav_dirty = false
|
||||||
_rebake_navigation()
|
_rebake_navigation()
|
||||||
if _ghost != null and is_instance_valid(_ghost) and current_mode == StageMode.EDIT:
|
if _ghost != null and is_instance_valid(_ghost) and current_mode == StageMode.EDIT:
|
||||||
_update_ghost_position()
|
_update_ghost_position()
|
||||||
|
if _toast_timer > 0.0:
|
||||||
|
_toast_timer -= delta
|
||||||
|
if _toast_timer <= 0.0:
|
||||||
|
_toast_text = ""
|
||||||
|
_refresh_status()
|
||||||
|
|
||||||
|
|
||||||
func _physics_process(_delta: float) -> void:
|
func _physics_process(_delta: float) -> void:
|
||||||
if _restore_frames_left > 0:
|
if _restore_frames_left > 0:
|
||||||
_restore_frames_left -= 1
|
_restore_frames_left -= 1
|
||||||
_restore_authored_state()
|
_restore_authored_state()
|
||||||
|
if current_mode == StageMode.PLAY:
|
||||||
|
_update_area_entry()
|
||||||
|
_update_stickman_prop_collision()
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Input
|
# Input
|
||||||
@@ -205,7 +267,9 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
|||||||
if current_mode == StageMode.EDIT:
|
if current_mode == StageMode.EDIT:
|
||||||
delete_selected()
|
delete_selected()
|
||||||
KEY_ESCAPE:
|
KEY_ESCAPE:
|
||||||
if _pending_walk_target:
|
if _rule_step != RuleStep.IDLE:
|
||||||
|
_cancel_rule_build()
|
||||||
|
elif _pending_walk_target:
|
||||||
_pending_walk_target = false
|
_pending_walk_target = false
|
||||||
_context_rig = null
|
_context_rig = null
|
||||||
_refresh_status()
|
_refresh_status()
|
||||||
@@ -237,6 +301,19 @@ func _handle_world_click(mb: InputEventMouseButton) -> void:
|
|||||||
if current_mode != StageMode.EDIT:
|
if current_mode != StageMode.EDIT:
|
||||||
return
|
return
|
||||||
var world_pos := _camera.get_global_mouse_position()
|
var world_pos := _camera.get_global_mouse_position()
|
||||||
|
# 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 _direct_mode:
|
||||||
if mb.pressed:
|
if mb.pressed:
|
||||||
_handle_direct_click(world_pos)
|
_handle_direct_click(world_pos)
|
||||||
@@ -311,6 +388,9 @@ func _enter_edit_mode() -> void:
|
|||||||
_director_visuals.set_enabled(true)
|
_director_visuals.set_enabled(true)
|
||||||
_apply_grid_settings()
|
_apply_grid_settings()
|
||||||
_set_build_controls_visible(true)
|
_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:
|
func _enter_play_mode() -> void:
|
||||||
@@ -327,6 +407,9 @@ func _enter_play_mode() -> void:
|
|||||||
_director_visuals.set_enabled(false)
|
_director_visuals.set_enabled(false)
|
||||||
_apply_grid_settings()
|
_apply_grid_settings()
|
||||||
_set_build_controls_visible(false)
|
_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():
|
for node: Node2D in _world_children_selectable():
|
||||||
if node is RigidBody2D:
|
if node is RigidBody2D:
|
||||||
(node as RigidBody2D).freeze = false
|
(node as RigidBody2D).freeze = false
|
||||||
@@ -395,7 +478,13 @@ func _place_at(world_pos: Vector2) -> void:
|
|||||||
if node is STICKMAN_RIG:
|
if node is STICKMAN_RIG:
|
||||||
var rig := node as STICKMAN_RIG
|
var rig := node as STICKMAN_RIG
|
||||||
rig.queue_changed.connect(_director_visuals.mark_dirty)
|
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()
|
_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:
|
if node is TerrainBlock:
|
||||||
_nav_dirty = true
|
_nav_dirty = true
|
||||||
object_placed.emit(node)
|
object_placed.emit(node)
|
||||||
@@ -503,6 +592,7 @@ func delete_selected() -> void:
|
|||||||
nav_changed = true
|
nav_changed = true
|
||||||
_clear_object_state(node)
|
_clear_object_state(node)
|
||||||
node.queue_free()
|
node.queue_free()
|
||||||
|
_cleanup_rules_for_nodes(selected)
|
||||||
_selection.clear_selection()
|
_selection.clear_selection()
|
||||||
if nav_changed:
|
if nav_changed:
|
||||||
_nav_dirty = true
|
_nav_dirty = true
|
||||||
@@ -527,9 +617,14 @@ func _refresh_status() -> void:
|
|||||||
sel_text = String(sel[0].name)
|
sel_text = String(sel[0].name)
|
||||||
else:
|
else:
|
||||||
sel_text = "%d objects" % sel.size()
|
sel_text = "%d objects" % sel.size()
|
||||||
_status_label.text = "Mode: %s | Objects: %d | Selected: %s" % [mode_text, count, sel_text]
|
var text := "Mode: %s | Objects: %d | Selected: %s" % [mode_text, count, sel_text]
|
||||||
if _pending_walk_target:
|
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:
|
if _mode_button != null:
|
||||||
_mode_button.set_pressed_no_signal(current_mode == StageMode.PLAY)
|
_mode_button.set_pressed_no_signal(current_mode == StageMode.PLAY)
|
||||||
_mode_button.text = "Play" if current_mode == StageMode.EDIT else "Edit"
|
_mode_button.text = "Play" if current_mode == StageMode.EDIT else "Edit"
|
||||||
@@ -604,9 +699,37 @@ func _build_ui() -> void:
|
|||||||
_action_popup.add_item("⏳ Wait", ACT_WAIT)
|
_action_popup.add_item("⏳ Wait", ACT_WAIT)
|
||||||
_action_popup.add_item("💥 Ragdoll", ACT_RAGDOLL)
|
_action_popup.add_item("💥 Ragdoll", ACT_RAGDOLL)
|
||||||
_action_popup.add_item("🔄 Recover", ACT_RECOVER)
|
_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)
|
_action_popup.id_pressed.connect(_on_action_popup_id_pressed)
|
||||||
ui.add_child(_action_popup)
|
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 = AcceptDialog.new()
|
||||||
_speak_dialog.title = "Speak"
|
_speak_dialog.title = "Speak"
|
||||||
_speak_dialog.confirmed.connect(_on_speak_confirmed)
|
_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" })
|
_context_rig.queue_action({ "type": "ragdoll" })
|
||||||
ACT_RECOVER:
|
ACT_RECOVER:
|
||||||
_context_rig.queue_action({ "type": "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:
|
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):
|
if _context_rig == null or not is_instance_valid(_context_rig):
|
||||||
return
|
return
|
||||||
_context_rig.queue_action({ "type": "speak", "text": _speak_edit.text, "duration": 2.0 })
|
_context_rig.queue_action({ "type": "speak", "text": _speak_edit.text, "duration": 2.0 })
|
||||||
|
|
||||||
|
|
||||||
func _on_wait_confirmed() -> void:
|
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):
|
if _context_rig == null or not is_instance_valid(_context_rig):
|
||||||
return
|
return
|
||||||
_context_rig.queue_action({ "type": "wait", "duration": _wait_spin.value })
|
_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
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -21,11 +21,27 @@ const ICON_COLOR := Color(1.0, 1.0, 1.0, 0.9)
|
|||||||
const ICON_SIZE_PX := 12.0
|
const ICON_SIZE_PX := 12.0
|
||||||
const BADGE_STACK_STEP := Vector2(0.0, -28.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 camera: Camera2D = null
|
||||||
var world: Node2D = null
|
var world: Node2D = null
|
||||||
var enabled: bool = true
|
var enabled: bool = true
|
||||||
var _dirty: 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:
|
func set_enabled(value: bool) -> void:
|
||||||
enabled = value
|
enabled = value
|
||||||
visible = value
|
visible = value
|
||||||
@@ -34,6 +50,33 @@ func set_enabled(value: bool) -> void:
|
|||||||
func mark_dirty() -> void:
|
func mark_dirty() -> void:
|
||||||
_dirty = true
|
_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:
|
func _process(_delta: float) -> void:
|
||||||
if _dirty:
|
if _dirty:
|
||||||
_dirty = false
|
_dirty = false
|
||||||
@@ -56,9 +99,11 @@ func _collect_rigs() -> Array[StickmanRig]:
|
|||||||
func _draw() -> void:
|
func _draw() -> void:
|
||||||
if not enabled:
|
if not enabled:
|
||||||
return
|
return
|
||||||
|
_rule_hit_regions.clear()
|
||||||
var zoom := _zoom()
|
var zoom := _zoom()
|
||||||
for rig: StickmanRig in _collect_rigs():
|
for rig: StickmanRig in _collect_rigs():
|
||||||
_draw_rig_queue(rig, zoom)
|
_draw_rig_queue(rig, zoom)
|
||||||
|
_draw_rules(zoom)
|
||||||
|
|
||||||
func _draw_rig_queue(rig: StickmanRig, zoom: float) -> void:
|
func _draw_rig_queue(rig: StickmanRig, zoom: float) -> void:
|
||||||
var queue := rig.get_queue()
|
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_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)
|
_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)
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ func box_select(rect: Rect2, additive: bool) -> void:
|
|||||||
static func get_world_aabb(node: Node2D) -> Rect2:
|
static func get_world_aabb(node: Node2D) -> Rect2:
|
||||||
if node == null or not is_instance_valid(node):
|
if node == null or not is_instance_valid(node):
|
||||||
return Rect2()
|
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
|
var poly := node.get_node_or_null(NodePath("Polygon2D")) as Polygon2D
|
||||||
if poly != null and not poly.polygon.is_empty():
|
if poly != null and not poly.polygon.is_empty():
|
||||||
var rect := Rect2(node.to_global(poly.polygon[0]), Vector2.ZERO)
|
var rect := Rect2(node.to_global(poly.polygon[0]), Vector2.ZERO)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const TERRAIN_UTILS := preload("res://scripts/terrain_utils.gd")
|
|||||||
const PROP_UTILS := preload("res://scripts/prop_utils.gd")
|
const PROP_UTILS := preload("res://scripts/prop_utils.gd")
|
||||||
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
|
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
|
||||||
const STICKMAN_FACTORY := preload("res://scripts/stickman_factory.gd")
|
const STICKMAN_FACTORY := preload("res://scripts/stickman_factory.gd")
|
||||||
|
const TRIGGER_AREA := preload("res://scripts/trigger_area.gd")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Constants
|
# Constants
|
||||||
@@ -87,6 +88,8 @@ func spawn(id: String, world_position: Vector2) -> Node2D:
|
|||||||
return _spawn_prop(entry, pos)
|
return _spawn_prop(entry, pos)
|
||||||
"stickman":
|
"stickman":
|
||||||
return _spawn_stickman(pos)
|
return _spawn_stickman(pos)
|
||||||
|
"area":
|
||||||
|
return _spawn_area(pos)
|
||||||
_:
|
_:
|
||||||
push_warning("StageSpawner: unknown spawn kind '%s'." % entry.get("kind", ""))
|
push_warning("StageSpawner: unknown spawn kind '%s'." % entry.get("kind", ""))
|
||||||
return null
|
return null
|
||||||
@@ -98,6 +101,9 @@ func spawn(id: String, world_position: Vector2) -> Node2D:
|
|||||||
static func get_world_aabb(node: Node2D) -> Rect2:
|
static func get_world_aabb(node: Node2D) -> Rect2:
|
||||||
if node == null or not is_instance_valid(node):
|
if node == null or not is_instance_valid(node):
|
||||||
return Rect2()
|
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
|
var poly := node.get_node_or_null(NodePath("Polygon2D")) as Polygon2D
|
||||||
if poly != null and not poly.polygon.is_empty():
|
if poly != null and not poly.polygon.is_empty():
|
||||||
var rect := Rect2(node.to_global(poly.polygon[0]), Vector2.ZERO)
|
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",
|
"id": "stickman", "label": "Stickman", "kind": "stickman",
|
||||||
"spawn_offset": STICKMAN_FOOT_OFFSET,
|
"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)
|
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:
|
func _spawn_stickman(world_position: Vector2) -> StickmanRig:
|
||||||
if _stickman_data.is_empty():
|
if _stickman_data.is_empty():
|
||||||
push_warning("StageSpawner: no stickman data loaded; check '%s'." % DEFAULT_STICKMAN_PATH)
|
push_warning("StageSpawner: no stickman data loaded; check '%s'." % DEFAULT_STICKMAN_PATH)
|
||||||
|
|||||||
+20
-2
@@ -272,7 +272,7 @@ signal state_changed(new_state: int)
|
|||||||
# Director signals (Phase 3a)
|
# 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_started(action: Dictionary, index: int)
|
||||||
signal action_finished(action: Dictionary, index: int)
|
signal action_finished(action: Dictionary, index: int)
|
||||||
signal queue_finished # queue ran to completion (not on stop)
|
signal queue_finished # queue ran to completion (not on stop)
|
||||||
@@ -1134,7 +1134,7 @@ func _finish_walk(reason: String = "") -> void:
|
|||||||
_restore_standing_markers()
|
_restore_standing_markers()
|
||||||
_walk_done = true
|
_walk_done = true
|
||||||
_walking = false
|
_walking = false
|
||||||
arrived.emit()
|
arrived.emit(_walk_target_feet)
|
||||||
|
|
||||||
|
|
||||||
func _cancel_walking() -> void:
|
func _cancel_walking() -> void:
|
||||||
@@ -1215,6 +1215,24 @@ func queue_size() -> int:
|
|||||||
return action_queue.size()
|
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)
|
# Runner state machine (Phase 3a)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -47,4 +47,5 @@ func _draw() -> void:
|
|||||||
Vector2(TAIL_WIDTH * 0.5, -TAIL_HEIGHT),
|
Vector2(TAIL_WIDTH * 0.5, -TAIL_HEIGHT),
|
||||||
Vector2(0.0, 0.0),
|
Vector2(0.0, 0.0),
|
||||||
]), BG_COLOR)
|
]), 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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dfx0hi5ey1auw
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://d1c53jmiqs43g
|
||||||
Reference in New Issue
Block a user