feat: Enhance Trigger Area Reliability in Sandbox Stage

- Updated `_rule_matches` to ensure `entered_area` checks both source and target instance IDs for accurate event triggering.
- Refined `_update_area_entry` to utilize `_movable_overlaps_area`, ensuring that visible stickman/prop geometry is considered for area overlap detection.
- Introduced a new regression test suite in `tests/test_phase4c_area.gd` to validate the functionality of `entered_area` rules, covering various edge cases and ensuring correct behavior for source-specific and wildcard rules.
- Documented the architectural changes and validation results in `specs/phase_4c_area.md`, detailing the scope, data contracts, and design decisions made during implementation.
- Added new agent configurations for architect, developer, and writer roles to streamline the development process and improve documentation practices.
This commit is contained in:
2026-09-11 15:43:08 -04:00
parent 31282313be
commit 04e0f1bea2
11 changed files with 489 additions and 9 deletions
+24
View File
@@ -0,0 +1,24 @@
name = "architect"
description = "Defines system requirements and technical specifications for Godot features."
model = "gpt-5.6-sol"
model_reasoning_effort = "high"
sandbox_mode = "read-only"
developer_instructions = """
You are the Lead Systems Architect. Refer to `AGENTS.md` for project directory standards and coding rules.
## Responsibilities
1. **Analyze:** Read existing code patterns before designing.
2. **Design:** Define API payload shapes, GDScript type hints, and signal flows.
3. **Log:** Append trade-offs to `docs/tech_debt_and_optimizations.md`.
## Workflow
1. **Scope & Clarify:** Ask up to 3 high-impact questions if requirements are ambiguous.
2. **Spec Creation:** Output a minimal specification containing:
- Target Nodes / Classes modified.
- Signal interfaces & static type contracts.
- Layout/Theme updates required.
3. **Handoff:** Save to `specs/[feature-name].md` and stop for user approval.
"""
+22
View File
@@ -0,0 +1,22 @@
name = "developer"
description = "Implements GDScript logic and scene adjustments."
model = "gpt-5.6-terra"
model_reasoning_effort = "medium"
sandbox_mode = "workspace-write"
developer_instructions = """
You are a Godot 4 / GDScript 2.0 implementation developer. Adhere to coding standards in `AGENTS.md`.
## Rules
- **Targeted Edits Only:** Do NOT output full unchanged files. Output only modified functions, concise diffs, or specific node configurations.
- **Static Typing:** Enforce explicit type hints for all signatures and variables.
- **Decoupling:** Follow "call down, signal up".
- **Validation:** Run syntax verification command when finished:
`& "C:\\Godot4\\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://path/to/script.gd" --path "C:\\Godot4\\stickman"`
## Output Format
- **Summary:** 1-2 lines on changes made.
- **Code Edits:** Show only modified snippet/function blocks with surrounding context lines.
"""
+19
View File
@@ -0,0 +1,19 @@
name = "writer"
description = "Drafts and updates technical documentation, GDScript APIs, and architecture specs."
model = "gpt-5.6-luna"
sandbox_mode = "read-only"
developer_instructions = """
You are a technical writer for Godot 4 / GDScript projects. You document game architectures, class APIs, and node systems with pinpoint precision.
### Deliverables
- **Architecture & System Guides:** Maintain concise READMEs, scene hierarchy overviews, and system runbooks.
- **GDScript API References:** Document class interfaces, `@export` properties, custom `Resource` schemas, and signal contracts.
- **Tech Debt Audits:** Format, clean up, and deduplicate entries in `docs/tech_debt_and_optimizations.md`.
### Execution Rules
1. **Targeted Edits Only:** Touch only the specific markdown sections affected by recent changes. Never output full unchanged files.
2. **Scannable & Technical:** Lead directly with code blocks, tables, and structured lists. Eliminate marketing fluff or generic introductions.
"""
+23
View File
@@ -0,0 +1,23 @@
---
name: spec-pipeline
description: "Lean implementation loop: Architect -> Developer -> User Verification."
---
## Execution Logic
1. **Phase 1: Architecture (Optional for small edits)**
- `@architect` defines signatures, node structures, or spec updates in `specs/[feature-name].md`.
- **STOP:** Wait for user approval on design choices before invoking developer.
2. **Phase 2: Implementation & Engine Validation**
- `@developer` updates code and scenes using targeted diffs/snippets.
- `@developer` runs headless syntax verification:
`& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://path/to/script.gd" --path "C:\Godot4\project"`
- If static check fails, `@developer` fixes the code immediately.
3. **Phase 3: User Hand-off (Stop)**
- Present the changes to the user to test inside the Godot Editor.
- Do NOT update READMEs or documentation yet.
4. **Phase 4: Documentation (Triggered manually by user)**
- Only call `@writer` after the user confirms: _"Feature verified and working."_
+12 -3
View File
@@ -598,7 +598,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
stickman-vs-prop separately). stickman-vs-prop separately).
- `scripts/trigger_area.gd` — `class_name TriggerArea`, `extends Node2D`; a **placeable sensor** - `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() (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 + -> Rect2` (centered local-space rectangle), and `_draw()` (translucent green fill +
dashed border). **No physics and no signals** — it is a pure geometric region evaluated by 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. `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
@@ -715,8 +715,9 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
`action_finished`, `speech_finished`, `entered_area`, `collided`; action types reuse the Phase `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 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 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_area_entry` tests each movable's visible world AABB against the current transformed
`_update_stickman_prop_collision` tests a stickman's feet point against prop AABBs and vice-versa), `TriggerArea` world AABB; `_update_stickman_prop_collision` continues to test 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 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`, 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_TARGET`, `ACTION_POSITION`, `PARAMS`) is driven from a **"⚡ When..."** item in the Direct
@@ -733,6 +734,14 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
confirm (`_finalize_rule`), cancel (`_cancel_rule_build`), or Direct-mode/flow exit confirm (`_finalize_rule`), cancel (`_cancel_rule_build`), or Direct-mode/flow exit
(`_clear_director_pending`), but **not** by `_reset_rule_builder()` (the Back-to-actions path (`_clear_director_pending`), but **not** by `_reset_rule_builder()` (the Back-to-actions path
intentionally reuses it). intentionally reuses it).
- **Phase 4c area-entry reliability:** `entered_area` matching requires both the source movable
instance ID and target area instance ID; `source == -1` remains the explicit wildcard. Area
entry uses visible world-AABB overlap for ANIMATED stickmen and unfrozen props, so an object can
trigger when a visible leg/body part enters even if its feet anchor is still outside. The
`_area_overlap` edge state is independent per area/movable instance-ID pair: it fires once on
outside→overlap, does not repeat while overlapping, and re-arms after exit. The area loop's prop
enumeration is correctly scoped. Verified by `tests/test_phase4c_area.gd` (19 assertions,
all passed) and manual editor verification of A entering an area causing B's speech bubble.
- **Phase 3b asset-library selector integration:** the **Stickman** and **Prop** palette buttons no - **Phase 3b asset-library selector integration:** the **Stickman** and **Prop** palette buttons no
longer place directly — pressing them opens a modal **selector grid** (`_selector: AssetSelector`, longer place directly — pressing them opens a modal **selector grid** (`_selector: AssetSelector`,
an instantiated `scenes/asset_selector.tscn` `PopupPanel`, added to the UI `CanvasLayer` in an instantiated `scenes/asset_selector.tscn` `PopupPanel`, added to the UI `CanvasLayer` in
+11 -3
View File
@@ -548,11 +548,11 @@ The **Director Tool** (Phase 3a) turns the Sandbox Stage into a mini director's
**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`. **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.). 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` carries `{type, source, target, ...}` instance references and `actions` is an array of Phase 3a action dicts (`{"type":"walk_to","target":...}`, etc.).
| File | Purpose | | 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/trigger_area.gd` | NEW `class_name TriggerArea`, `extends Node2D` — a placeable sensor (`@export size: Vector2`, default 96×96) with a centered **local-space** `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/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/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/prop_block.gd` | NEW `signal collided(other: Node)` (physics-based, prop-vs-prop). |
@@ -567,7 +567,7 @@ The rule concept is **When → Then**: a *trigger* (an event on some object) fir
| `arrived_at_waypoint` | Stickman | The stickman completes a `walk_to` (the rig's `arrived` signal). | | `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). | | `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). | | `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`). | | `entered_area` | Trigger area | A movable (stickman/prop) visibly overlaps the area's transformed world AABB (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). | | `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**. **Action types** are the Phase 3a set, reused verbatim: `walk_to`, `speak`, `wait`, `ragdoll`, `recover`. Actions always target a **stickman**.
@@ -591,6 +591,14 @@ All rule-builder context menus are **session-anchored**: the first popup in a fl
**`enqueue_reactive` semantics:** `StickmanRig.enqueue_reactive(actions: Array[Dictionary]) -> void` appends reactive actions to the rig's action queue, **tagging each with `reactive = true`**. 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. Because they are tagged, `clear_reactive_actions()` can strip them on return to Edit/Direct (see §19), so reactive actions injected across multiple Play sessions never accumulate on top of the authored sequential queue. **`enqueue_reactive` semantics:** `StickmanRig.enqueue_reactive(actions: Array[Dictionary]) -> void` appends reactive actions to the rig's action queue, **tagging each with `reactive = true`**. 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. Because they are tagged, `clear_reactive_actions()` can strip them on return to Edit/Direct (see §19), so reactive actions injected across multiple Play sessions never accumulate on top of the authored sequential queue.
### 20.1 Phase 4c — Trigger Area Reliability
Phase 4c fixes `entered_area` source matching and visible overlap detection. In PLAY, `_update_area_entry()` enumerates committed `TriggerArea` nodes and ANIMATED stickmen plus unfrozen props. Each movable is tested against the area's current transformed world AABB, using the visible object's world AABB (with a point fallback only for a future movable with no bounds). This means a stickman's leg or another visible part can enter an area even when its feet anchor remains outside; the area itself still supports move/rotate/scale transforms.
The event remains edge-triggered per `(area instance ID, movable instance ID)` pair: one `entered_area` event is emitted when that pair changes from outside to overlapping, no event repeats while it remains overlapping, and leaving then re-entering emits again. Rule matching requires both the trigger's source ID and target area ID; `source = -1` remains the explicit wildcard for any movable. The stage's prop enumeration is correctly scoped so unfrozen props participate without accidentally changing the area loop.
Verified coverage in `tests/test_phase4c_area.gd`: **19 assertions passed**, including source-specific and wildcard matching, one-shot/re-entry edges, independent area/movable pairs, transformed areas, visible leg-level overlap with feet outside, speak queue normalization and visible speech execution, no-rule no-op behavior, stale action targets, and deletion cleanup. The editor verification also confirmed that `When Stickman A enters Area → Stickman B speaks` fires when A walks through the area.
**Prop collision detection (two mechanisms):** **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. - **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.
+3 -1
View File
@@ -23,7 +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. A cell-keyed spatial broadphase (e.g. `_grid_cells`) can cull candidate pairs before the AABB/feet-point tests. Phase 4b implemented that index (see #16, now Resolved) but the event engine does **not yet query it** — #14 remains Open until `_update_area_entry()` / `_update_stickman_prop_collision()` use it. (2026‑08‑30 / note updated 2026‑09‑02) | | 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. A cell-keyed spatial broadphase (e.g. `_grid_cells`) can cull candidate pairs before the visible-AABB/feet-point tests. Phase 4b implemented that index (see #16, now Resolved) but the event engine does **not yet query it** — #14 remains Open until `_update_area_entry()` / `_update_stickman_prop_collision()` use it. (2026‑08‑30 / note updated 2026‑09‑02) |
| 15 | **Walk arrival jitter (mode re-evaluated per frame + arrival-radius mismatch)** — `StickmanRig._update_walking()` re-computes `_walk_mode` from `is_target_reachable()` every physics frame, and finishes via `is_navigation_finished()` (`NAV_TARGET_DESIRED_DISTANCE` 12 px, feet-space) *or* the `ARRIVE_DISTANCE` 8 px root-space guard. Near the nav-mesh boundary (clicked waypoints often sit just off placed terrain) `is_target_reachable()` can flip, swapping `root_target` between the nav path point and the raw waypoint — two points with a small vertical offset → up/down jitter. | Medium | ✅ Resolved | Phase 4b: `StickmanRig._update_walking()` now **latches `_walk_mode` once per walk** (probes up to `LATCH_PROBE_MAX_FRAMES` = 6 after the map syncs, latching `"nav"` when reachable or `"direct"` on the bound) so it can never flip between frames, unifies arrival on the **final target** at `ARRIVE_DISTANCE` 8 px (snap-on-arrive), steers straight at the final target when within `2 × ARRIVE_DISTANCE`, and re-asserts the standing markers one extra physics frame after stop (`_settle_walk_markers()`) to clear any residual body-bob. Exactly one `arrive` fires; `global_position` is unchanged after arrival. See `plans/PHASE_4b_SPEC.md` §3. (2026-09-02) | | 15 | **Walk arrival jitter (mode re-evaluated per frame + arrival-radius mismatch)** — `StickmanRig._update_walking()` re-computes `_walk_mode` from `is_target_reachable()` every physics frame, and finishes via `is_navigation_finished()` (`NAV_TARGET_DESIRED_DISTANCE` 12 px, feet-space) *or* the `ARRIVE_DISTANCE` 8 px root-space guard. Near the nav-mesh boundary (clicked waypoints often sit just off placed terrain) `is_target_reachable()` can flip, swapping `root_target` between the nav path point and the raw waypoint — two points with a small vertical offset → up/down jitter. | Medium | ✅ Resolved | Phase 4b: `StickmanRig._update_walking()` now **latches `_walk_mode` once per walk** (probes up to `LATCH_PROBE_MAX_FRAMES` = 6 after the map syncs, latching `"nav"` when reachable or `"direct"` on the bound) so it can never flip between frames, unifies arrival on the **final target** at `ARRIVE_DISTANCE` 8 px (snap-on-arrive), steers straight at the final target when within `2 × ARRIVE_DISTANCE`, and re-asserts the standing markers one extra physics frame after stop (`_settle_walk_markers()`) to clear any residual body-bob. Exactly one `arrive` fires; `global_position` is unchanged after arrival. See `plans/PHASE_4b_SPEC.md` §3. (2026-09-02) |
| 16 | **No grid spatial dictionary** — terrain placement (`sandbox_stage.gd` `_place_at`) is single-click with no occupancy tracking; the 3-state "empty/same-type/blocked" drag-paint query and any spatial broadphase need a cell→nodes index. | Low | ✅ Resolved | Phase 4b adds an **advisory** grid spatial dictionary `_grid_cells` (cell `Vector2i` @ `TERRAIN_GRID_SIZE` 16 → `Array[Node2D]`) plus `_rasterize_aabb_to_cells()` to index every world AABB's covered cells; it drives the terrain drag-painting 3-state ghost query and the director target-validity test (skipping cells whose nodes include a `TerrainBlock`). It is populated on place, rebuilt on move/rotate/delete, and is **never authoritative** (the `World` tree is). It is **not yet wired into the Phase 4 event engine** — #14 stays Open for that. See `plans/PHASE_4b_SPEC.md` §2.6. (2026-09-02) | | 16 | **No grid spatial dictionary** — terrain placement (`sandbox_stage.gd` `_place_at`) is single-click with no occupancy tracking; the 3-state "empty/same-type/blocked" drag-paint query and any spatial broadphase need a cell→nodes index. | Low | ✅ Resolved | Phase 4b adds an **advisory** grid spatial dictionary `_grid_cells` (cell `Vector2i` @ `TERRAIN_GRID_SIZE` 16 → `Array[Node2D]`) plus `_rasterize_aabb_to_cells()` to index every world AABB's covered cells; it drives the terrain drag-painting 3-state ghost query and the director target-validity test (skipping cells whose nodes include a `TerrainBlock`). It is populated on place, rebuilt on move/rotate/delete, and is **never authoritative** (the `World` tree is). It is **not yet wired into the Phase 4 event engine** — #14 stays Open for that. See `plans/PHASE_4b_SPEC.md` §2.6. (2026-09-02) |
| 17 | **Thumbnail caching has no eviction / cap; render is deferred one-per-frame** — Phase 3b (`stickman_library.gd` / `prop_library.gd` + `thumbnails/*`) caches rig/prop thumbnails to `user://thumbnails/` keyed by stickman basename+mtime and prop `id_v<PROP_VERSION>`. | Low | Open | PNGs accumulate unboundedly on disk (only `clean_stale_stickmen` prunes superseded basenames; nothing caps total bytes), and in-headless the renderers return `null` → a placeholder is shown until a **manual F6 run** populates real captures. Future: a disk-size/LRU eviction policy, a version/cleanup sweep on stage open, and an explicit cache-warm pass (or skip-the-placeholder note) for headless/CI. (2026-09-03) | | 17 | **Thumbnail caching has no eviction / cap; render is deferred one-per-frame** — Phase 3b (`stickman_library.gd` / `prop_library.gd` + `thumbnails/*`) caches rig/prop thumbnails to `user://thumbnails/` keyed by stickman basename+mtime and prop `id_v<PROP_VERSION>`. | Low | Open | PNGs accumulate unboundedly on disk (only `clean_stale_stickmen` prunes superseded basenames; nothing caps total bytes), and in-headless the renderers return `null` → a placeholder is shown until a **manual F6 run** populates real captures. Future: a disk-size/LRU eviction policy, a version/cleanup sweep on stage open, and an explicit cache-warm pass (or skip-the-placeholder note) for headless/CI. (2026-09-03) |
@@ -33,6 +33,7 @@ This document tracks known technical debt, optimization opportunities, and minor
| 21 | **Phase 3c drag-reorder uses nearest-row-center, not an insertion point** — `QueuePanel` / `RulePanel` reorder computes the drop target as the row whose vertical center is nearest the mouse, then moves the dragged item to that index. | Low | Open | Functional, but the drop position can read off-by-one near row boundaries (it snaps to a whole row rather than an edge between rows). A true insertion-point indicator (a line drawn between rows) would be clearer; not worth the complexity until reordering long queues/rules is common. (2026-09-04) | | 21 | **Phase 3c drag-reorder uses nearest-row-center, not an insertion point** — `QueuePanel` / `RulePanel` reorder computes the drop target as the row whose vertical center is nearest the mouse, then moves the dragged item to that index. | Low | Open | Functional, but the drop position can read off-by-one near row boundaries (it snaps to a whole row rather than an edge between rows). A true insertion-point indicator (a line drawn between rows) would be clearer; not worth the complexity until reordering long queues/rules is common. (2026-09-04) |
| 22 | **`--check-only --script` needs a prior `--editor` rescan for new `class_name` scripts** — a freshly added `class_name` (e.g. `QueuePanel`) is not resolvable by `--check-only --script res://scripts/sandbox_stage.gd` until `--headless --editor --quit` regenerates `.godot/global_script_class_cache.cfg`. | Low | Open | Verification-workflow gotcha only (not a runtime bug): in-editor the cache is always current. CI/headless scripts that add new `class_name`s should run an `--editor` pass before per-script `--check-only`. (2026-09-04) | | 22 | **`--check-only --script` needs a prior `--editor` rescan for new `class_name` scripts** — a freshly added `class_name` (e.g. `QueuePanel`) is not resolvable by `--check-only --script res://scripts/sandbox_stage.gd` until `--headless --editor --quit` regenerates `.godot/global_script_class_cache.cfg`. | Low | Open | Verification-workflow gotcha only (not a runtime bug): in-editor the cache is always current. CI/headless scripts that add new `class_name`s should run an `--editor` pass before per-script `--check-only`. (2026-09-04) |
| 23 | **Phase 3c confirmation dialog is popped over an exclusive panel** — Queue delete / Clear All (`_on_queue_panel_delete_requested` / `_on_queue_panel_clear_requested`) and Rule delete / Clear All (`_on_rule_panel_delete_requested` / `_on_rule_panel_clear_requested`) call the shared `ConfirmationDialog` (`_ask_confirm`) **without first hiding the `exclusive = true` `QueuePanel` / `RulePanel`**. | Low | Open | Distinct facet of #19 (host-visibility discipline): popping a second exclusive window over a visible exclusive panel logs a **non-fatal engine warning** and the confirmation may render **non-modal**. It still functions (the panel is re-shown after the callback) — cosmetic. A fix would hide the host panel around `_ask_confirm` and re-show it in the confirm callback (and on the dialog's cancel/hide). Deliberate non-debt note: plan §11.2's separate confirmation dialog for editing `ragdoll`/`recover` actions was implemented instead as a paramless `ActionEditor` pre-fill — recorded in `docs/phase_3c_editor_spec.md` §2 decision 5, not as a debt row. (2026-09-04) | | 23 | **Phase 3c confirmation dialog is popped over an exclusive panel** — Queue delete / Clear All (`_on_queue_panel_delete_requested` / `_on_queue_panel_clear_requested`) and Rule delete / Clear All (`_on_rule_panel_delete_requested` / `_on_rule_panel_clear_requested`) call the shared `ConfirmationDialog` (`_ask_confirm`) **without first hiding the `exclusive = true` `QueuePanel` / `RulePanel`**. | Low | Open | Distinct facet of #19 (host-visibility discipline): popping a second exclusive window over a visible exclusive panel logs a **non-fatal engine warning** and the confirmation may render **non-modal**. It still functions (the panel is re-shown after the callback) — cosmetic. A fix would hide the host panel around `_ask_confirm` and re-show it in the confirm callback (and on the dialog's cancel/hide). Deliberate non-debt note: plan §11.2's separate confirmation dialog for editing `ragdoll`/`recover` actions was implemented instead as a paramless `ActionEditor` pre-fill — recorded in `docs/phase_3c_editor_spec.md` §2 decision 5, not as a debt row. (2026-09-04) |
| 24 | **Sandbox trigger rules use session-only instance IDs** — `entered_area` source/target references and action targets are `Object.get_instance_id()` integers. | Low | Open | Correct for the current in-memory rule engine and shared by the builder, matcher, visualizer, cleanup, and action executor. Rules survive `EDIT ⇄ DIRECT ⇄ PLAY` but are not serialized; a future saved-stage format needs stable authored object IDs instead of runtime instance IDs. (2026‑09‑11) |
--- ---
@@ -73,6 +74,7 @@ This document tracks known technical debt, optimization opportunities, and minor
| 2026-09-03 | **Selector UI bugfix round** (5 bugs, documented via `tests/test_phase3b_ui_fixes.gd` + docs in `docs/phase_3b_asset_grid_spec.md` / `README.md` / `AGENTS.md`): `AssetSelector.open()` drops its selection params (`selected_path`/`selected_id`) — no cell is pre-highlighted on open (selected stylebox removed); the selector re-centers on window resize (`size_changed` → `popup_centered()`); the stage adds a dim backdrop `_selector_dim` (`SELECTOR_DIM_ALPHA` 0.5) behind the grid; the selector's `popup_hide` routes to `_on_selector_cancelled()` (idempotency-guarded) so an outside-click un-presses the palette button; and the Direct-mode action popup opens **right of the clicked stickman** (`_world_to_screen` + 24 px) instead of at the cursor. Reviewed #17 and #18 — neither is obsolete (both concern thumbnail-cache growth/headless placeholders and session-only *persistence*, orthogonal to these UI fixes), so both remain **Open** unchanged; no duplicate rows introduced. | | 2026-09-03 | **Selector UI bugfix round** (5 bugs, documented via `tests/test_phase3b_ui_fixes.gd` + docs in `docs/phase_3b_asset_grid_spec.md` / `README.md` / `AGENTS.md`): `AssetSelector.open()` drops its selection params (`selected_path`/`selected_id`) — no cell is pre-highlighted on open (selected stylebox removed); the selector re-centers on window resize (`size_changed` → `popup_centered()`); the stage adds a dim backdrop `_selector_dim` (`SELECTOR_DIM_ALPHA` 0.5) behind the grid; the selector's `popup_hide` routes to `_on_selector_cancelled()` (idempotency-guarded) so an outside-click un-presses the palette button; and the Direct-mode action popup opens **right of the clicked stickman** (`_world_to_screen` + 24 px) instead of at the cursor. Reviewed #17 and #18 — neither is obsolete (both concern thumbnail-cache growth/headless placeholders and session-only *persistence*, orthogonal to these UI fixes), so both remain **Open** unchanged; no duplicate rows introduced. |
| 2026-09-03 | **Rule-builder popup-anchor bugfix round** (`sandbox_stage.gd`, documented in `README.md` / `AGENTS.md`): the Phase 4 rule-builder context menus used to re-pop at the live mouse position on every re-open, so cycling "⚡ When…" → "⬅ Back to actions" → "When…" walked the menu down the screen. Now a **session anchor** records the first context menu's screen position (`_popup_anchor: Rect2i` / `_popup_anchor_set`; Direct first menu = right of the clicked stickman; rule-label edit entry = the click position) and all child popups reuse it via `_set_popup_anchor(rect)` / `_clear_popup_anchor()` / `_popup_anchor_rect()`, until cleared on confirm (`_finalize_rule`), cancel (`_cancel_rule_build`), or Direct-mode/flow exit (`_clear_director_pending`) — but **not** on `_reset_rule_builder()` (Back-to-actions reuses it). No numbered debt row described the old cursor-following behavior, so no row was flipped to Resolved and no duplicates introduced; recorded here in the Change Log only. | | 2026-09-03 | **Rule-builder popup-anchor bugfix round** (`sandbox_stage.gd`, documented in `README.md` / `AGENTS.md`): the Phase 4 rule-builder context menus used to re-pop at the live mouse position on every re-open, so cycling "⚡ When…" → "⬅ Back to actions" → "When…" walked the menu down the screen. Now a **session anchor** records the first context menu's screen position (`_popup_anchor: Rect2i` / `_popup_anchor_set`; Direct first menu = right of the clicked stickman; rule-label edit entry = the click position) and all child popups reuse it via `_set_popup_anchor(rect)` / `_clear_popup_anchor()` / `_popup_anchor_rect()`, until cleared on confirm (`_finalize_rule`), cancel (`_cancel_rule_build`), or Direct-mode/flow exit (`_clear_director_pending`) — but **not** on `_reset_rule_builder()` (Back-to-actions reuses it). No numbered debt row described the old cursor-following behavior, so no row was flipped to Resolved and no duplicates introduced; recorded here in the Change Log only. |
| 2026-09-04 | Phase 3c (Editor Tools — Action & Rule Editing) implemented: `scripts/action_registry.gd` / `trigger_registry.gd` (extensible action/trigger templates), `scripts/action_editor.gd` / `rule_editor.gd` / `queue_panel.gd` / `rule_panel.gd` + `scenes/*.tscn` shells, `scripts/waypoint_context.gd`, plus integration in `sandbox_stage.gd` (unified `CaptureKind` target-capture, "Edit Queue…/Edit Rules…" + right-click stickman/waypoint context menus, confirmation dialog, consequence-only rule editor) and `stage_director_visuals.gd` (`hit_test_waypoint_action`, waypoint edit highlight). Logged #19 (ad-hoc popup host-visibility discipline), #20 ("Add Rule" has no source in waypoint-filtered panels), #21 (drag-reorder nearest-row-center), #22 (`--check-only --script` needs a prior `--editor` rescan), and later the same day #23 (Phase 3c confirmation dialog popped over an exclusive panel — the delete/clear-all confirm paths do not hide the `QueuePanel`/`RulePanel` first). Documented the plan-§11.2 deviation (paramless `ActionEditor` pre-fill instead of a separate ragdoll/recover confirmation dialog) in `docs/phase_3c_editor_spec.md` §2 decision 5 — a design decision, so no debt row. Static-verified via headless `--editor --quit` + per-script `--check-only`; runtime F6 flows (queue/rule panel edit-delete-reorder, waypoint context menu, visual walk edit, full vs consequence-only rule editor) still need manual verification. | | 2026-09-04 | Phase 3c (Editor Tools — Action & Rule Editing) implemented: `scripts/action_registry.gd` / `trigger_registry.gd` (extensible action/trigger templates), `scripts/action_editor.gd` / `rule_editor.gd` / `queue_panel.gd` / `rule_panel.gd` + `scenes/*.tscn` shells, `scripts/waypoint_context.gd`, plus integration in `sandbox_stage.gd` (unified `CaptureKind` target-capture, "Edit Queue…/Edit Rules…" + right-click stickman/waypoint context menus, confirmation dialog, consequence-only rule editor) and `stage_director_visuals.gd` (`hit_test_waypoint_action`, waypoint edit highlight). Logged #19 (ad-hoc popup host-visibility discipline), #20 ("Add Rule" has no source in waypoint-filtered panels), #21 (drag-reorder nearest-row-center), #22 (`--check-only --script` needs a prior `--editor` rescan), and later the same day #23 (Phase 3c confirmation dialog popped over an exclusive panel — the delete/clear-all confirm paths do not hide the `QueuePanel`/`RulePanel` first). Documented the plan-§11.2 deviation (paramless `ActionEditor` pre-fill instead of a separate ragdoll/recover confirmation dialog) in `docs/phase_3c_editor_spec.md` §2 decision 5 — a design decision, so no debt row. Static-verified via headless `--editor --quit` + per-script `--check-only`; runtime F6 flows (queue/rule panel edit-delete-reorder, waypoint context menu, visual walk edit, full vs consequence-only rule editor) still need manual verification. |
| 2026-09-11 | Phase 4c (Trigger Area Reliability) verified: `sandbox_stage.gd` `entered_area` now requires source + area instance IDs and detects visible world-AABB overlap for ANIMATED stickmen/unfrozen props, edge-triggered per area/movable pair. The prop enumeration scope bug was corrected. `tests/test_phase4c_area.gd` passed all 19 assertions, including transformed-area, leg-level overlap, wildcard/re-entry, speech consequence, stale-target, and deletion-cleanup coverage; editor verification confirmed the speech bubble fires for the configured source stickman. Logged #24 (session-only instance-ID rule references). |
--- ---
+14 -2
View File
@@ -2436,7 +2436,7 @@ func _rule_matches(rule: Dictionary, event: Dictionary) -> bool:
"speech_finished": "speech_finished":
return _ids_match(source_id, source) return _ids_match(source_id, source)
"entered_area": "entered_area":
return _ids_match(target_id, target) return _ids_match(source_id, source) and _ids_match(target_id, target)
"collided": "collided":
return _ids_match(source_id, source) and _ids_match(target_id, target) return _ids_match(source_id, source) and _ids_match(target_id, target)
_: _:
@@ -2492,7 +2492,7 @@ func _update_area_entry() -> void:
for node: Node2D in movables: for node: Node2D in movables:
var point := _movable_point(node) var point := _movable_point(node)
var key := "%d:%d" % [area.get_instance_id(), node.get_instance_id()] var key := "%d:%d" % [area.get_instance_id(), node.get_instance_id()]
var inside: bool = area_rect.has_point(point) var inside: bool = _movable_overlaps_area(node, area_rect)
var was: bool = bool(_area_overlap.get(key, false)) var was: bool = bool(_area_overlap.get(key, false))
if inside and not was: if inside and not was:
_area_overlap[key] = true _area_overlap[key] = true
@@ -2533,6 +2533,18 @@ func _movable_point(node: Node2D) -> Vector2:
return node.global_position return node.global_position
## Trigger entry follows the visible object, not only its origin/feet anchor.
## StageSelection already owns the mounted-geometry world AABB contract used by
## selection and gizmos, so reuse it here to keep what the user sees and what
## the event engine detects in agreement. Point-like fallbacks retain the old
## behavior for any future movable without visual bounds.
func _movable_overlaps_area(node: Node2D, area_rect: Rect2) -> bool:
var bounds := STAGE_SELECTION.get_world_aabb(node).abs()
if bounds.size.is_zero_approx():
return area_rect.has_point(_movable_point(node))
return area_rect.intersects(bounds, true)
func _show_toast(msg: String) -> void: func _show_toast(msg: String) -> void:
_toast_text = msg _toast_text = msg
_toast_timer = 2.0 _toast_timer = 2.0
+163
View File
@@ -0,0 +1,163 @@
# Phase 4c — Trigger Area Reliability
## Scope
Repair and regression-test `entered_area` rules in the standalone Sandbox Stage. This is a targeted correction to the existing geometric event engine; it does not add trigger types, persistence, physics sensors, or new UI.
## Architecture diagnosis
Read-only inspection and a Godot 4.7.1 `--check-only` pass establish that the diagnostic plan was written against contracts that differ from the current implementation:
- `SandboxStage._physics_process()` already calls `_update_area_entry()` only in `StageMode.PLAY`.
- `StageSpawner._spawn_area()` already adds the committed `TriggerArea` directly to `World`.
- `_update_area_entry()` already keys overlap state by both area and movable instance IDs (`"<area_id>:<movable_id>"`) and clears that state on PLAY/EDIT transitions.
- `TriggerArea.get_area_rect()` intentionally returns a **local-space** centered `Rect2`; `_update_area_entry()` converts it once with `area.global_transform`. Changing the getter to world space would double-transform detection and make `_draw()` incorrect.
- Rules intentionally store integer instance IDs. The rule builder, `RuleEditor`, cleanup, visualizer, filters, and action executor all share this representation. Replacing only an area target with `WeakRef` would violate the rule payload contract and break `int(trigger.target)`, `instance_from_id()`, deep-copy/edit, and deletion-cleanup paths.
- The stickman event position is already its feet. `StickmanRig.FOOT_OFFSET` is defined as `Vector2(0, -385)` in the feet-to-root equation `root = feet + FOOT_OFFSET`; therefore the inverse is `feet = root - FOOT_OFFSET`. The diagnostic plan's proposed `root + FOOT_OFFSET` has the wrong sign. Entry containment itself uses the movable's visible world AABB, while this feet point remains the event payload and point-like fallback.
- Both rule creation paths already commit `entered_area` as a rule in `_event_rules`.
The verified correctness defects were in two parts of the area path. `SandboxStage._rule_matches()` checked only `trigger.target` (the area) and ignored `trigger.source` (the selected stickman/prop), allowing another movable to consume the apparent entry moment and run the consequence before A arrived. `_update_area_entry()` also tested only the movable's origin/feet point, so a visible leg or body could pass through an area while its anchor remained outside. The final implementation matches both IDs and tests visible world-AABB overlap; the corrected prop enumeration scope keeps unfrozen props in the movable set without changing the area loop. The new end-to-end regression suite and editor verification cover these boundaries.
## Data contracts
Keep the existing transient rule payload:
```gdscript
{
"id": int,
"trigger": {
"type": "entered_area",
"source": int, # Object.get_instance_id(); -1 means any movable
"target": int, # TriggerArea.get_instance_id()
"params": Dictionary,
},
"actions": Array[Dictionary],
}
```
Keep the emitted internal event payload:
```gdscript
{
"type": "entered_area",
"source": Node2D, # movable that crossed the edge
"target": TriggerArea, # crossed area
"position": Vector2, # feet for StickmanRig; global_position otherwise
"action": Dictionary,
}
```
No serialized format or public signal changes are required.
## Target files and changes
### `scripts/sandbox_stage.gd`
1. Preserve the PLAY-only call in `_physics_process(_delta: float) -> void`.
2. Preserve `_update_area_entry() -> void` as the owner of area discovery, movable discovery, transformed world-AABB overlap, pairwise edge state, and event emission.
3. Correct `_rule_matches(rule: Dictionary, event: Dictionary) -> bool` so `entered_area` matches **both** ends of the trigger:
```gdscript
"entered_area":
return _ids_match(source_id, source) and _ids_match(target_id, target)
```
This retains the existing `-1` wildcard behavior through `_ids_match()` while ensuring a rule authored from Stickman A does not fire for Stickman B or a prop.
4. Keep `_movable_point(node: Node2D) -> Vector2` unchanged for stickmen:
```gdscript
return node.global_position - StickmanRig.FOOT_OFFSET
```
5. Use `StageSelection.get_world_aabb(node)` for visible stickman/prop overlap. If a future movable has no bounds, fall back to `area_rect.has_point(_movable_point(node))`. Keep the area's local rectangle contract and transform it once with `area.global_transform` before comparing AABBs.
6. Keep instance IDs as the single rule-reference representation. Stale action targets remain null-guarded by `instance_from_id(...) as StickmanRig`; deleted stage objects continue to remove referencing rules through `_cleanup_rules_for_nodes()`.
7. Do not ship the four unconditional diagnostic prints proposed in `plans/PHASE_4c_AREA.md`. Automated assertions cover each boundary without per-physics-frame console spam. If interactive tracing is temporarily needed during development, remove it before hand-off or guard it behind a default-false debug constant.
### `tests/test_phase4c_area.gd` (new)
Add a headless regression suite that instantiates `res://scenes/sandbox_stage.tscn`, uses real `StageSpawner` objects, and drives the event engine deterministically. Tests may call internal methods, matching the existing phase suites.
Required cases:
1. Spawning `"area"` returns a `TriggerArea` whose parent is `stage._world`.
2. An `entered_area` rule built with live instance IDs is retained in `_event_rules` with non-empty actions.
3. A source stickman crossing from outside to inside adds exactly one reactive action to the configured target rig.
4. Repeated polls while still inside do not add another action; an outside poll followed by another inside poll adds one more.
5. A different stickman or an unfrozen prop entering the same area does **not** match a source-specific rule.
6. A `source == -1` rule remains a deliberate wildcard and fires independently for multiple movables.
7. Two areas and two source stickmen use independent pair keys; entering one pair does not suppress another.
8. Moving/rotating/scaling an area uses its current global transform for containment.
9. A stickman's event `position` equals `rig.global_position - StickmanRig.FOOT_OFFSET` (feet), not its root or a bone position; containment uses the visible world AABB so the tested overlap can be leg/body-level.
10. An area with no matching rule is a no-op with no errors.
11. A stale/deleted action target is skipped without a crash. Deleting through the stage path removes rules referencing the deleted area.
12. Exercise at least one visible consequence payload (`speak`) and assert that the correct rig receives a normalized reactive queue action; the editor user-verification pass confirms the speech bubble itself.
Tests must restore/avoid global side effects and queue-free their instantiated stage.
## Nodes/classes affected
- `SandboxStage` (`scenes/sandbox_stage.tscn` root): source+target matcher correction, visible-AABB overlap helper, and prop-enumeration scope correction; no node-tree change.
- `TriggerArea`: exercised but not modified.
- `StageSpawner`, `RuleEditor`, `StageDirectorVisuals`, and `StickmanRig`: exercised through their existing contracts; no implementation change expected.
## Signal flow
```text
PLAY physics tick
-> SandboxStage._update_area_entry()
-> new (area_id, movable_id) containment edge
-> SandboxStage._handle_event(event)
-> SandboxStage._rule_matches(rule, event)
source instance ID AND area instance ID must match
-> SandboxStage._execute_rule_actions(rule)
-> StickmanRig.enqueue_reactive(actions)
-> existing StickmanRig queue runner / speech bubble
```
No new signals are introduced. Existing queue signals (`queue_changed`, `action_started`, `action_finished`, `queue_finished`) continue unchanged.
## Layout/theme
No layout, scene, cursor, label, or theme changes are required. Trigger area drawing remains a local-space translucent rectangle with a dashed outline.
## Validation
Developer validation:
```powershell
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://scripts/sandbox_stage.gd" --path "C:\Godot4\stickman"
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script "res://tests/test_phase4c_area.gd"
```
The baseline syntax check passed before implementation. Post-implementation validation passed as follows:
- `sandbox_stage.gd` and `tests/test_phase4c_area.gd` passed Godot `--check-only` syntax validation.
- `tests/test_phase4c_area.gd` reported `RESULT: ALL PASSED (19 assertions)`, covering source-specific and wildcard matching, one-shot/re-entry edges, independent pairs, transformed areas, visible leg-level overlap with feet outside, speech queue normalization and visible speech execution, no-rule no-op behavior, stale targets, and deletion cleanup.
- Existing Phase 4b suites remained passing: 16/16 logic assertions and 53/53 stage/UI assertions.
User verification in the Godot editor (`scenes/sandbox_stage.tscn`, F6) completed successfully:
1. Place Stickman A, Stickman B, and an Area.
2. In DIRECT, queue A to walk through the Area.
3. Author `When A enters Area -> B speaks`.
4. Enter PLAY: B speaks once as A's visible body overlaps the area, even when the feet anchor remains outside.
5. Keep A inside for five seconds: no repeat.
6. Return to EDIT/DIRECT, author a route that exits and re-enters, then PLAY: B speaks once on each entry.
7. Add another stickman/area pair and confirm independent firing with no warnings/errors.
The reported editor result was that Stickman A walking completely through the area caused Stickman B's configured speech bubble to appear, confirming visible-body overlap rather than feet-only containment.
## Design decisions and trade-offs
- **Instance IDs, not weakrefs:** consistent with all current rule UI/runtime consumers and adequate for session-only rules. A future persisted-stage format will need stable authored object IDs; that is outside Phase 4c.
- **Local area rectangle, transformed by the stage:** keeps drawing, hit-testing, AABB calculation, and overlap detection on one local geometry contract and supports moved/rotated/scaled areas.
- **Visible AABB overlap, not physics shape overlap:** a stickman or prop enters when its visible world AABB overlaps the area's transformed world AABB. This catches leg/body-level crossings while keeping the event engine geometric and lightweight; exact polygon/collision-shape overlap remains out of scope.
- **Source-specific by default, wildcard explicitly supported:** UI-authored rules retain the selected source. `-1` remains available for existing/programmatic "any movable" rules.
## Out of scope / documentation follow-up
- `exited_area` and `stay_in_area` triggers.
- Physics `Area2D` conversion or exact polygon/collision-shape overlap.
- Disk persistence/stable IDs for rules.
- Project documentation was updated after user verification; the stable-ID persistence limitation is recorded in `docs/tech_debt_and_optimizations.md`.
+197
View File
@@ -0,0 +1,197 @@
# Phase 4c headless regression checks for geometric entered_area rules.
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script "res://tests/test_phase4c_area.gd"
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const STICKMAN_RIG := preload("res://scripts/stickman_rig.gd")
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("\n=== PHASE 4c AREA TEST ===")
await _test_spawner_and_rule_contract()
await _test_source_specific_edges()
await _test_wildcard_and_pair_independence()
await _test_transforms_points_and_cleanup()
print("----------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func _new_stage() -> Node2D:
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
await process_frame
return stage
func _free_stage(stage: Node2D) -> void:
stage.queue_free()
await process_frame
func _spawn_area(stage: Node2D, position: Vector2) -> TriggerArea:
return stage._spawner.spawn("area", position) as TriggerArea
func _spawn_rig(stage: Node2D, feet: Vector2) -> StickmanRig:
var rig := stage._spawner.spawn("stickman", feet) as StickmanRig
stage._finalize_placed_node(rig)
return rig
func _move_rig_body_center(rig: StickmanRig, world_center: Vector2) -> void:
var bounds: Rect2 = STAGE_SELECTION.get_world_aabb(rig)
rig.global_position += world_center - bounds.get_center()
func _add_rule(stage: Node2D, source: Node2D, area: TriggerArea, target: StickmanRig, text: String = "area") -> Dictionary:
var rule: Dictionary = {
"trigger": {
"type": "entered_area",
"source": source.get_instance_id() if source != null else -1,
"target": area.get_instance_id(),
"params": {},
},
"actions": [{ "type": "speak", "target": target.get_instance_id(), "params": { "text": text, "duration": 1.0 } }],
}
stage._on_rule_editor_committed(rule)
return stage._event_rules[stage._event_rules.size() - 1]
func _clear_actions(rig: StickmanRig) -> void:
rig.action_queue.clear()
rig.stop_queue()
func _test_spawner_and_rule_contract() -> void:
var stage: Node2D = await _new_stage()
var area := _spawn_area(stage, Vector2(100.0, 100.0))
var source := _spawn_rig(stage, Vector2(-200.0, 100.0))
var target := _spawn_rig(stage, Vector2(400.0, 100.0))
_check(area != null and area.get_parent() == stage._world, "area spawns directly under World")
var rule := _add_rule(stage, source, area, target, "contract")
_check(stage._event_rules.size() == 1 and not (rule.get("actions", []) as Array).is_empty(), "live-ID entered_area rule is retained with actions")
_check(int((rule["trigger"] as Dictionary).get("source", -1)) == source.get_instance_id(), "rule preserves source instance ID")
await _free_stage(stage)
func _test_source_specific_edges() -> void:
var stage: Node2D = await _new_stage()
var area := _spawn_area(stage, Vector2.ZERO)
area.size = Vector2(120.0, 120.0)
var source := _spawn_rig(stage, Vector2(-1500.0, 0.0))
var other := _spawn_rig(stage, Vector2(-1500.0, 0.0))
var target := _spawn_rig(stage, Vector2(1500.0, 0.0))
_add_rule(stage, source, area, target, "source only")
stage._update_area_entry()
_move_rig_body_center(source, area.global_position)
var world_area_rect: Rect2 = (area.global_transform * area.get_area_rect()).abs()
_check(not world_area_rect.has_point(stage._movable_point(source)), "regression setup keeps feet outside the leg-level area")
stage._update_area_entry()
_check(target.action_queue.size() == 1, "source crossing queues exactly one consequence")
var action: Dictionary = target.action_queue[0]
_check(action.get("type", "") == "speak" and action.get("text", "") == "source only" and bool(action.get("reactive", false)), "speak consequence is normalized as a reactive action")
# SceneTree.physics_frame is emitted before node _physics_process callbacks;
# wait through the following tick so the runner can begin the action.
await physics_frame
await physics_frame
_check(target._speech_active and target._speech_bubble != null and target._speech_bubble.visible, "queued consequence executes as a visible speech bubble")
stage._update_area_entry()
_check(target.action_queue.size() == 1, "polling while inside does not repeat")
_move_rig_body_center(source, Vector2(-1500.0, 0.0))
stage._update_area_entry()
_move_rig_body_center(source, area.global_position)
stage._update_area_entry()
_check(target.action_queue.size() == 2, "exit then re-entry queues one additional consequence")
_clear_actions(target)
_move_rig_body_center(other, area.global_position)
stage._update_area_entry()
_check(target.action_queue.is_empty(), "different stickman cannot trigger source-specific rule")
var prop := stage._spawner.spawn("prop", Vector2(-1500.0, 0.0)) as PropBlock
stage._finalize_placed_node(prop)
prop.freeze = false
prop.global_position = Vector2.ZERO
stage._update_area_entry()
_check(target.action_queue.is_empty(), "unfrozen prop cannot trigger source-specific rule")
await _free_stage(stage)
func _test_wildcard_and_pair_independence() -> void:
var stage: Node2D = await _new_stage()
var area_a := _spawn_area(stage, Vector2.ZERO)
var area_b := _spawn_area(stage, Vector2(500.0, 0.0))
var source_a := _spawn_rig(stage, Vector2(-1500.0, 0.0))
var source_b := _spawn_rig(stage, Vector2(-1500.0, 0.0))
var target := _spawn_rig(stage, Vector2(2000.0, 0.0))
_add_rule(stage, null, area_a, target, "wildcard")
stage._update_area_entry()
_move_rig_body_center(source_a, area_a.global_position)
stage._update_area_entry()
_move_rig_body_center(source_a, Vector2(-1500.0, 0.0))
stage._update_area_entry()
_move_rig_body_center(source_b, area_a.global_position)
stage._update_area_entry()
_check(target.action_queue.size() == 2, "wildcard source fires independently for multiple movables")
_clear_actions(target)
_add_rule(stage, source_b, area_b, target, "second pair")
_move_rig_body_center(source_b, area_b.global_position)
stage._update_area_entry()
_check(target.action_queue.size() == 1, "second area/source pair has independent edge state")
await _free_stage(stage)
func _test_transforms_points_and_cleanup() -> void:
var stage: Node2D = await _new_stage()
var area := _spawn_area(stage, Vector2(300.0, 200.0))
area.size = Vector2(160.0, 80.0)
area.rotation = deg_to_rad(35.0)
area.scale = Vector2(1.4, 0.8)
var source := _spawn_rig(stage, Vector2(-300.0, 0.0))
var target := _spawn_rig(stage, Vector2(2000.0, 0.0))
_add_rule(stage, source, area, target, "transformed")
_check(stage._movable_point(source) == source.global_position - STICKMAN_RIG.FOOT_OFFSET, "stickman overlap point is feet, not root")
_move_rig_body_center(source, area.global_position)
stage._update_area_entry()
_check(target.action_queue.size() == 1, "current transformed area overlaps visible stickman geometry")
_clear_actions(target)
var no_rule_area := _spawn_area(stage, Vector2(-500.0, 0.0))
source.global_position = no_rule_area.global_position + STICKMAN_RIG.FOOT_OFFSET
stage._update_area_entry()
_check(target.action_queue.is_empty(), "area with no matching rule is a no-op")
var stale_rule := _add_rule(stage, source, area, target, "stale")
var stale_id := target.get_instance_id()
target.free()
_move_rig_body_center(source, Vector2(-1500.0, 0.0))
stage._update_area_entry()
_move_rig_body_center(source, area.global_position)
stage._update_area_entry()
_check(not is_instance_id_valid(stale_id), "stale action target is invalid and skipped safely")
_check(stage._event_rules.has(stale_rule), "stale target does not corrupt retained rule payload")
stage._selection.select_only(area)
stage.delete_selected()
await process_frame
_check(not stage._event_rules.any(func(rule: Dictionary) -> bool: return int((rule.get("trigger", {}) as Dictionary).get("target", -1)) == area.get_instance_id()), "stage deletion removes rules referencing deleted area")
await _free_stage(stage)
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)
+1
View File
@@ -0,0 +1 @@
uid://csig3ktbmuurl