Area2D¶
A trigger zone that fires body_entered / body_exited.
▶ Run in browserTags: 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())