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