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. The playfield is whatever the window is,
5and the ship flies from the keyboard, a controller, a held pointer or the
6on-screen pad.
7"""
8
9from __future__ import annotations
10
11import math
12import random
13from dataclasses import dataclass
14
15from simvx.core import Axis1D, Controller, Input, Key, Mouse, Node, Property
16from simvx.core.ui import TouchController
17
18SHIP_ROTATE_SPEED = math.radians(220.0)
19SHIP_THRUST = 240.0
20SHIP_DRAG = 0.992
21SHIP_SIZE = 12.0
22BULLET_SPEED = 480.0
23BULLET_LIFE = 1.4
24SHOOT_COOLDOWN = 0.18
25
26SIZE_LARGE = 56
27SIZE_MEDIUM = 32
28SIZE_SMALL = 16
29
30# Pointer flying: holding the pointer steers the ship towards it, thrusts and
31# fires. Inside this radius the pointer is "on" the ship, so heading is held.
32POINTER_DEAD_ZONE = 24.0
33
34# Below this the left stick is at rest and the ship keeps the heading it has.
35STICK_DEAD_ZONE = 0.35
36
37START_PROMPT = "PRESS [SPACE] OR TAP TO START"
38AGAIN_PROMPT = "PRESS [SPACE] OR TAP TO PLAY AGAIN"
39KEYS_HINT = "ARROWS/WASD: ROTATE+THRUST SPACE: SHOOT"
40POINTER_HINT = "HOLD MOUSE OR TOUCH: STEER, THRUST, FIRE"
41PAD_HINT = "PAD: STICK AIMS A: THRUST B: FIRE"
42
43WHITE = (1.0, 1.0, 1.0, 1.0)
44SHIP_C = (0.85, 0.95, 1.0, 1.0)
45THRUST_C = (1.0, 0.6, 0.2, 1.0)
46ROCK_C = (0.85, 0.85, 0.92, 1.0)
47HINT_COLOUR = (0.70, 0.70, 0.70, 1.0)
48BG_C = (0.04, 0.04, 0.07, 1.0)
49
50STATE_MENU = "menu"
51STATE_PLAY = "play"
52STATE_OVER = "over"
53
54
55def _wrap(x, lo, hi):
56 if x < lo:
57 return hi - (lo - x)
58 if x > hi:
59 return lo + (x - hi)
60 return x
61
62
63@dataclass
64class Bullet:
65 x: float
66 y: float
67 vx: float
68 vy: float
69 life: float = BULLET_LIFE
70
71
72@dataclass
73class Particle:
74 x: float
75 y: float
76 vx: float
77 vy: float
78 life: float
79
80
81class Asteroid:
82 __slots__ = ("x", "y", "vx", "vy", "size", "shape", "spin", "angle")
83
84 def __init__(self, x: float, y: float, size: int):
85 self.x = x
86 self.y = y
87 self.size = size
88 speed = random.uniform(40.0, 90.0) * (1.0 + (SIZE_LARGE - size) / 70)
89 ang = random.uniform(0, math.tau)
90 self.vx = math.cos(ang) * speed
91 self.vy = math.sin(ang) * speed
92 self.angle = 0.0
93 self.spin = random.uniform(-1.5, 1.5)
94 n = random.randint(8, 11)
95 self.shape = [
96 (
97 math.cos(i * math.tau / n) * size * random.uniform(0.7, 1.0),
98 math.sin(i * math.tau / n) * size * random.uniform(0.7, 1.0),
99 )
100 for i in range(n)
101 ]
102
103 def update(self, dt: float, w: float, h: float) -> None:
104 self.x = _wrap(self.x + self.vx * dt, 0, w)
105 self.y = _wrap(self.y + self.vy * dt, 0, h)
106 self.angle += self.spin * dt
107
108 def draw(self, renderer) -> None:
109 c = math.cos(self.angle)
110 s = math.sin(self.angle)
111 pts = [(self.x + px * c - py * s, self.y + px * s + py * c) for px, py in self.shape]
112 for i in range(len(pts)):
113 x0, y0 = pts[i]
114 x1, y1 = pts[(i + 1) % len(pts)]
115 renderer.line((x0, y0), (x1, y1), stroke=ROCK_C, width=2.0)
116
117
118def _spawn_at_edge(w: float, h: float) -> tuple[float, float]:
119 edge = random.choice("tblr")
120 if edge == "t":
121 return random.uniform(0, w), 0
122 if edge == "b":
123 return random.uniform(0, w), h
124 if edge == "l":
125 return 0, random.uniform(0, h)
126 return w, random.uniform(0, h)
127
128
129class AsteroidsGame(Node):
130 dynamic = True # ship, asteroids, bullets, particles move every frame
131
132 score = Property(0)
133
134 #: What the player can do. Declared on the scene root rather than from
135 #: ``main()``, so a web export and a test that loads this node both get
136 #: the same bindings without running anything first.
137 #:
138 #: Rotating is one signed axis with three keyboard-or-pad sources: the
139 #: arrows, A and D, and the pad's d-pad. The source furthest from rest
140 #: wins, so they all feed the one action and the game reads it once.
141 input_context = {
142 "rotate": [
143 Axis1D(low=Key.LEFT, high=Key.RIGHT),
144 Axis1D(low=Key.A, high=Key.D),
145 Axis1D(low=Controller.DPAD_LEFT, high=Controller.DPAD_RIGHT),
146 ],
147 # Where the player wants the nose pointed, read as a direction rather
148 # than as a turn: see _steer_towards_stick.
149 "steer": [Controller.LEFT_STICK],
150 "thrust": [Key.UP, Key.W, Controller.A],
151 "shoot": [Key.SPACE, Controller.B],
152 # A touch reports as Mouse.LEFT on the web build: holding flies
153 # the ship towards the pointer, and a tap starts or restarts.
154 "fly": [Mouse.LEFT],
155 "start": [Key.SPACE, Key.R, Key.ENTER, Mouse.LEFT, Controller.A, Controller.START],
156 "restart": [Key.R, Key.ENTER, Key.SPACE, Mouse.LEFT, Controller.A, Controller.START],
157 "quit": [Key.ESCAPE],
158 }
159
160 def __init__(self, **kw):
161 super().__init__(**kw)
162 self._state = STATE_MENU
163 self._bullets: list[Bullet] = []
164 self._particles: list[Particle] = []
165 self._asteroids: list[Asteroid] = []
166
167 def on_enter_tree(self) -> None:
168 # viewport_size is valid from here on, so the world is built against
169 # the real window rather than against a baked-in size.
170 self._reset_world()
171
172 def on_ready(self) -> None:
173 # Rotate-and-thrust is an awkward shape for a thumb: holding a rotate
174 # key while watching the nose come round is a keyboard idiom, and a
175 # d-pad would only reproduce it. A stick says where the ship should
176 # face instead, which is the same "point at it" gesture the held
177 # pointer already uses, so the glass gets a stick with A to thrust and
178 # B to fire. The pad publishes a real controller, so the Controller and
179 # Controller stick bindings above already read it.
180 self.add_child(TouchController(layout="stick_and_buttons", buttons=(Controller.A, Controller.B)))
181
182 def _reset_world(self) -> None:
183 width, height = self.viewport_size
184 self.score = 0
185 self._lives = 3
186 self._dead = False
187 self._x = width / 2
188 self._y = height / 2
189 self._vx = 0.0
190 self._vy = 0.0
191 self._heading = -math.pi / 2
192 self._thrusting = False
193 self._cooldown = 0.0
194 self._invincible = 1.5
195 self._respawn_after = 0.0
196 self._bullets = []
197 self._particles = []
198 self._asteroids = [Asteroid(*_spawn_at_edge(width, height), SIZE_LARGE) for _ in range(4)]
199
200 def on_update(self, dt: float) -> None:
201 if Input.just_pressed("quit"):
202 self.app.quit()
203 return
204
205 if self._state == STATE_MENU:
206 if Input.just_pressed("start"):
207 self._reset_world()
208 self._state = STATE_PLAY
209 return
210
211 if self._state == STATE_OVER:
212 if Input.just_pressed("restart"):
213 self._reset_world()
214 self._state = STATE_PLAY
215 return
216
217 # ---- play ----
218 w, h = self.viewport_size
219
220 self._heading += Input.value("rotate") * SHIP_ROTATE_SPEED * dt
221 self._steer_towards_stick(dt)
222
223 flying = Input.pressed("fly")
224 if flying:
225 self._steer_towards_pointer(dt)
226
227 self._thrusting = (Input.pressed("thrust") or flying) and not self._dead
228 if self._thrusting:
229 self._vx += math.cos(self._heading) * SHIP_THRUST * dt
230 self._vy += math.sin(self._heading) * SHIP_THRUST * dt
231
232 self._vx *= SHIP_DRAG
233 self._vy *= SHIP_DRAG
234 if not self._dead:
235 self._x = _wrap(self._x + self._vx * dt, 0, w)
236 self._y = _wrap(self._y + self._vy * dt, 0, h)
237
238 self._cooldown = max(0.0, self._cooldown - dt)
239 if (Input.pressed("shoot") or flying) and self._cooldown <= 0 and not self._dead:
240 self._cooldown = SHOOT_COOLDOWN
241 bvx = math.cos(self._heading) * BULLET_SPEED + self._vx
242 bvy = math.sin(self._heading) * BULLET_SPEED + self._vy
243 self._bullets.append(Bullet(self._x, self._y, bvx, bvy))
244
245 for b in self._bullets:
246 b.x = _wrap(b.x + b.vx * dt, 0, w)
247 b.y = _wrap(b.y + b.vy * dt, 0, h)
248 b.life -= dt
249 self._bullets = [b for b in self._bullets if b.life > 0]
250
251 for p in self._particles:
252 p.x += p.vx * dt
253 p.y += p.vy * dt
254 p.life -= dt
255 self._particles = [p for p in self._particles if p.life > 0]
256
257 for a in self._asteroids:
258 a.update(dt, w, h)
259
260 new_rocks = []
261 for a in self._asteroids:
262 hit = False
263 for b in self._bullets:
264 if b.life <= 0:
265 continue # already spent on an earlier rock this frame
266 if (a.x - b.x) ** 2 + (a.y - b.y) ** 2 <= a.size * a.size:
267 hit = True
268 b.life = 0
269 self.score += {SIZE_LARGE: 20, SIZE_MEDIUM: 50, SIZE_SMALL: 100}.get(a.size, 100)
270 self._spawn_particles(a.x, a.y, 14)
271 break
272 if hit:
273 if a.size == SIZE_LARGE:
274 new_rocks.extend([Asteroid(a.x, a.y, SIZE_MEDIUM) for _ in range(2)])
275 elif a.size == SIZE_MEDIUM:
276 new_rocks.extend([Asteroid(a.x, a.y, SIZE_SMALL) for _ in range(2)])
277 else:
278 new_rocks.append(a)
279 self._asteroids = new_rocks
280 self._bullets = [b for b in self._bullets if b.life > 0]
281
282 self._invincible = max(0.0, self._invincible - dt)
283 if not self._dead and self._invincible <= 0:
284 for a in self._asteroids:
285 if (a.x - self._x) ** 2 + (a.y - self._y) ** 2 <= (a.size + SHIP_SIZE) ** 2:
286 self._spawn_particles(self._x, self._y, 24)
287 self._dead = True
288 self._lives -= 1
289 if self._lives <= 0:
290 self._state = STATE_OVER
291 else:
292 self._respawn_after = 1.2
293 break
294 if self._dead and self._state != STATE_OVER:
295 self._respawn_after -= dt
296 if self._respawn_after <= 0:
297 self._x = w / 2
298 self._y = h / 2
299 self._vx = self._vy = 0.0
300 self._invincible = 2.0
301 self._dead = False
302
303 if not self._asteroids:
304 self._asteroids = [Asteroid(*_spawn_at_edge(w, h), SIZE_LARGE) for _ in range(4)]
305
306 def _steer_towards_pointer(self, dt: float) -> None:
307 """Turn the ship towards the held pointer, capped at the rotation rate."""
308 pointer = Input.mouse_position
309 dx = float(pointer.x) - self._x
310 dy = float(pointer.y) - self._y
311 if dx * dx + dy * dy <= POINTER_DEAD_ZONE * POINTER_DEAD_ZONE:
312 return
313 self._turn_towards(math.atan2(dy, dx), dt)
314
315 def _steer_towards_stick(self, dt: float) -> None:
316 """Turn the ship towards where the left stick is pushed.
317
318 The nose follows the stick at the ship's own rotation rate rather than
319 snapping to it, so the drawn pad and a controller fly the same ship the
320 keyboard does and a flick of the thumb cannot spin it instantly.
321 """
322 stick = Input.value("steer")
323 x, y = float(stick.x), float(stick.y)
324 if x * x + y * y < STICK_DEAD_ZONE * STICK_DEAD_ZONE:
325 return
326 self._turn_towards(math.atan2(y, x), dt)
327
328 def _turn_towards(self, target: float, dt: float) -> None:
329 """Rotate the heading towards *target* radians, capped by the turn rate."""
330 delta = (target - self._heading + math.pi) % math.tau - math.pi
331 step = SHIP_ROTATE_SPEED * dt
332 self._heading += max(-step, min(step, delta))
333
334 def _spawn_particles(self, x: float, y: float, n: int) -> None:
335 for _ in range(n):
336 ang = random.uniform(0, math.tau)
337 spd = random.uniform(40, 180)
338 self._particles.append(Particle(x, y, math.cos(ang) * spd, math.sin(ang) * spd, random.uniform(0.4, 0.9)))
339
340 # ------------------------------------------------------------------
341 # Drawing
342 # ------------------------------------------------------------------
343 def on_draw(self, renderer) -> None:
344 sw, sh = self.viewport_size
345 renderer.rect((0, 0), (sw, sh), fill=BG_C)
346
347 if self._state == STATE_MENU:
348 hint_scale = min(
349 renderer.fit_scale(hint, sw * 0.9, base_scale=2.0) for hint in (KEYS_HINT, POINTER_HINT, PAD_HINT)
350 )
351 self._draw_stack(
352 renderer,
353 [
354 ("ASTEROIDS", renderer.fit_scale("ASTEROIDS", sw * 0.55, base_scale=8.0), WHITE),
355 (START_PROMPT, renderer.fit_scale(START_PROMPT, sw * 0.85, base_scale=2.0), WHITE),
356 (KEYS_HINT, hint_scale, HINT_COLOUR),
357 (POINTER_HINT, hint_scale, HINT_COLOUR),
358 (PAD_HINT, hint_scale, HINT_COLOUR),
359 ],
360 gap=12,
361 )
362 return
363
364 # asteroids
365 for a in self._asteroids:
366 a.draw(renderer)
367 # bullets
368 for b in self._bullets:
369 renderer.rect((b.x - 1.5, b.y - 1.5), (3, 3), fill=WHITE)
370 # particles
371 for p in self._particles:
372 alpha = max(0.0, p.life)
373 renderer.rect((p.x - 1, p.y - 1), (2, 2), fill=(1.0, 0.9, 0.5, alpha))
374 # ship
375 if not self._dead:
376 self._draw_ship(renderer)
377 # HUD top-left
378 score_text = f"Score: {self.score}"
379 renderer.text(score_text, (10, 6), fill=WHITE, scale=2)
380 renderer.text(f"Lives: {self._lives}", (10, 6 + renderer.text_height(score_text, 2)), fill=WHITE, scale=2)
381
382 if self._state == STATE_OVER:
383 game_over_scale = renderer.fit_scale("GAME OVER", sw * 0.7, base_scale=6.0)
384 score_line = f"SCORE {self.score:05d}"
385 self._draw_stack(
386 renderer,
387 [
388 ("GAME OVER", game_over_scale, (0.95, 0.4, 0.4, 1.0)),
389 (score_line, max(2.0, game_over_scale - 2.0), WHITE),
390 (AGAIN_PROMPT, renderer.fit_scale(AGAIN_PROMPT, sw * 0.85, base_scale=2.0), HINT_COLOUR),
391 ],
392 gap=14,
393 )
394
395 # In-game controls panel: vertical, top-right, left-justified. Top
396 # rather than bottom because the on-screen pad owns the bottom corners.
397 self._draw_controls_panel(
398 renderer,
399 [
400 "ARROWS/WASD: ROTATE+THRUST",
401 "SPACE: SHOOT",
402 "PAD: STICK AIMS, A THRUST, B FIRE",
403 "HOLD MOUSE/TOUCH: FLY+FIRE",
404 "ESC: QUIT",
405 ],
406 )
407
408 def _draw_ship(self, renderer) -> None:
409 h = self._heading
410 c, s = math.cos(h), math.sin(h)
411 nose = (self._x + c * SHIP_SIZE, self._y + s * SHIP_SIZE)
412 wing_l_h = h + math.radians(140)
413 wing_r_h = h - math.radians(140)
414 wing_l = (self._x + math.cos(wing_l_h) * SHIP_SIZE, self._y + math.sin(wing_l_h) * SHIP_SIZE)
415 wing_r = (self._x + math.cos(wing_r_h) * SHIP_SIZE, self._y + math.sin(wing_r_h) * SHIP_SIZE)
416 col = SHIP_C if self._invincible <= 0 or int(self._invincible * 10) % 2 == 0 else (0.5, 0.5, 0.6, 1.0)
417 renderer.line(nose, wing_l, stroke=col, width=2.0)
418 renderer.line(nose, wing_r, stroke=col, width=2.0)
419 renderer.line(wing_l, wing_r, stroke=col, width=2.0)
420 if self._thrusting:
421 # Flame comes out the back: from the midpoint of the rear edge
422 # (between the two wings), extending further behind the ship.
423 back_x = (wing_l[0] + wing_r[0]) * 0.5
424 back_y = (wing_l[1] + wing_r[1]) * 0.5
425 tail_h = h + math.pi
426 tx = back_x + math.cos(tail_h) * SHIP_SIZE * 0.6
427 ty = back_y + math.sin(tail_h) * SHIP_SIZE * 0.6
428 renderer.line((back_x, back_y), (tx, ty), stroke=THRUST_C, width=2.0)
429
430 # ------------------------------------------------------------------
431 # Helpers (port UX baseline)
432 # ------------------------------------------------------------------
433 def _draw_centered(self, renderer, text, *, scale, y, colour=WHITE):
434 renderer.text(text, (self.viewport_size.x / 2, y), fill=colour, scale=scale, align="centre")
435
436 def _draw_stack(self, renderer, lines, *, gap: float) -> None:
437 """Draw ``(text, scale, colour)`` lines as one vertically centred block."""
438 heights = [renderer.text_height(text, scale) for text, scale, _ in lines]
439 y = self.viewport_size.y / 2 - (sum(heights) + gap * (len(lines) - 1)) / 2
440 for (text, scale, colour), height in zip(lines, heights, strict=True):
441 self._draw_centered(renderer, text, scale=scale, y=y, colour=colour)
442 y += height + gap
443
444 def _draw_controls_panel(self, renderer, lines: list[str]) -> None:
445 sw, _ = self.viewport_size
446 margin = 12
447 widest = max(lines, key=lambda line: renderer.text_width(line, 1.0))
448 scale = renderer.fit_scale(widest, sw * 0.36 - margin, base_scale=2.0)
449 line_height = renderer.text_height(widest, scale)
450 widest_w = max(renderer.text_width(line, scale) for line in lines)
451 panel_x = sw - widest_w - margin
452 y = margin
453 for line in lines:
454 renderer.text(line, (panel_x, y), fill=HINT_COLOUR, scale=scale)
455 y += line_height