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:
@@ -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.
|
||||||
|
"""
|
||||||
@@ -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.
|
||||||
|
"""
|
||||||
@@ -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.
|
||||||
|
"""
|
||||||
@@ -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."_
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://csig3ktbmuurl
|
||||||
Reference in New Issue
Block a user