Asteroids 2D

Classic arcade game with wrap-around physics.

▶ Run in browser

Tags: game collision wrap shooting

(No additional documentation. See source below.)

Source

  1#!/usr/bin/env python3
  2"""Asteroids 2D: Classic arcade game with wrap-around physics.
  3
  4# /// simvx
  5# tags = ["game", "collision", "wrap", "shooting"]
  6# web = { root = "MainMenu" }
  7# ///
  8"""
  9
 10
 11import math
 12import random
 13
 14from simvx.core import (
 15    # Input
 16    Input,
 17    InputMap,
 18    Key,
 19    MouseButton,
 20    Node,
 21    Node2D,
 22    # Engine
 23    Property,
 24    Signal,
 25    Timer,
 26    # Math
 27    Vec2,
 28)
 29from simvx.graphics import App
 30
 31WIDTH, HEIGHT = 800, 600
 32
 33# Ship shapes: local-space points drawn by Node2D.draw_polygon
 34SHIP_SHAPE = [Vec2(0, -12), Vec2(-8, 10), Vec2(8, 10)]
 35THRUST_SHAPE = [Vec2(-5, 10), Vec2(0, 18), Vec2(5, 10)]
 36
 37
 38class Body2D(Node2D):
 39    """A manually integrated 2D actor with a collision radius + group overlap query.
 40
 41    Asteroids is a wrap-around arcade game with NO physics simulation: every actor
 42    integrates ``position += velocity * dt`` itself and collisions are plain
 43    circle-vs-circle radius tests. This is deliberately NOT a ``CharacterBody2D``
 44    (the seam character runs a stepped collide-and-slide world); it is a light
 45    ``Node2D`` that carries a radius and a group-scoped overlap poll.
 46    """
 47
 48    def __init__(self, radius: float = 8.0, **kwargs):
 49        super().__init__(**kwargs)
 50        self.radius = float(radius)
 51        self.velocity = Vec2()
 52
 53    def get_overlapping(self, group: str) -> list[Body2D]:
 54        """Other ``Body2D`` nodes in ``group`` whose radius overlaps ours."""
 55        if not self.tree:
 56            return []
 57        hits: list[Body2D] = []
 58        for b in self.tree.get_group(group):
 59            if b is self or not isinstance(b, Body2D):
 60                continue
 61            d = b.world_position - self.world_position
 62            rr = self.radius + b.radius
 63            if float(d.x) ** 2 + float(d.y) ** 2 <= rr * rr:
 64                hits.append(b)
 65        return hits
 66
 67
 68def random_asteroid_shape(radius: float, verts=10) -> list[Vec2]:
 69    return [
 70        Vec2(math.cos(a) * radius * random.uniform(0.7, 1.3), math.sin(a) * radius * random.uniform(0.7, 1.3))
 71        for a in (i / verts * math.tau for i in range(verts))
 72    ]
 73
 74
 75# ============================================================================
 76# Ship
 77# ============================================================================
 78
 79
 80class Ship(Body2D):
 81    turn_speed = Property(200.0, range=(50, 400), hint="Degrees per second")
 82    thrust_power = Property(300.0, range=(50, 800))
 83    max_speed = Property(400.0, range=(100, 1000))
 84    drag = Property(0.98, range=(0.9, 1.0))
 85
 86    def __init__(self, **kwargs):
 87        super().__init__(radius=10, **kwargs)
 88        self.fired = Signal()
 89        self.died = Signal()
 90        self._thrusting = False
 91        self._invincible = 0.0
 92        self._visible = True
 93
 94        self.fire_timer = self.add_child(Timer(0.15, name="FireTimer"))
 95
 96    def on_ready(self):
 97        self.position = Vec2(WIDTH / 2, HEIGHT / 2)
 98
 99    def on_fixed_update(self, dt: float):
100        # Turning
101        if Input.is_action_pressed("turn_left"):
102            self.rotation -= math.radians(self.turn_speed) * dt
103        if Input.is_action_pressed("turn_right"):
104            self.rotation += math.radians(self.turn_speed) * dt
105
106        # Pointer controls: hold/drag to steer toward the pointer, thrusting and
107        # firing while held (web touch arrives as MouseButton.LEFT).
108        pointer_held = Input.is_mouse_button_pressed(MouseButton.LEFT)
109        if pointer_held:
110            d = Input.mouse_position - self.world_position
111            if d.length() > self.radius * 2:
112                target = math.atan2(float(d.x), -float(d.y))
113                diff = (target - self.rotation + math.pi) % math.tau - math.pi
114                step = math.radians(self.turn_speed) * dt
115                self.rotation += max(-step, min(step, diff))
116
117        # Thrust
118        self._thrusting = Input.is_action_pressed("thrust") or pointer_held
119        if self._thrusting:
120            self.velocity += self.forward * (self.thrust_power * dt)
121            speed = self.velocity.length()
122            if speed > self.max_speed:
123                self.velocity = self.velocity.normalized() * self.max_speed
124
125        self.velocity *= self.drag
126        self.position += self.velocity * dt
127        self.wrap_screen()
128
129        # Shooting (timer prevents rapid-fire)
130        if (Input.is_action_pressed("fire") or pointer_held) and self.fire_timer.stopped:
131            self.fire_timer.start()
132            self.fired.emit()
133
134        # Invincibility blink
135        if self._invincible > 0:
136            self._invincible -= dt
137            self._visible = int(self._invincible * 10) % 2 == 0
138        else:
139            self._visible = True
140
141    def on_draw(self, renderer):
142        if not self._visible:
143            return
144        self.draw_polygon(renderer, SHIP_SHAPE)
145        if self._thrusting:
146            self.draw_polygon(renderer, THRUST_SHAPE)
147
148    def respawn(self):
149        self.position = Vec2(WIDTH / 2, HEIGHT / 2)
150        self.velocity = Vec2()
151        self.rotation = 0.0
152        self._invincible = 2.0
153
154    @property
155    def is_invincible(self):
156        return self._invincible > 0
157
158
159# ============================================================================
160# Bullet
161# ============================================================================
162
163
164class Bullet(Body2D):
165    speed = Property(500.0)
166
167    def __init__(self, direction: Vec2 = None, **kwargs):
168        super().__init__(radius=2, **kwargs)
169        self.add_to_group("bullets")
170        if direction:
171            self.velocity = direction * self.speed
172
173        # Auto-expire via timer
174        t = self.add_child(Timer(1.5, name="Lifetime"))
175        t.timeout.connect(self.destroy)
176        t.start()
177
178    def on_fixed_update(self, dt: float):
179        self.position += self.velocity * dt
180        self.wrap_screen()
181
182    def on_draw(self, renderer):
183        renderer.draw_circle(self.position, 2, segments=6)
184
185
186# ============================================================================
187# Asteroid
188# ============================================================================
189
190SIZES = {"large": 40, "medium": 20, "small": 10}
191SCORES = {"large": 20, "medium": 50, "small": 100}
192
193
194class Asteroid(Body2D):
195    size_class = Property("large", enum=["large", "medium", "small"])
196
197    def __init__(self, size_class="large", **kwargs):
198        radius = SIZES[size_class]
199        super().__init__(radius=radius, **kwargs)
200        self.size_class = size_class
201        self.add_to_group("asteroids")
202        self._shape = random_asteroid_shape(radius)
203        self._spin = math.radians(random.uniform(-90, 90))
204        # Random velocity
205        angle = random.uniform(0, math.tau)
206        speed = random.uniform(40, 120)
207        self.velocity = Vec2(math.cos(angle), math.sin(angle)) * speed
208
209    def on_fixed_update(self, dt: float):
210        self.position += self.velocity * dt
211        self.wrap_screen(margin=SIZES[self.size_class])
212        self.rotation += self._spin * dt
213
214    def on_draw(self, renderer):
215        self.draw_polygon(renderer, self._shape)
216
217    def split(self) -> list[Asteroid]:
218        next_size = {"large": "medium", "medium": "small"}.get(self.size_class)
219        if not next_size:
220            return []
221        return [Asteroid(name="Asteroid", size_class=next_size, position=Vec2(self.position)) for _ in range(2)]
222
223
224# ============================================================================
225# MainMenu
226# ============================================================================
227
228
229class MainMenu(Node):
230    # on_draw blinks "PRESS ENTER" from the per-frame _blink timer (no Property),
231    # so it must re-run every frame under retained 2D.
232    dynamic = True
233
234    def __init__(self, **kwargs):
235        super().__init__(name="MainMenu", **kwargs)
236        self._blink = 0.0
237
238    def on_ready(self):
239        InputMap.add_action("thrust", [Key.W, Key.UP])
240        InputMap.add_action("turn_left", [Key.A, Key.LEFT])
241        InputMap.add_action("turn_right", [Key.D, Key.RIGHT])
242        InputMap.add_action("fire", [Key.SPACE])
243        InputMap.add_action("start", [Key.ENTER, MouseButton.LEFT])
244
245    def on_update(self, dt):
246        self._blink += dt
247        if Input.is_action_just_pressed("start"):
248            self.tree.change_scene(AsteroidsGame())
249
250    def on_draw(self, renderer):
251        title = "ASTEROIDS"
252        tw = renderer.text_width(title, 6)
253        renderer.draw_text(title, (WIDTH // 2 - tw // 2, 120), scale=6, colour=(1.0, 1.0, 1.0))
254
255        # Draw decorative ship
256        cx = WIDTH // 2
257        pts = Node2D(position=Vec2(cx, 300)).transform_points([p * 2.5 for p in SHIP_SHAPE])
258        renderer.draw_lines(pts, closed=True)
259
260        # Controls
261        renderer.draw_text("W/UP  THRUST", (cx - 100, 370), scale=2, colour=(0.71, 0.71, 0.71))
262        renderer.draw_text("A/D   TURN", (cx - 100, 395), scale=2, colour=(0.71, 0.71, 0.71))
263        renderer.draw_text("SPACE FIRE", (cx - 100, 420), scale=2, colour=(0.71, 0.71, 0.71))
264        renderer.draw_text("TOUCH HOLD TO FLY + FIRE", (cx - 100, 445), scale=2, colour=(0.71, 0.71, 0.71))
265
266        if int(self._blink * 2) % 2 == 0:
267            prompt = "PRESS ENTER OR TAP TO START"
268            pw = renderer.text_width(prompt, 3)
269            renderer.draw_text(prompt, (WIDTH // 2 - pw // 2, 490), scale=3, colour=(0.78, 0.78, 0.78))
270
271
272# ============================================================================
273# Game Scene
274# ============================================================================
275
276
277class AsteroidsGame(Node2D):
278    # on_draw reads the plain _score/_lives counters (no Property), so the HUD
279    # must re-run every frame under retained 2D.
280    dynamic = True
281
282    start_asteroids = Property(4, range=(1, 12))
283    lives = Property(3, range=(1, 10))
284
285    def __init__(self, **kwargs):
286        super().__init__(name="AsteroidsGame", **kwargs)
287        self.ship = self.add_child(Ship(name="Ship"))
288        self._score = 0
289        self._lives = self.lives
290        self._wave = 0
291
292    def on_ready(self):
293        @self.ship.fired.connect
294        def on_fire():
295            fwd = self.ship.forward
296            self.add_child(
297                Bullet(
298                    name="Bullet",
299                    position=Vec2(self.ship.position) + fwd * 15,
300                    direction=fwd,
301                )
302            )
303
304        @self.ship.died.connect
305        def on_died():
306            self._lives -= 1
307            if self._lives <= 0:
308                self.tree.change_scene(GameOver(self._score))
309                return
310            self.ship.respawn()
311
312        self._spawn_wave()
313
314    def _spawn_wave(self):
315        self._wave += 1
316        for i in range(self.start_asteroids + self._wave - 1):
317            pos = Vec2(
318                random.choice([random.uniform(0, 100), random.uniform(WIDTH - 100, WIDTH)]),
319                random.choice([random.uniform(0, 100), random.uniform(HEIGHT - 100, HEIGHT)]),
320            )
321            self.add_child(Asteroid(name=f"Asteroid{i}", position=pos))
322
323    def on_fixed_update(self, dt: float):
324        if not self.tree:
325            return
326        # Bullet-asteroid collisions via groups
327        for bullet in self.tree.get_group("bullets"):
328            for asteroid in bullet.get_overlapping(group="asteroids"):
329                self._score += SCORES[asteroid.size_class]
330                for piece in asteroid.split():
331                    self.add_child(piece)
332                asteroid.destroy()
333                bullet.destroy()
334                break
335
336        # Ship-asteroid collisions
337        if not self.ship.is_invincible:
338            if self.ship.get_overlapping(group="asteroids"):
339                self.ship.died()
340
341        # Next wave?
342        if not self.tree or not self.tree.get_group("asteroids"):
343            self._spawn_wave()
344
345    def on_draw(self, renderer):
346        # HUD: score
347        renderer.draw_text(f"SCORE {self._score:05d}", (10, 10), scale=2, colour=(1.0, 1.0, 1.0))
348        # HUD: draw remaining lives as small ships
349        for i in range(self._lives):
350            pts = Node2D(position=Vec2(WIDTH - 80 + i * 25, 18)).transform_points(SHIP_SHAPE)
351            renderer.draw_lines(pts, closed=True)
352
353
354# ============================================================================
355# GameOver
356# ============================================================================
357
358
359class GameOver(Node):
360    # on_draw blinks the continue prompt from the per-frame _blink timer
361    # (no Property), so it must re-run every frame under retained 2D.
362    dynamic = True
363
364    def __init__(self, score=0, **kwargs):
365        super().__init__(name="GameOver", **kwargs)
366        self.score = score
367        self._blink = 0.0
368
369    def on_update(self, dt):
370        self._blink += dt
371        if Input.is_action_just_pressed("start"):
372            self.tree.change_scene(MainMenu())
373
374    def on_draw(self, renderer):
375        title = "GAME OVER"
376        tw = renderer.text_width(title, 5)
377        renderer.draw_text(title, (WIDTH // 2 - tw // 2, 180), scale=5, colour=(1.0, 0.2, 0.2))
378
379        score_text = f"SCORE  {self.score:05d}"
380        sw = renderer.text_width(score_text, 3)
381        renderer.draw_text(score_text, (WIDTH // 2 - sw // 2, 300), scale=3, colour=(1.0, 1.0, 1.0))
382
383        if int(self._blink * 2) % 2 == 0:
384            prompt = "PRESS ENTER OR TAP TO CONTINUE"
385            pw = renderer.text_width(prompt, 2)
386            renderer.draw_text(prompt, (WIDTH // 2 - pw // 2, 400), scale=2, colour=(0.78, 0.78, 0.78))
387
388
389# ============================================================================
390# Main
391# ============================================================================
392
393
394if __name__ == "__main__":
395    App("Asteroids", WIDTH, HEIGHT).run(MainMenu())