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:
2026-09-01 08:58:07 -04:00
parent bf11a5fab5
commit f208127917
17 changed files with 1538 additions and 8 deletions
+39 -1
View File
@@ -337,6 +337,11 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
Walking is kinematic
(`global_position.move_toward`); movement composes with the `walk_left`/`walk_right` in-place limb
animations (each has a discrete `.:facing_profile` track).
- **Phase 4 triggers:** the `arrived` signal gained a `target: Vector2` payload (emits
`_walk_target_feet`) so the stage can match waypoints for `arrived_at_waypoint` rules; new
`enqueue_reactive(actions: Array[Dictionary]) -> void` appends reactive actions to the action
queue and, if the runner is `IDLE`, resumes at the **first newly-appended action** (no replay of
the already-consumed queue prefix). Sequential Phase 3a queues are untouched.
- `scripts/stickman_factory.gd` — `class_name StickmanFactory`, `extends RefCounted`; a **static
factory** and the **runtime entry point** (Phase 9, **not used by the editor**) that turns a
`.stk` file into a live, rigged `master_rig.tscn` instance:
@@ -510,6 +515,16 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
None: mass 1.0, friction 0.5, bounce 0.05) and recolor fill/outline for non-`NONE`. Unified
live-update setters push geometry/color changes to all children (null-guarded for `@tool`
editor safety); `_apply_shape()` enables exactly one collision node.
- **Phase 4 collision signal:** new `signal collided(other: Node)`; `_ready()` sets
`contact_monitor = true`, `max_contacts_reported = 8`, and connects the guarded
`body_entered` signal → `_on_body_entered` → `collided.emit(body)`, so **prop-vs-prop**
collisions are reported by the physics engine (the stage's geometric feet-point test covers
stickman-vs-prop separately).
- `scripts/trigger_area.gd` — `class_name TriggerArea`, `extends Node2D`; a **placeable sensor**
(Phase 4, **not used by the editor**). `@export size: Vector2` (default 96×96), `get_area_rect()
-> Rect2` (centered on the node's global position), and `_draw()` (translucent green fill +
dashed border). **No physics and no signals** — it is a pure geometric region evaluated by
`sandbox_stage.gd`'s event engine (`_update_area_entry`) for `entered_area` rules.
- `scripts/prop_utils.gd` — `class_name PropUtils`, `extends RefCounted`; static factory
(**not used by the editor**):
- `create_box(size := Vector2(48,48))` / `create_ball(radius := 24.0)` /
@@ -616,6 +631,20 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
`stop_queue()`s then `snap_to_standing()`s stickmen and re-enables the visuals. Wires
`node.queue_changed → _director_visuals.mark_dirty` when a stickman is placed; `object_deleted` →
`mark_dirty()`. `_set_build_controls_visible()` also hides the Direct button in PLAY.
- **Phase 4 triggers & event system:** adds a When→Then **rule system** — `_event_rules:
Array[Dictionary]` of `{id, trigger, actions}` rules (trigger types: `arrived_at_waypoint`,
`action_finished`, `speech_finished`, `entered_area`, `collided`; action types reuse the Phase
3a set: `walk_to`/`speak`/`wait`/`ragdoll`/`recover`). Rules persist across Play/Edit mode
toggles but are **not** saved to disk (Phase 5). A geometric **event engine** runs in PLAY
(`_update_area_entry` tests a movable's feet/position against each `TriggerArea.get_area_rect()`;
`_update_stickman_prop_collision` tests a stickman's feet point against prop AABBs and vice-versa),
with **edge-triggered** dicts (already-fired events) reset on mode entry. A **rule-builder UI
state machine** (`RuleStep` enum: `IDLE`, `SELECT_TRIGGER`, `TRIGGER_TARGET`, `SELECT_ACTION`,
`ACTION_TARGET`, `ACTION_POSITION`, `PARAMS`) is driven from a **"⚡ When..."** item in the Direct
action popup: trigger sub-menu popup → trigger-target click → rule-action popup → target/position
→ "Add another action / Done" popup. **Esc** has highest priority; status-bar hints + toast
messages guide the flow. Rule **label click** → consequence-only edit (replaces the rule, same
`id`); **✕** deletes; `_cleanup_rules_for_nodes` auto-removes rules referencing deleted objects.
- `scripts/stickman_speech_bubble.gd` — `class_name SpeechBubble`, `extends Node2D`; a **world-space
speech bubble** drawn in `_draw()` (Phase 3a, **not used by the editor**). Child of a `StickmanRig`
at `SPEECH_BUBBLE_OFFSET` (above the head), so it follows the figure and scales with the camera.
@@ -637,6 +666,11 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
(start at rig feet + `FOOT_OFFSET`; each `walk_to` advances the anchor; non-walk anchors at the
current position), consecutive badges stacking `(0, -28)/zoom`. All sizes divided by `_zoom()` so
markers stay screen-constant; pure `_draw()`, no hit-testing; hidden in PLAY via `set_enabled(false)`.
- **Phase 4 rule visualization:** `_draw()` also renders `_event_rules` (set via `set_rules()`) —
a **dashed white connector line** from the trigger object to the target, a green **⚡ trigger
badge**, an orange **→ action badge**, a dark label with `rule_summary()` text, and a **✕ delete
icon** (the label + icon are hit-testable via `hit_test_rule()`, `Vector2.INF` sentinel from
`hit_test_waypoint()`); click-label → consequence-only edit, click-✕ → delete.
- `scripts/stage_spawner.gd` — `class_name StageSpawner`, `extends RefCounted`; registry-driven
spawner (Phase 2). A `_registry: Array[Dictionary]` maps ids to terrain/prop/stickman templates;
adding a type = appending an entry (no hard-coded id `match`). Reuses `TerrainUtils.spawn_block`,
@@ -647,6 +681,9 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
`_init`) and applies `STICKMAN_FOOT_OFFSET (0, -385)` so feet land at the cursor. Also exposes
a `static get_world_aabb(node)` helper (for a stickman it unions the mounted `Body/*` shape
geometry via a recursive `_collect_visual_points()` so the box is centered head-to-feet).
- **Phase 4 area palette entry:** a new `"area"` registry entry (label "Area") → `_spawn_area()`
instantiates a `TriggerArea`; `get_world_aabb()` gains a **duck-typed** `get_area_rect` AABB
branch (if the node responds to `get_area_rect()`, use its `Rect2` as the world bounds).
- `scripts/stage_selection.gd` — `class_name StageSelection`, `extends RefCounted`; hover/click/
box selection via geometric world-space AABB hit-testing (Phase 2). `static get_world_aabb`
unions a `Polygon2D` child's world points (terrain/props) or, for a stickman rig, recursively
@@ -656,7 +693,8 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
smallest area breaks ties. `_is_selectable` excludes the `RagdollBodyContainer` subtree. Signals
`hover_changed(node)` / `selection_changed(nodes)`; public API `get_selected`/`get_primary`/
`clear_selection`/`select_only`/`add_to_selection`/`toggle_selection`/`is_selected`/`hit_test`/
`update_hover`/`box_select`.
`update_hover`/`box_select`. (Phase 4) `get_world_aabb` also handles `TriggerArea` objects via
the same **duck-typed** `get_area_rect` AABB branch, so trigger areas are selectable/hit-testable.
- `scripts/stage_gizmos.gd` — `class_name StageGizmos`, `extends Node2D`; hover highlight +
selection outlines + a rotate ring (Phase 2). **No translate handle** — objects are dragged
directly by the root; `set_targets(nodes: Array[Node2D])` holds the current multi-selection,