Asteroids 2D¶
Classic arcade game with wrap-around physics.
▶ Run in browserTags: game collision wrap shooting
(No additional documentation. See source below.)
Source¶
1#!/usr/bin/env python3
2"""Asteroids 2D: Classic arcade game with wrap-around physics.
3
4# /// simvx
5# tags = ["game", "collision", "wrap", "shooting"]
6# web = { root = "MainMenu" }
7# ///
8"""
9
10
11import math
12import random
13
14from simvx.core import (
15 # Input
16 Input,
17 InputMap,
18 Key,
19 MouseButton,
20 Node,
21 Node2D,
22 # Engine
23 Property,
24 Signal,
25 Timer,
26 # Math
27 Vec2,
28)
29from simvx.graphics import App
30
31WIDTH, HEIGHT = 800, 600
32
33# Ship shapes: local-space points drawn by Node2D.draw_polygon
34SHIP_SHAPE = [Vec2(0, -12), Vec2(-8, 10), Vec2(8, 10)]
35THRUST_SHAPE = [Vec2(-5, 10), Vec2(0, 18), Vec2(5, 10)]
36
37
38class Body2D(Node2D):
39 """A manually integrated 2D actor with a collision radius + group overlap query.
40
41 Asteroids is a wrap-around arcade game with NO physics simulation: every actor
42 integrates ``position += velocity * dt`` itself and collisions are plain
43 circle-vs-circle radius tests. This is deliberately NOT a ``CharacterBody2D``
44 (the seam character runs a stepped collide-and-slide world); it is a light
45 ``Node2D`` that carries a radius and a group-scoped overlap poll.
46 """
47
48 def __init__(self, radius: float = 8.0, **kwargs):
49 super().__init__(**kwargs)
50 self.radius = float(radius)
51 self.velocity = Vec2()
52
53 def get_overlapping(self, group: str) -> list[Body2D]:
54 """Other ``Body2D`` nodes in ``group`` whose radius overlaps ours."""
55 if not self.tree:
56 return []
57 hits: list[Body2D] = []
58 for b in self.tree.get_group(group):
59 if b is self or not isinstance(b, Body2D):
60 continue
61 d = b.world_position - self.world_position
62 rr = self.radius + b.radius
63 if float(d.x) ** 2 + float(d.y) ** 2 <= rr * rr:
64 hits.append(b)
65 return hits
66
67
68def random_asteroid_shape(radius: float, verts=10) -> list[Vec2]:
69 return [
70 Vec2(math.cos(a) * radius * random.uniform(0.7, 1.3), math.sin(a) * radius * random.uniform(0.7, 1.3))
71 for a in (i / verts * math.tau for i in range(verts))
72 ]
73
74
75# ============================================================================
76# Ship
77# ============================================================================
78
79
80class Ship(Body2D):
81 turn_speed = Property(200.0, range=(50, 400), hint="Degrees per second")
82 thrust_power = Property(300.0, range=(50, 800))
83 max_speed = Property(400.0, range=(100, 1000))
84 drag = Property(0.98, range=(0.9, 1.0))
85
86 def __init__(self, **kwargs):
87 super().__init__(radius=10, **kwargs)
88 self.fired = Signal()
89 self.died = Signal()
90 self._thrusting = False
91 self._invincible = 0.0
92 self._visible = True
93
94 self.fire_timer = self.add_child(Timer(0.15, name="FireTimer"))
95
96 def on_ready(self):
97 self.position = Vec2(WIDTH / 2, HEIGHT / 2)
98
99 def on_fixed_update(self, dt: float):
100 # Turning
101 if Input.is_action_pressed("turn_left"):
102 self.rotation -= math.radians(self.turn_speed) * dt
103 if Input.is_action_pressed("turn_right"):
104 self.rotation += math.radians(self.turn_speed) * dt
105
106 # Pointer controls: hold/drag to steer toward the pointer, thrusting and
107 # firing while held (web touch arrives as MouseButton.LEFT).
108 pointer_held = Input.is_mouse_button_pressed(MouseButton.LEFT)
109 if pointer_held:
110 d = Input.mouse_position - self.world_position
111 if d.length() > self.radius * 2:
112 target = math.atan2(float(d.x), -float(d.y))
113 diff = (target - self.rotation + math.pi) % math.tau - math.pi
114 step = math.radians(self.turn_speed) * dt
115 self.rotation += max(-step, min(step, diff))
116
117 # Thrust
118 self._thrusting = Input.is_action_pressed("thrust") or pointer_held
119 if self._thrusting:
120 self.velocity += self.forward * (self.thrust_power * dt)
121 speed = self.velocity.length()
122 if speed > self.max_speed:
123 self.velocity = self.velocity.normalized() * self.max_speed
124
125 self.velocity *= self.drag
126 self.position += self.velocity * dt
127 self.wrap_screen()
128
129 # Shooting (timer prevents rapid-fire)
130 if (Input.is_action_pressed("fire") or pointer_held) and self.fire_timer.stopped:
131 self.fire_timer.start()
132 self.fired.emit()
133
134 # Invincibility blink
135 if self._invincible > 0:
136 self._invincible -= dt
137 self._visible = int(self._invincible * 10) % 2 == 0
138 else:
139 self._visible = True
140
141 def on_draw(self, renderer):
142 if not self._visible:
143 return
144 self.draw_polygon(renderer, SHIP_SHAPE)
145 if self._thrusting:
146 self.draw_polygon(renderer, THRUST_SHAPE)
147
148 def respawn(self):
149 self.position = Vec2(WIDTH / 2, HEIGHT / 2)
150 self.velocity = Vec2()
151 self.rotation = 0.0
152 self._invincible = 2.0
153
154 @property
155 def is_invincible(self):
156 return self._invincible > 0
157
158
159# ============================================================================
160# Bullet
161# ============================================================================
162
163
164class Bullet(Body2D):
165 speed = Property(500.0)
166
167 def __init__(self, direction: Vec2 = None, **kwargs):
168 super().__init__(radius=2, **kwargs)
169 self.add_to_group("bullets")
170 if direction:
171 self.velocity = direction * self.speed
172
173 # Auto-expire via timer
174 t = self.add_child(Timer(1.5, name="Lifetime"))
175 t.timeout.connect(self.destroy)
176 t.start()
177
178 def on_fixed_update(self, dt: float):
179 self.position += self.velocity * dt
180 self.wrap_screen()
181
182 def on_draw(self, renderer):
183 renderer.draw_circle(self.position, 2, segments=6)
184
185
186# ============================================================================
187# Asteroid
188# ============================================================================
189
190SIZES = {"large": 40, "medium": 20, "small": 10}
191SCORES = {"large": 20, "medium": 50, "small": 100}
192
193
194class Asteroid(Body2D):
195 size_class = Property("large", enum=["large", "medium", "small"])
196
197 def __init__(self, size_class="large", **kwargs):
198 radius = SIZES[size_class]
199 super().__init__(radius=radius, **kwargs)
200 self.size_class = size_class
201 self.add_to_group("asteroids")
202 self._shape = random_asteroid_shape(radius)
203 self._spin = math.radians(random.uniform(-90, 90))
204 # Random velocity
205 angle = random.uniform(0, math.tau)
206 speed = random.uniform(40, 120)
207 self.velocity = Vec2(math.cos(angle), math.sin(angle)) * speed
208
209 def on_fixed_update(self, dt: float):
210 self.position += self.velocity * dt
211 self.wrap_screen(margin=SIZES[self.size_class])
212 self.rotation += self._spin * dt
213
214 def on_draw(self, renderer):
215 self.draw_polygon(renderer, self._shape)
216
217 def split(self) -> list[Asteroid]:
218 next_size = {"large": "medium", "medium": "small"}.get(self.size_class)
219 if not next_size:
220 return []
221 return [Asteroid(name="Asteroid", size_class=next_size, position=Vec2(self.position)) for _ in range(2)]
222
223
224# ============================================================================
225# MainMenu
226# ============================================================================
227
228
229class MainMenu(Node):
230 # on_draw blinks "PRESS ENTER" from the per-frame _blink timer (no Property),
231 # so it must re-run every frame under retained 2D.
232 dynamic = True
233
234 def __init__(self, **kwargs):
235 super().__init__(name="MainMenu", **kwargs)
236 self._blink = 0.0
237
238 def on_ready(self):
239 InputMap.add_action("thrust", [Key.W, Key.UP])
240 InputMap.add_action("turn_left", [Key.A, Key.LEFT])
241 InputMap.add_action("turn_right", [Key.D, Key.RIGHT])
242 InputMap.add_action("fire", [Key.SPACE])
243 InputMap.add_action("start", [Key.ENTER, MouseButton.LEFT])
244
245 def on_update(self, dt):
246 self._blink += dt
247 if Input.is_action_just_pressed("start"):
248 self.tree.change_scene(AsteroidsGame())
249
250 def on_draw(self, renderer):
251 title = "ASTEROIDS"
252 tw = renderer.text_width(title, 6)
253 renderer.draw_text(title, (WIDTH // 2 - tw // 2, 120), scale=6, colour=(1.0, 1.0, 1.0))
254
255 # Draw decorative ship
256 cx = WIDTH // 2
257 pts = Node2D(position=Vec2(cx, 300)).transform_points([p * 2.5 for p in SHIP_SHAPE])
258 renderer.draw_lines(pts, closed=True)
259
260 # Controls
261 renderer.draw_text("W/UP THRUST", (cx - 100, 370), scale=2, colour=(0.71, 0.71, 0.71))
262 renderer.draw_text("A/D TURN", (cx - 100, 395), scale=2, colour=(0.71, 0.71, 0.71))
263 renderer.draw_text("SPACE FIRE", (cx - 100, 420), scale=2, colour=(0.71, 0.71, 0.71))
264 renderer.draw_text("TOUCH HOLD TO FLY + FIRE", (cx - 100, 445), scale=2, colour=(0.71, 0.71, 0.71))
265
266 if int(self._blink * 2) % 2 == 0:
267 prompt = "PRESS ENTER OR TAP TO START"
268 pw = renderer.text_width(prompt, 3)
269 renderer.draw_text(prompt, (WIDTH // 2 - pw // 2, 490), scale=3, colour=(0.78, 0.78, 0.78))
270
271
272# ============================================================================
273# Game Scene
274# ============================================================================
275
276
277class AsteroidsGame(Node2D):
278 # on_draw reads the plain _score/_lives counters (no Property), so the HUD
279 # must re-run every frame under retained 2D.
280 dynamic = True
281
282 start_asteroids = Property(4, range=(1, 12))
283 lives = Property(3, range=(1, 10))
284
285 def __init__(self, **kwargs):
286 super().__init__(name="AsteroidsGame", **kwargs)
287 self.ship = self.add_child(Ship(name="Ship"))
288 self._score = 0
289 self._lives = self.lives
290 self._wave = 0
291
292 def on_ready(self):
293 @self.ship.fired.connect
294 def on_fire():
295 fwd = self.ship.forward
296 self.add_child(
297 Bullet(
298 name="Bullet",
299 position=Vec2(self.ship.position) + fwd * 15,
300 direction=fwd,
301 )
302 )
303
304 @self.ship.died.connect
305 def on_died():
306 self._lives -= 1
307 if self._lives <= 0:
308 self.tree.change_scene(GameOver(self._score))
309 return
310 self.ship.respawn()
311
312 self._spawn_wave()
313
314 def _spawn_wave(self):
315 self._wave += 1
316 for i in range(self.start_asteroids + self._wave - 1):
317 pos = Vec2(
318 random.choice([random.uniform(0, 100), random.uniform(WIDTH - 100, WIDTH)]),
319 random.choice([random.uniform(0, 100), random.uniform(HEIGHT - 100, HEIGHT)]),
320 )
321 self.add_child(Asteroid(name=f"Asteroid{i}", position=pos))
322
323 def on_fixed_update(self, dt: float):
324 if not self.tree:
325 return
326 # Bullet-asteroid collisions via groups
327 for bullet in self.tree.get_group("bullets"):
328 for asteroid in bullet.get_overlapping(group="asteroids"):
329 self._score += SCORES[asteroid.size_class]
330 for piece in asteroid.split():
331 self.add_child(piece)
332 asteroid.destroy()
333 bullet.destroy()
334 break
335
336 # Ship-asteroid collisions
337 if not self.ship.is_invincible:
338 if self.ship.get_overlapping(group="asteroids"):
339 self.ship.died()
340
341 # Next wave?
342 if not self.tree or not self.tree.get_group("asteroids"):
343 self._spawn_wave()
344
345 def on_draw(self, renderer):
346 # HUD: score
347 renderer.draw_text(f"SCORE {self._score:05d}", (10, 10), scale=2, colour=(1.0, 1.0, 1.0))
348 # HUD: draw remaining lives as small ships
349 for i in range(self._lives):
350 pts = Node2D(position=Vec2(WIDTH - 80 + i * 25, 18)).transform_points(SHIP_SHAPE)
351 renderer.draw_lines(pts, closed=True)
352
353
354# ============================================================================
355# GameOver
356# ============================================================================
357
358
359class GameOver(Node):
360 # on_draw blinks the continue prompt from the per-frame _blink timer
361 # (no Property), so it must re-run every frame under retained 2D.
362 dynamic = True
363
364 def __init__(self, score=0, **kwargs):
365 super().__init__(name="GameOver", **kwargs)
366 self.score = score
367 self._blink = 0.0
368
369 def on_update(self, dt):
370 self._blink += dt
371 if Input.is_action_just_pressed("start"):
372 self.tree.change_scene(MainMenu())
373
374 def on_draw(self, renderer):
375 title = "GAME OVER"
376 tw = renderer.text_width(title, 5)
377 renderer.draw_text(title, (WIDTH // 2 - tw // 2, 180), scale=5, colour=(1.0, 0.2, 0.2))
378
379 score_text = f"SCORE {self.score:05d}"
380 sw = renderer.text_width(score_text, 3)
381 renderer.draw_text(score_text, (WIDTH // 2 - sw // 2, 300), scale=3, colour=(1.0, 1.0, 1.0))
382
383 if int(self._blink * 2) % 2 == 0:
384 prompt = "PRESS ENTER OR TAP TO CONTINUE"
385 pw = renderer.text_width(prompt, 2)
386 renderer.draw_text(prompt, (WIDTH // 2 - pw // 2, 400), scale=2, colour=(0.78, 0.78, 0.78))
387
388
389# ============================================================================
390# Main
391# ============================================================================
392
393
394if __name__ == "__main__":
395 App("Asteroids", WIDTH, HEIGHT).run(MainMenu())