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