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 # Nodes
16 CharacterBody2D,
17 # Input
18 Input,
19 InputMap,
20 Key,
21 Node,
22 Node2D,
23 # Engine
24 Property,
25 Signal,
26 Timer,
27 # Math
28 Vec2,
29)
30from simvx.graphics import App
31
32WIDTH, HEIGHT = 800, 600
33
34# Ship shapes: local-space points drawn by Node2D.draw_polygon
35SHIP_SHAPE = [Vec2(0, -12), Vec2(-8, 10), Vec2(8, 10)]
36THRUST_SHAPE = [Vec2(-5, 10), Vec2(0, 18), Vec2(5, 10)]
37
38
39def random_asteroid_shape(radius: float, verts=10) -> list[Vec2]:
40 return [
41 Vec2(math.cos(a) * radius * random.uniform(0.7, 1.3), math.sin(a) * radius * random.uniform(0.7, 1.3))
42 for a in (i / verts * math.tau for i in range(verts))
43 ]
44
45
46# ============================================================================
47# Ship
48# ============================================================================
49
50
51class Ship(CharacterBody2D):
52 turn_speed = Property(200.0, range=(50, 400), hint="Degrees per second")
53 thrust_power = Property(300.0, range=(50, 800))
54 max_speed = Property(400.0, range=(100, 1000))
55 drag = Property(0.98, range=(0.9, 1.0))
56
57 def __init__(self, **kwargs):
58 super().__init__(collision=10, **kwargs)
59 self.fired = Signal()
60 self.died = Signal()
61 self._thrusting = False
62 self._invincible = 0.0
63 self._visible = True
64
65 self.fire_timer = self.add_child(Timer(0.15, name="FireTimer"))
66
67 def on_ready(self):
68 self.position = Vec2(WIDTH / 2, HEIGHT / 2)
69
70 def on_physics_process(self, dt: float):
71 # Turning
72 if Input.is_action_pressed("turn_left"):
73 self.rotation -= math.radians(self.turn_speed) * dt
74 if Input.is_action_pressed("turn_right"):
75 self.rotation += math.radians(self.turn_speed) * dt
76
77 # Thrust
78 self._thrusting = Input.is_action_pressed("thrust")
79 if self._thrusting:
80 self.velocity += self.forward * (self.thrust_power * dt)
81 speed = self.velocity.length()
82 if speed > self.max_speed:
83 self.velocity = self.velocity.normalized() * self.max_speed
84
85 self.velocity *= self.drag
86 self.position += self.velocity * dt
87 self.wrap_screen()
88
89 # Shooting (timer prevents rapid-fire)
90 if Input.is_action_pressed("fire") and self.fire_timer.stopped:
91 self.fire_timer.start()
92 self.fired.emit()
93
94 # Invincibility blink
95 if self._invincible > 0:
96 self._invincible -= dt
97 self._visible = int(self._invincible * 10) % 2 == 0
98 else:
99 self._visible = True
100
101 def on_draw(self, renderer):
102 if not self._visible:
103 return
104 self.draw_polygon(renderer, SHIP_SHAPE)
105 if self._thrusting:
106 self.draw_polygon(renderer, THRUST_SHAPE)
107
108 def respawn(self):
109 self.position = Vec2(WIDTH / 2, HEIGHT / 2)
110 self.velocity = Vec2()
111 self.rotation = 0.0
112 self._invincible = 2.0
113
114 @property
115 def is_invincible(self):
116 return self._invincible > 0
117
118
119# ============================================================================
120# Bullet
121# ============================================================================
122
123
124class Bullet(CharacterBody2D):
125 speed = Property(500.0)
126
127 def __init__(self, direction: Vec2 = None, **kwargs):
128 super().__init__(collision=2, **kwargs)
129 self.add_to_group("bullets")
130 if direction:
131 self.velocity = direction * self.speed
132
133 # Auto-expire via timer
134 t = self.add_child(Timer(1.5, name="Lifetime"))
135 t.timeout.connect(self.destroy)
136 t.start()
137
138 def on_physics_process(self, dt: float):
139 self.position += self.velocity * dt
140 self.wrap_screen()
141
142 def on_draw(self, renderer):
143 renderer.draw_circle(self.position, 2, segments=6)
144
145
146# ============================================================================
147# Asteroid
148# ============================================================================
149
150SIZES = {"large": 40, "medium": 20, "small": 10}
151SCORES = {"large": 20, "medium": 50, "small": 100}
152
153
154class Asteroid(CharacterBody2D):
155 size_class = Property("large", enum=["large", "medium", "small"])
156
157 def __init__(self, size_class="large", **kwargs):
158 radius = SIZES[size_class]
159 super().__init__(collision=radius, **kwargs)
160 self.size_class = size_class
161 self.add_to_group("asteroids")
162 self._shape = random_asteroid_shape(radius)
163 self._spin = math.radians(random.uniform(-90, 90))
164 # Random velocity
165 angle = random.uniform(0, math.tau)
166 speed = random.uniform(40, 120)
167 self.velocity = Vec2(math.cos(angle), math.sin(angle)) * speed
168
169 def on_physics_process(self, dt: float):
170 self.position += self.velocity * dt
171 self.wrap_screen(margin=SIZES[self.size_class])
172 self.rotation += self._spin * dt
173
174 def on_draw(self, renderer):
175 self.draw_polygon(renderer, self._shape)
176
177 def split(self) -> list[Asteroid]:
178 next_size = {"large": "medium", "medium": "small"}.get(self.size_class)
179 if not next_size:
180 return []
181 return [Asteroid(name="Asteroid", size_class=next_size, position=Vec2(self.position)) for _ in range(2)]
182
183
184# ============================================================================
185# MainMenu
186# ============================================================================
187
188
189class MainMenu(Node):
190 def __init__(self, **kwargs):
191 super().__init__(name="MainMenu", **kwargs)
192 self._blink = 0.0
193
194 def on_ready(self):
195 InputMap.add_action("thrust", [Key.W, Key.UP])
196 InputMap.add_action("turn_left", [Key.A, Key.LEFT])
197 InputMap.add_action("turn_right", [Key.D, Key.RIGHT])
198 InputMap.add_action("fire", [Key.SPACE])
199 InputMap.add_action("start", [Key.ENTER])
200
201 def on_process(self, dt):
202 self._blink += dt
203 if Input.is_action_just_pressed("start"):
204 self.tree.change_scene(AsteroidsGame())
205
206 def on_draw(self, renderer):
207 title = "ASTEROIDS"
208 tw = renderer.text_width(title, 6)
209 renderer.draw_text(title, (WIDTH // 2 - tw // 2, 120), scale=6, colour=(1.0, 1.0, 1.0))
210
211 # Draw decorative ship
212 cx = WIDTH // 2
213 pts = Node2D(position=Vec2(cx, 300)).transform_points([p * 2.5 for p in SHIP_SHAPE])
214 renderer.draw_lines(pts, closed=True)
215
216 # Controls
217 renderer.draw_text("W/UP THRUST", (cx - 100, 370), scale=2, colour=(0.71, 0.71, 0.71))
218 renderer.draw_text("A/D TURN", (cx - 100, 395), scale=2, colour=(0.71, 0.71, 0.71))
219 renderer.draw_text("SPACE FIRE", (cx - 100, 420), scale=2, colour=(0.71, 0.71, 0.71))
220
221 if int(self._blink * 2) % 2 == 0:
222 prompt = "PRESS ENTER TO START"
223 pw = renderer.text_width(prompt, 3)
224 renderer.draw_text(prompt, (WIDTH // 2 - pw // 2, 490), scale=3, colour=(0.78, 0.78, 0.78))
225
226
227# ============================================================================
228# Game Scene
229# ============================================================================
230
231
232class AsteroidsGame(Node2D):
233 start_asteroids = Property(4, range=(1, 12))
234 lives = Property(3, range=(1, 10))
235
236 def __init__(self, **kwargs):
237 super().__init__(name="AsteroidsGame", **kwargs)
238 self.ship = self.add_child(Ship(name="Ship"))
239 self._score = 0
240 self._lives = self.lives
241 self._wave = 0
242
243 def on_ready(self):
244 @self.ship.fired.connect
245 def on_fire():
246 fwd = self.ship.forward
247 self.add_child(
248 Bullet(
249 name="Bullet",
250 position=Vec2(self.ship.position) + fwd * 15,
251 direction=fwd,
252 )
253 )
254
255 @self.ship.died.connect
256 def on_died():
257 self._lives -= 1
258 if self._lives <= 0:
259 self.tree.change_scene(GameOver(self._score))
260 return
261 self.ship.respawn()
262
263 self._spawn_wave()
264
265 def _spawn_wave(self):
266 self._wave += 1
267 for i in range(self.start_asteroids + self._wave - 1):
268 pos = Vec2(
269 random.choice([random.uniform(0, 100), random.uniform(WIDTH - 100, WIDTH)]),
270 random.choice([random.uniform(0, 100), random.uniform(HEIGHT - 100, HEIGHT)]),
271 )
272 self.add_child(Asteroid(name=f"Asteroid{i}", position=pos))
273
274 def on_physics_process(self, dt: float):
275 if not self.tree:
276 return
277 # Bullet-asteroid collisions via groups
278 for bullet in self.tree.get_group("bullets"):
279 for asteroid in bullet.get_overlapping(group="asteroids"):
280 self._score += SCORES[asteroid.size_class]
281 for piece in asteroid.split():
282 self.add_child(piece)
283 asteroid.destroy()
284 bullet.destroy()
285 break
286
287 # Ship-asteroid collisions
288 if not self.ship.is_invincible:
289 if self.ship.get_overlapping(group="asteroids"):
290 self.ship.died()
291
292 # Next wave?
293 if not self.tree or not self.tree.get_group("asteroids"):
294 self._spawn_wave()
295
296 def on_draw(self, renderer):
297 # HUD: score
298 renderer.draw_text(f"SCORE {self._score:05d}", (10, 10), scale=2, colour=(1.0, 1.0, 1.0))
299 # HUD: draw remaining lives as small ships
300 for i in range(self._lives):
301 pts = Node2D(position=Vec2(WIDTH - 80 + i * 25, 18)).transform_points(SHIP_SHAPE)
302 renderer.draw_lines(pts, closed=True)
303
304
305# ============================================================================
306# GameOver
307# ============================================================================
308
309
310class GameOver(Node):
311 def __init__(self, score=0, **kwargs):
312 super().__init__(name="GameOver", **kwargs)
313 self.score = score
314 self._blink = 0.0
315
316 def on_process(self, dt):
317 self._blink += dt
318 if Input.is_action_just_pressed("start"):
319 self.tree.change_scene(MainMenu())
320
321 def on_draw(self, renderer):
322 title = "GAME OVER"
323 tw = renderer.text_width(title, 5)
324 renderer.draw_text(title, (WIDTH // 2 - tw // 2, 180), scale=5, colour=(1.0, 0.2, 0.2))
325
326 score_text = f"SCORE {self.score:05d}"
327 sw = renderer.text_width(score_text, 3)
328 renderer.draw_text(score_text, (WIDTH // 2 - sw // 2, 300), scale=3, colour=(1.0, 1.0, 1.0))
329
330 if int(self._blink * 2) % 2 == 0:
331 prompt = "PRESS ENTER TO CONTINUE"
332 pw = renderer.text_width(prompt, 2)
333 renderer.draw_text(prompt, (WIDTH // 2 - pw // 2, 400), scale=2, colour=(0.78, 0.78, 0.78))
334
335
336# ============================================================================
337# Main
338# ============================================================================
339
340
341if __name__ == "__main__":
342 App("Asteroids", WIDTH, HEIGHT).run(MainMenu())