nodes/asteroids_game.py¶
Part of Asteroids (raylib classic).
1"""Asteroids: port of raylib-games/classics/src/asteroids.c (zlib/libpng).
2
3Vector ship physics, screen wrap, asteroids split large to medium to small on
4hit, particle bursts on destruction. Resize-aware playfield, playable with the
5keyboard or with a held pointer (mouse or touch).
6"""
7
8from __future__ import annotations
9
10import math
11import random
12from dataclasses import dataclass
13
14from simvx.core import Input, InputMap, Key, MouseButton, Node, Property
15
16# Logical baseline; the actual play area is whatever the window is.
17WIDTH = 800
18HEIGHT = 600
19
20SHIP_ROTATE_SPEED = math.radians(220.0)
21SHIP_THRUST = 240.0
22SHIP_DRAG = 0.992
23SHIP_SIZE = 12.0
24BULLET_SPEED = 480.0
25BULLET_LIFE = 1.4
26SHOOT_COOLDOWN = 0.18
27
28SIZE_LARGE = 56
29SIZE_MEDIUM = 32
30SIZE_SMALL = 16
31
32# Pointer flying: holding the pointer steers the ship towards it, thrusts and
33# fires. Inside this radius the pointer is "on" the ship, so heading is held.
34POINTER_DEAD_ZONE = 24.0
35
36START_PROMPT = "PRESS [SPACE] OR TAP TO START"
37AGAIN_PROMPT = "PRESS [SPACE] OR TAP TO PLAY AGAIN"
38KEYS_HINT = "ARROWS/WASD: ROTATE+THRUST SPACE: SHOOT"
39POINTER_HINT = "HOLD MOUSE OR TOUCH: STEER, THRUST, FIRE"
40
41WHITE = (1.0, 1.0, 1.0, 1.0)
42SHIP_C = (0.85, 0.95, 1.0, 1.0)
43THRUST_C = (1.0, 0.6, 0.2, 1.0)
44ROCK_C = (0.85, 0.85, 0.92, 1.0)
45HINT_COLOUR = (0.70, 0.70, 0.70, 1.0)
46BG_C = (0.04, 0.04, 0.07, 1.0)
47
48STATE_MENU = "menu"
49STATE_PLAY = "play"
50STATE_OVER = "over"
51
52
53def _wrap(x, lo, hi):
54 if x < lo:
55 return hi - (lo - x)
56 if x > hi:
57 return lo + (x - hi)
58 return x
59
60
61@dataclass
62class Bullet:
63 x: float
64 y: float
65 vx: float
66 vy: float
67 life: float = BULLET_LIFE
68
69
70@dataclass
71class Particle:
72 x: float
73 y: float
74 vx: float
75 vy: float
76 life: float
77
78
79class Asteroid:
80 __slots__ = ("x", "y", "vx", "vy", "size", "shape", "spin", "angle")
81
82 def __init__(self, x: float, y: float, size: int):
83 self.x = x
84 self.y = y
85 self.size = size
86 speed = random.uniform(40.0, 90.0) * (1.0 + (SIZE_LARGE - size) / 70)
87 ang = random.uniform(0, math.tau)
88 self.vx = math.cos(ang) * speed
89 self.vy = math.sin(ang) * speed
90 self.angle = 0.0
91 self.spin = random.uniform(-1.5, 1.5)
92 n = random.randint(8, 11)
93 self.shape = [
94 (
95 math.cos(i * math.tau / n) * size * random.uniform(0.7, 1.0),
96 math.sin(i * math.tau / n) * size * random.uniform(0.7, 1.0),
97 )
98 for i in range(n)
99 ]
100
101 def update(self, dt: float, w: float, h: float) -> None:
102 self.x = _wrap(self.x + self.vx * dt, 0, w)
103 self.y = _wrap(self.y + self.vy * dt, 0, h)
104 self.angle += self.spin * dt
105
106 def draw(self, renderer) -> None:
107 c = math.cos(self.angle)
108 s = math.sin(self.angle)
109 pts = [(self.x + px * c - py * s, self.y + px * s + py * c) for px, py in self.shape]
110 for i in range(len(pts)):
111 x0, y0 = pts[i]
112 x1, y1 = pts[(i + 1) % len(pts)]
113 renderer.draw_line((x0, y0), (x1, y1), colour=ROCK_C, thickness=2.0)
114
115
116def _spawn_at_edge(w: float, h: float) -> tuple[float, float]:
117 edge = random.choice("tblr")
118 if edge == "t":
119 return random.uniform(0, w), 0
120 if edge == "b":
121 return random.uniform(0, w), h
122 if edge == "l":
123 return 0, random.uniform(0, h)
124 return w, random.uniform(0, h)
125
126
127class AsteroidsGame(Node):
128 dynamic = True # ship, asteroids, bullets, particles move every frame
129
130 score = Property(0)
131
132 def __init__(self, **kw):
133 super().__init__(**kw)
134 self._state = STATE_MENU
135 self._screen_w = WIDTH
136 self._screen_h = HEIGHT
137 self._reset_world()
138
139 def _reset_world(self) -> None:
140 self.score = 0
141 self._lives = 3
142 self._dead = False
143 self._x = self._screen_w / 2
144 self._y = self._screen_h / 2
145 self._vx = 0.0
146 self._vy = 0.0
147 self._heading = -math.pi / 2
148 self._thrusting = False
149 self._cooldown = 0.0
150 self._invincible = 1.5
151 self._respawn_after = 0.0
152 self._bullets: list[Bullet] = []
153 self._particles: list[Particle] = []
154 self._asteroids: list[Asteroid] = [
155 Asteroid(*_spawn_at_edge(self._screen_w, self._screen_h), SIZE_LARGE) for _ in range(4)
156 ]
157
158 def _refresh_screen(self) -> None:
159 if self.tree:
160 self._screen_w, self._screen_h = self.tree.screen_size
161
162 def on_ready(self) -> None:
163 InputMap.add_action("thrust", [Key.UP, Key.W])
164 InputMap.add_action("rot_left", [Key.LEFT, Key.A])
165 InputMap.add_action("rot_right", [Key.RIGHT, Key.D])
166 InputMap.add_action("shoot", [Key.SPACE])
167 # Mouse / touch (a touch reports as MouseButton.LEFT on the web build):
168 # a tap starts or restarts, and holding flies the ship.
169 InputMap.add_action("restart", [Key.R, Key.ENTER, Key.SPACE, MouseButton.LEFT])
170 InputMap.add_action("start", [Key.SPACE, Key.R, Key.ENTER, MouseButton.LEFT])
171 InputMap.add_action("fly", [MouseButton.LEFT])
172 InputMap.add_action("quit", [Key.ESCAPE])
173
174 def on_update(self, dt: float) -> None:
175 if Input.is_action_just_pressed("quit"):
176 self.app.quit()
177 return
178
179 self._refresh_screen()
180
181 if self._state == STATE_MENU:
182 if Input.is_action_just_pressed("start"):
183 self._reset_world()
184 self._state = STATE_PLAY
185 return
186
187 if self._state == STATE_OVER:
188 if Input.is_action_just_pressed("restart"):
189 self._reset_world()
190 self._state = STATE_PLAY
191 return
192
193 # ---- play ----
194 w, h = self._screen_w, self._screen_h
195
196 if Input.is_action_pressed("rot_left"):
197 self._heading -= SHIP_ROTATE_SPEED * dt
198 if Input.is_action_pressed("rot_right"):
199 self._heading += SHIP_ROTATE_SPEED * dt
200
201 flying = Input.is_action_pressed("fly")
202 if flying:
203 self._steer_towards_pointer(dt)
204
205 self._thrusting = (Input.is_action_pressed("thrust") or flying) and not self._dead
206 if self._thrusting:
207 self._vx += math.cos(self._heading) * SHIP_THRUST * dt
208 self._vy += math.sin(self._heading) * SHIP_THRUST * dt
209
210 self._vx *= SHIP_DRAG
211 self._vy *= SHIP_DRAG
212 if not self._dead:
213 self._x = _wrap(self._x + self._vx * dt, 0, w)
214 self._y = _wrap(self._y + self._vy * dt, 0, h)
215
216 self._cooldown = max(0.0, self._cooldown - dt)
217 if (Input.is_action_pressed("shoot") or flying) and self._cooldown <= 0 and not self._dead:
218 self._cooldown = SHOOT_COOLDOWN
219 bvx = math.cos(self._heading) * BULLET_SPEED + self._vx
220 bvy = math.sin(self._heading) * BULLET_SPEED + self._vy
221 self._bullets.append(Bullet(self._x, self._y, bvx, bvy))
222
223 for b in self._bullets:
224 b.x = _wrap(b.x + b.vx * dt, 0, w)
225 b.y = _wrap(b.y + b.vy * dt, 0, h)
226 b.life -= dt
227 self._bullets = [b for b in self._bullets if b.life > 0]
228
229 for p in self._particles:
230 p.x += p.vx * dt
231 p.y += p.vy * dt
232 p.life -= dt
233 self._particles = [p for p in self._particles if p.life > 0]
234
235 for a in self._asteroids:
236 a.update(dt, w, h)
237
238 new_rocks = []
239 for a in self._asteroids:
240 hit = False
241 for b in self._bullets:
242 if b.life <= 0:
243 continue # already spent on an earlier rock this frame
244 if (a.x - b.x) ** 2 + (a.y - b.y) ** 2 <= a.size * a.size:
245 hit = True
246 b.life = 0
247 self.score += {SIZE_LARGE: 20, SIZE_MEDIUM: 50, SIZE_SMALL: 100}.get(a.size, 100)
248 self._spawn_particles(a.x, a.y, 14)
249 break
250 if hit:
251 if a.size == SIZE_LARGE:
252 new_rocks.extend([Asteroid(a.x, a.y, SIZE_MEDIUM) for _ in range(2)])
253 elif a.size == SIZE_MEDIUM:
254 new_rocks.extend([Asteroid(a.x, a.y, SIZE_SMALL) for _ in range(2)])
255 else:
256 new_rocks.append(a)
257 self._asteroids = new_rocks
258 self._bullets = [b for b in self._bullets if b.life > 0]
259
260 self._invincible = max(0.0, self._invincible - dt)
261 if not self._dead and self._invincible <= 0:
262 for a in self._asteroids:
263 if (a.x - self._x) ** 2 + (a.y - self._y) ** 2 <= (a.size + SHIP_SIZE) ** 2:
264 self._spawn_particles(self._x, self._y, 24)
265 self._dead = True
266 self._lives -= 1
267 if self._lives <= 0:
268 self._state = STATE_OVER
269 else:
270 self._respawn_after = 1.2
271 break
272 if self._dead and self._state != STATE_OVER:
273 self._respawn_after -= dt
274 if self._respawn_after <= 0:
275 self._x = w / 2
276 self._y = h / 2
277 self._vx = self._vy = 0.0
278 self._invincible = 2.0
279 self._dead = False
280
281 if not self._asteroids:
282 self._asteroids = [Asteroid(*_spawn_at_edge(w, h), SIZE_LARGE) for _ in range(4)]
283
284 def _steer_towards_pointer(self, dt: float) -> None:
285 """Turn the ship towards the held pointer, capped at the rotation rate."""
286 pointer = Input.mouse_position
287 dx = float(pointer.x) - self._x
288 dy = float(pointer.y) - self._y
289 if dx * dx + dy * dy <= POINTER_DEAD_ZONE * POINTER_DEAD_ZONE:
290 return
291 delta = (math.atan2(dy, dx) - self._heading + math.pi) % math.tau - math.pi
292 step = SHIP_ROTATE_SPEED * dt
293 self._heading += max(-step, min(step, delta))
294
295 def _spawn_particles(self, x: float, y: float, n: int) -> None:
296 for _ in range(n):
297 ang = random.uniform(0, math.tau)
298 spd = random.uniform(40, 180)
299 self._particles.append(Particle(x, y, math.cos(ang) * spd, math.sin(ang) * spd, random.uniform(0.4, 0.9)))
300
301 # ------------------------------------------------------------------
302 # Drawing
303 # ------------------------------------------------------------------
304 def on_draw(self, renderer) -> None:
305 self._refresh_screen()
306 sw, sh = self._screen_w, self._screen_h
307 renderer.draw_rect((0, 0), (sw, sh), colour=BG_C, filled=True)
308
309 if self._state == STATE_MENU:
310 hint_scale = min(
311 renderer.fit_scale(KEYS_HINT, sw * 0.9, base_scale=2.0),
312 renderer.fit_scale(POINTER_HINT, sw * 0.9, base_scale=2.0),
313 )
314 self._draw_stack(
315 renderer,
316 [
317 ("ASTEROIDS", renderer.fit_scale("ASTEROIDS", sw * 0.55, base_scale=8.0), WHITE),
318 (START_PROMPT, renderer.fit_scale(START_PROMPT, sw * 0.85, base_scale=2.0), WHITE),
319 (KEYS_HINT, hint_scale, HINT_COLOUR),
320 (POINTER_HINT, hint_scale, HINT_COLOUR),
321 ],
322 gap=12,
323 )
324 return
325
326 # asteroids
327 for a in self._asteroids:
328 a.draw(renderer)
329 # bullets
330 for b in self._bullets:
331 renderer.draw_rect((b.x - 1.5, b.y - 1.5), (3, 3), colour=WHITE, filled=True)
332 # particles
333 for p in self._particles:
334 alpha = max(0.0, p.life)
335 renderer.draw_rect((p.x - 1, p.y - 1), (2, 2), colour=(1.0, 0.9, 0.5, alpha), filled=True)
336 # ship
337 if not self._dead:
338 self._draw_ship(renderer)
339 # HUD top-left
340 score_text = f"Score: {self.score}"
341 renderer.draw_text(score_text, (10, 6), scale=2, colour=WHITE)
342 renderer.draw_text(
343 f"Lives: {self._lives}", (10, 6 + renderer.text_height(score_text, 2)), scale=2, colour=WHITE
344 )
345
346 if self._state == STATE_OVER:
347 game_over_scale = renderer.fit_scale("GAME OVER", sw * 0.7, base_scale=6.0)
348 score_line = f"SCORE {self.score:05d}"
349 self._draw_stack(
350 renderer,
351 [
352 ("GAME OVER", game_over_scale, (0.95, 0.4, 0.4, 1.0)),
353 (score_line, max(2.0, game_over_scale - 2.0), WHITE),
354 (AGAIN_PROMPT, renderer.fit_scale(AGAIN_PROMPT, sw * 0.85, base_scale=2.0), HINT_COLOUR),
355 ],
356 gap=14,
357 )
358
359 # In-game controls panel: vertical, bottom-right, left-justified.
360 self._draw_controls_panel(
361 renderer,
362 [
363 "ARROWS/WASD: ROTATE+THRUST",
364 "SPACE: SHOOT",
365 "HOLD MOUSE/TOUCH: FLY+FIRE",
366 "ESC: QUIT",
367 ],
368 )
369
370 def _draw_ship(self, renderer) -> None:
371 h = self._heading
372 c, s = math.cos(h), math.sin(h)
373 nose = (self._x + c * SHIP_SIZE, self._y + s * SHIP_SIZE)
374 wing_l_h = h + math.radians(140)
375 wing_r_h = h - math.radians(140)
376 wing_l = (self._x + math.cos(wing_l_h) * SHIP_SIZE, self._y + math.sin(wing_l_h) * SHIP_SIZE)
377 wing_r = (self._x + math.cos(wing_r_h) * SHIP_SIZE, self._y + math.sin(wing_r_h) * SHIP_SIZE)
378 col = SHIP_C if self._invincible <= 0 or int(self._invincible * 10) % 2 == 0 else (0.5, 0.5, 0.6, 1.0)
379 renderer.draw_line(nose, wing_l, colour=col, thickness=2.0)
380 renderer.draw_line(nose, wing_r, colour=col, thickness=2.0)
381 renderer.draw_line(wing_l, wing_r, colour=col, thickness=2.0)
382 if self._thrusting:
383 # Flame comes out the back: from the midpoint of the rear edge
384 # (between the two wings), extending further behind the ship.
385 back_x = (wing_l[0] + wing_r[0]) * 0.5
386 back_y = (wing_l[1] + wing_r[1]) * 0.5
387 tail_h = h + math.pi
388 tx = back_x + math.cos(tail_h) * SHIP_SIZE * 0.6
389 ty = back_y + math.sin(tail_h) * SHIP_SIZE * 0.6
390 renderer.draw_line((back_x, back_y), (tx, ty), colour=THRUST_C, thickness=2.0)
391
392 # ------------------------------------------------------------------
393 # Helpers (port UX baseline)
394 # ------------------------------------------------------------------
395 def _draw_centered(self, renderer, text, *, scale, y, colour=WHITE):
396 renderer.draw_text(text, (self._screen_w // 2, y), scale=scale, colour=colour, alignment="centre")
397
398 def _draw_stack(self, renderer, lines, *, gap: float) -> None:
399 """Draw ``(text, scale, colour)`` lines as one vertically centred block."""
400 heights = [renderer.text_height(text, scale) for text, scale, _ in lines]
401 y = self._screen_h / 2 - (sum(heights) + gap * (len(lines) - 1)) / 2
402 for (text, scale, colour), height in zip(lines, heights, strict=True):
403 self._draw_centered(renderer, text, scale=scale, y=y, colour=colour)
404 y += height + gap
405
406 def _draw_controls_panel(self, renderer, lines: list[str]) -> None:
407 sw, sh = self._screen_w, self._screen_h
408 margin = 12
409 widest = max(lines, key=lambda line: renderer.text_width(line, 1.0))
410 scale = renderer.fit_scale(widest, sw * 0.32 - margin, base_scale=2.0)
411 line_height = renderer.text_height(widest, scale)
412 widest_w = max(renderer.text_width(line, scale) for line in lines)
413 panel_x = sw - widest_w - margin
414 y = sh - line_height * len(lines) - margin
415 for line in lines:
416 renderer.draw_text(line, (panel_x, y), scale=scale, colour=HINT_COLOUR)
417 y += line_height