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