Area2D

A trigger zone that fires body_entered / body_exited.

▶ Run in browser

Tags: 2d physics area trigger signals

A PhysicsBody2D shuttles left and right across the screen and repeatedly passes through a stationary Area2D sensor. The Area2D emits body_entered when the walker overlaps it and body_exited when it leaves; the demo recolours the zone while occupied and counts total entries.

What it demonstrates

  • Area2D as a broadphase sensor zone, given its geometry with the single-collider shape= shortcut (a rectangle).

  • Connecting to the body_entered / body_exited signals (payload is the body).

  • A PhysicsBody2D inside a PhysicsRoot2D driven by velocity each fixed step.

  • Reacting to overlap state: recolour the zone, count entries, live HUD.

Controls: ESC - Quit

Run: uv run python examples/features/2d/area2d.py Headless self-check: uv run python examples/features/2d/area2d.py –test

Source

  1"""Area2D: A trigger zone that fires body_entered / body_exited.
  2
  3A PhysicsBody2D shuttles left and right across the screen and repeatedly
  4passes through a stationary Area2D sensor. The Area2D emits body_entered when
  5the walker overlaps it and body_exited when it leaves; the demo recolours the
  6zone while occupied and counts total entries.
  7
  8# /// simvx
  9# tags = ["2d", "physics", "area", "trigger", "signals"]
 10# web = { root = "TriggerZoneDemo", width = 800, height = 600, responsive = true }
 11# ///
 12
 13## What it demonstrates
 14- Area2D as a broadphase sensor zone, given its geometry with the single-collider
 15  `shape=` shortcut (a rectangle).
 16- Connecting to the body_entered / body_exited signals (payload is the body).
 17- A PhysicsBody2D inside a PhysicsRoot2D driven by velocity each fixed step.
 18- Reacting to overlap state: recolour the zone, count entries, live HUD.
 19
 20Controls:
 21  ESC - Quit
 22
 23Run: uv run python examples/features/2d/area2d.py
 24Headless self-check: uv run python examples/features/2d/area2d.py --test
 25"""
 26
 27from simvx.core import (
 28    Area2D,
 29    BodyMode,
 30    CircleShape2D,
 31    Input,
 32    InputMap,
 33    Key,
 34    Node2D,
 35    PhysicsBody2D,
 36    PhysicsRoot2D,
 37    RectangleShape2D,
 38    Vec2,
 39)
 40from simvx.graphics import App
 41
 42WIDTH, HEIGHT = 800, 600
 43ZONE = Vec2(WIDTH / 2, HEIGHT / 2)
 44ZONE_HALF = Vec2(90, 130)  # half-extents of the rectangular sensor
 45WALKER_R = 22.0
 46WALK_SPEED = 260.0
 47
 48
 49class TriggerZoneDemo(Node2D):
 50    """A PhysicsBody2D shuttling through an Area2D sensor zone."""
 51
 52    dynamic = True  # walker shuttles + zone recolours every frame (physics state)
 53
 54    def on_ready(self):
 55        InputMap.add_action("quit", [Key.ESCAPE])
 56
 57        self._entry_count = 0
 58        self._inside = False
 59
 60        # One isolated 2D world (gravity off; the walker rides a horizontal line).
 61        # No Camera2D: the demo draws in screen pixels via on_draw.
 62        self._root = self.add_child(PhysicsRoot2D(name="World", gravity=Vec2(0, 0)))
 63
 64        # Sensor zone: a rectangular Area2D. `shape=` is the single-collider
 65        # shortcut (equivalent to a CollisionShape2D child) and can be swapped
 66        # live; the broadphase detects overlapping bodies each fixed step.
 67        self._zone = Area2D(name="Zone", position=Vec2(ZONE.x, ZONE.y), shape=RectangleShape2D(half_extents=ZONE_HALF))
 68        self._zone.body_entered.connect(self._on_body_entered)
 69        self._zone.body_exited.connect(self._on_body_exited)
 70        self._root.add_child(self._zone)
 71
 72        # The walker: a DYNAMIC PhysicsBody2D the broadphase can see, moved by
 73        # velocity. Bodies take the same `shape=` shortcut.
 74        self._walker = PhysicsBody2D(
 75            name="Walker",
 76            mode=BodyMode.DYNAMIC,
 77            mass=1.0,
 78            position=Vec2(120, ZONE.y),
 79            shape=CircleShape2D(WALKER_R),
 80        )
 81        self._root.add_child(self._walker)
 82        self._walker.velocity = Vec2(WALK_SPEED, 0)
 83
 84    def _on_body_entered(self, body):
 85        # Payload is the body that entered. Count it and flag occupancy.
 86        self._entry_count += 1
 87        self._inside = True
 88
 89    def _on_body_exited(self, body):
 90        self._inside = False
 91
 92    def on_update(self, dt: float):
 93        if Input.is_action_just_pressed("quit"):
 94            self.app.quit()
 95
 96    def on_fixed_update(self, dt: float):
 97        # Bounce off the side walls so the walker keeps crossing the zone.
 98        p = self._walker.world_position
 99        if p.x < WALKER_R and self._walker.velocity.x < 0:
100            self._walker.velocity = Vec2(WALK_SPEED, 0)
101        elif p.x > WIDTH - WALKER_R and self._walker.velocity.x > 0:
102            self._walker.velocity = Vec2(-WALK_SPEED, 0)
103
104    def on_draw(self, renderer):
105        # Zone: green when occupied, blue when empty.
106        zx, zy = self._zone.position.x, self._zone.position.y
107        top_left = (zx - ZONE_HALF.x, zy - ZONE_HALF.y)
108        size = (ZONE_HALF.x * 2, ZONE_HALF.y * 2)
109        fill = (0.2, 0.7, 0.3, 0.45) if self._inside else (0.2, 0.45, 0.9, 0.35)
110        renderer.draw_rect(top_left, size, colour=fill, filled=True)
111        renderer.draw_rect(top_left, size, colour=(0.85, 0.9, 1.0, 1.0), filled=False)
112
113        # Walker.
114        wp = self._walker.world_position
115        renderer.draw_circle((wp.x, wp.y), WALKER_R, colour=(1.0, 0.75, 0.2, 1.0), filled=True)
116
117        # HUD.
118        renderer.draw_text("Area2D Trigger Zone", (10, 10), colour=(1.0, 1.0, 1.0), scale=2)
119        state = "INSIDE" if self._inside else "outside"
120        renderer.draw_text(f"Walker: {state}   Entries: {self._entry_count}", (10, 40), colour=(0.75, 0.75, 0.75))
121        renderer.draw_text("ESC: quit", (10, HEIGHT - 28), colour=(0.6, 0.6, 0.6))
122
123
124def _selftest() -> bool:
125    """Headless: let the walker shuttle, and check the signals against the geometry.
126
127    The demo's own handlers own ``_inside`` and ``_entry_count``, so watching those
128    watches the real ``body_entered`` / ``body_exited`` wiring rather than a
129    listener the test added for itself.
130    """
131    from simvx.core.testing import InputSimulator
132    from simvx.graphics.testing import assert_not_blank, save_png
133
134    # Enough frames for several full crossings at WALK_SPEED across an 800px screen.
135    FRAMES = 600
136    #: The broadphase reports an overlap on the fixed step, so the flag can lag the
137    #: geometry by a step or two around each boundary.
138    LAG = 4
139
140    app = App(title="Area2D", width=WIDTH, height=HEIGHT, visible=False)
141    scene = TriggerZoneDemo(name="TriggerZoneDemo")
142    payloads: list[object] = []
143    track: list[tuple[bool, bool]] = []  # (overlapping by geometry, zone says occupied)
144    connected = False
145
146    def on_frame(idx: int, _t: float) -> bool:
147        nonlocal connected
148        if not connected:
149            # An extra listener on the same signal, purely to see what it carries.
150            scene._zone.body_entered.connect(payloads.append)
151            connected = True
152        x = float(scene._walker.world_position.x)
153        overlapping = abs(x - ZONE.x) < ZONE_HALF.x + WALKER_R
154        track.append((overlapping, scene._inside))
155        return True
156
157    frames = app.run_headless(scene, frames=FRAMES, on_frame=on_frame, capture_frames=[FRAMES - 1])
158    assert_not_blank(frames[0])
159    save_png(frames[0], "/tmp/area2d_test.png")
160
161    ok = True
162
163    def check(label: str, passed: bool, detail: str) -> None:
164        nonlocal ok
165        ok = ok and passed
166        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
167
168    crossings = sum(1 for i in range(1, len(track)) if track[i][0] and not track[i - 1][0])
169    check("the walker crossed the zone more than once", crossings >= 2, f"{crossings} crossings")
170    check(
171        "body_entered fired once per crossing",
172        scene._entry_count == crossings,
173        f"{scene._entry_count} entries for {crossings} crossings",
174    )
175    check(
176        "the signal carries the body that entered",
177        len(payloads) >= 1 and all(p is scene._walker for p in payloads),
178        f"{len(payloads)} payloads, all the walker: {all(p is scene._walker for p in payloads)}",
179    )
180
181    # body_exited must fire too, or the flag would latch on after the first entry.
182    check(
183        "body_exited cleared the flag again", not track[-1][1] or crossings == 0, f"occupied at the end: {track[-1][1]}"
184    )
185
186    # The flag agrees with the geometry on every frame that is not within a few
187    # steps of a boundary, which is where the broadphase's reporting lag lives.
188    def near_boundary(i: int) -> bool:
189        lo, hi = max(0, i - LAG), min(len(track), i + LAG + 1)
190        return any(track[j][0] != track[i][0] for j in range(lo, hi))
191
192    disagreements = [i for i, (geom, flag) in enumerate(track) if geom != flag and not near_boundary(i)]
193    check(
194        "occupancy tracks the overlap frame by frame",
195        not disagreements,
196        f"{len(disagreements)} frames disagreed out of {len(track)}",
197    )
198
199    # And the advertised quit key really ends the loop, through the action map.
200    sim = InputSimulator()
201    quit_scene = TriggerZoneDemo(name="TriggerZoneDemo")
202    last = [-1]
203
204    def quit_frame(idx: int, _t: float) -> bool:
205        last[0] = idx
206        if idx == 30:
207            sim.press_key(Key.ESCAPE)
208        elif idx == 31:
209            sim.release_key(Key.ESCAPE)
210        return True
211
212    App(title="Area2D quit", width=WIDTH, height=HEIGHT, visible=False).run_headless(
213        quit_scene, frames=200, on_frame=quit_frame
214    )
215    check("ESC ends the run", last[0] < 60, f"the loop stopped at frame {last[0]} of 200")
216
217    print("screenshot: /tmp/area2d_test.png")
218    print("SELFTEST:", "PASS" if ok else "FAIL")
219    return ok
220
221
222if __name__ == "__main__":
223    import sys
224
225    if "--test" in sys.argv:
226        sys.exit(0 if _selftest() else 1)
227    App(title="Area2D Trigger Zone", width=WIDTH, height=HEIGHT).run(TriggerZoneDemo())