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