11 KiB
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 inStageMode.PLAY.StageSpawner._spawn_area()already adds the committedTriggerAreadirectly toWorld._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 centeredRect2;_update_area_entry()converts it once witharea.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 withWeakRefwould violate the rule payload contract and breakint(trigger.target),instance_from_id(), deep-copy/edit, and deletion-cleanup paths. - The stickman event position is already its feet.
StickmanRig.FOOT_OFFSETis defined asVector2(0, -385)in the feet-to-root equationroot = feet + FOOT_OFFSET; therefore the inverse isfeet = root - FOOT_OFFSET. The diagnostic plan's proposedroot + FOOT_OFFSEThas 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_areaas 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:
{
"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:
{
"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/editors/stage/sandbox_stage.gd
-
Preserve the PLAY-only call in
_physics_process(_delta: float) -> void. -
Preserve
_update_area_entry() -> voidas the owner of area discovery, movable discovery, transformed world-AABB overlap, pairwise edge state, and event emission. -
Correct
_rule_matches(rule: Dictionary, event: Dictionary) -> boolsoentered_areamatches both ends of the trigger:"entered_area": return _ids_match(source_id, source) and _ids_match(target_id, target)This retains the existing
-1wildcard behavior through_ids_match()while ensuring a rule authored from Stickman A does not fire for Stickman B or a prop. -
Keep
_movable_point(node: Node2D) -> Vector2unchanged for stickmen:return node.global_position - StickmanRig.FOOT_OFFSET -
Use
StageSelection.get_world_aabb(node)for visible stickman/prop overlap. If a future movable has no bounds, fall back toarea_rect.has_point(_movable_point(node)). Keep the area's local rectangle contract and transform it once witharea.global_transformbefore comparing AABBs. -
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(). -
Do not ship the four unconditional diagnostic prints proposed in
docs/archive/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/stage/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:
- Spawning
"area"returns aTriggerAreawhose parent isstage._world. - An
entered_arearule built with live instance IDs is retained in_event_ruleswith non-empty actions. - A source stickman crossing from outside to inside adds exactly one reactive action to the configured target rig.
- Repeated polls while still inside do not add another action; an outside poll followed by another inside poll adds one more.
- A different stickman or an unfrozen prop entering the same area does not match a source-specific rule.
- A
source == -1rule remains a deliberate wildcard and fires independently for multiple movables. - Two areas and two source stickmen use independent pair keys; entering one pair does not suppress another.
- Moving/rotating/scaling an area uses its current global transform for containment.
- A stickman's event
positionequalsrig.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. - An area with no matching rule is a no-op with no errors.
- A stale/deleted action target is skipped without a crash. Deleting through the stage path removes rules referencing the deleted area.
- 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/stage/sandbox_stage.tscnroot): 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, andStickmanRig: exercised through their existing contracts; no implementation change expected.
Signal flow
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:
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://scripts/editors/stage/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.gdandtests/test_phase4c_area.gdpassed Godot--check-onlysyntax validation.tests/test_phase4c_area.gdreportedRESULT: 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/stage/sandbox_stage.tscn, F6) completed successfully:
- Place Stickman A, Stickman B, and an Area.
- In DIRECT, queue A to walk through the Area.
- Author
When A enters Area -> B speaks. - Enter PLAY: B speaks once as A's visible body overlaps the area, even when the feet anchor remains outside.
- Keep A inside for five seconds: no repeat.
- Return to EDIT/DIRECT, author a route that exits and re-enters, then PLAY: B speaks once on each entry.
- 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.
-1remains available for existing/programmatic "any movable" rules.
Out of scope / documentation follow-up
exited_areaandstay_in_areatriggers.- Physics
Area2Dconversion 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/architecture/tech_debt_and_optimizations.md.