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    # Nodes
 16    CharacterBody2D,
 17    # Input
 18    Input,
 19    InputMap,
 20    Key,
 21    Node,
 22    Node2D,
 23    # Engine
 24    Property,
 25    Signal,
 26    Timer,
 27    # Math
 28    Vec2,
 29)
 30from simvx.graphics import App
 31
 32WIDTH, HEIGHT = 800, 600
 33
 34# Ship shapes: local-space points drawn by Node2D.draw_polygon
 35SHIP_SHAPE = [Vec2(0, -12), Vec2(-8, 10), Vec2(8, 10)]
 36THRUST_SHAPE = [Vec2(-5, 10), Vec2(0, 18), Vec2(5, 10)]
 37
 38
 39def random_asteroid_shape(radius: float, verts=10) -> list[Vec2]:
 40    return [
 41        Vec2(math.cos(a) * radius * random.uniform(0.7, 1.3), math.sin(a) * radius * random.uniform(0.7, 1.3))
 42        for a in (i / verts * math.tau for i in range(verts))
 43    ]
 44
 45
 46# ============================================================================
 47# Ship
 48# ============================================================================
 49
 50
 51class Ship(CharacterBody2D):
 52    turn_speed = Property(200.0, range=(50, 400), hint="Degrees per second")
 53    thrust_power = Property(300.0, range=(50, 800))
 54    max_speed = Property(400.0, range=(100, 1000))
 55    drag = Property(0.98, range=(0.9, 1.0))
 56
 57    def __init__(self, **kwargs):
 58        super().__init__(collision=10, **kwargs)
 59        self.fired = Signal()
 60        self.died = Signal()
 61        self._thrusting = False
 62        self._invincible = 0.0
 63        self._visible = True
 64
 65        self.fire_timer = self.add_child(Timer(0.15, name="FireTimer"))
 66
 67    def on_ready(self):
 68        self.position = Vec2(WIDTH / 2, HEIGHT / 2)
 69
 70    def on_physics_process(self, dt: float):
 71        # Turning
 72        if Input.is_action_pressed("turn_left"):
 73            self.rotation -= math.radians(self.turn_speed) * dt
 74        if Input.is_action_pressed("turn_right"):
 75            self.rotation += math.radians(self.turn_speed) * dt
 76
 77        # Thrust
 78        self._thrusting = Input.is_action_pressed("thrust")
 79        if self._thrusting:
 80            self.velocity += self.forward * (self.thrust_power * dt)
 81            speed = self.velocity.length()
 82            if speed > self.max_speed:
 83                self.velocity = self.velocity.normalized() * self.max_speed
 84
 85        self.velocity *= self.drag
 86        self.position += self.velocity * dt
 87        self.wrap_screen()
 88
 89        # Shooting (timer prevents rapid-fire)
 90        if Input.is_action_pressed("fire") and self.fire_timer.stopped:
 91            self.fire_timer.start()
 92            self.fired.emit()
 93
 94        # Invincibility blink
 95        if self._invincible > 0:
 96            self._invincible -= dt
 97            self._visible = int(self._invincible * 10) % 2 == 0
 98        else:
 99            self._visible = True
100
101    def on_draw(self, renderer):
102        if not self._visible:
103            return
104        self.draw_polygon(renderer, SHIP_SHAPE)
105        if self._thrusting:
106            self.draw_polygon(renderer, THRUST_SHAPE)
107
108    def respawn(self):
109        self.position = Vec2(WIDTH / 2, HEIGHT / 2)
110        self.velocity = Vec2()
111        self.rotation = 0.0
112        self._invincible = 2.0
113
114    @property
115    def is_invincible(self):
116        return self._invincible > 0
117
118
119# ============================================================================
120# Bullet
121# ============================================================================
122
123
124class Bullet(CharacterBody2D):
125    speed = Property(500.0)
126
127    def __init__(self, direction: Vec2 = None, **kwargs):
128        super().__init__(collision=2, **kwargs)
129        self.add_to_group("bullets")
130        if direction:
131            self.velocity = direction * self.speed
132
133        # Auto-expire via timer
134        t = self.add_child(Timer(1.5, name="Lifetime"))
135        t.timeout.connect(self.destroy)
136        t.start()
137
138    def on_physics_process(self, dt: float):
139        self.position += self.velocity * dt
140        self.wrap_screen()
141
142    def on_draw(self, renderer):
143        renderer.draw_circle(self.position, 2, segments=6)
144
145
146# ============================================================================
147# Asteroid
148# ============================================================================
149
150SIZES = {"large": 40, "medium": 20, "small": 10}
151SCORES = {"large": 20, "medium": 50, "small": 100}
152
153
154class Asteroid(CharacterBody2D):
155    size_class = Property("large", enum=["large", "medium", "small"])
156
157    def __init__(self, size_class="large", **kwargs):
158        radius = SIZES[size_class]
159        super().__init__(collision=radius, **kwargs)
160        self.size_class = size_class
161        self.add_to_group("asteroids")
162        self._shape = random_asteroid_shape(radius)
163        self._spin = math.radians(random.uniform(-90, 90))
164        # Random velocity
165        angle = random.uniform(0, math.tau)
166        speed = random.uniform(40, 120)
167        self.velocity = Vec2(math.cos(angle), math.sin(angle)) * speed
168
169    def on_physics_process(self, dt: float):
170        self.position += self.velocity * dt
171        self.wrap_screen(margin=SIZES[self.size_class])
172        self.rotation += self._spin * dt
173
174    def on_draw(self, renderer):
175        self.draw_polygon(renderer, self._shape)
176
177    def split(self) -> list[Asteroid]:
178        next_size = {"large": "medium", "medium": "small"}.get(self.size_class)
179        if not next_size:
180            return []
181        return [Asteroid(name="Asteroid", size_class=next_size, position=Vec2(self.position)) for _ in range(2)]
182
183
184# ============================================================================
185# MainMenu
186# ============================================================================
187
188
189class MainMenu(Node):
190    def __init__(self, **kwargs):
191        super().__init__(name="MainMenu", **kwargs)
192        self._blink = 0.0
193
194    def on_ready(self):
195        InputMap.add_action("thrust", [Key.W, Key.UP])
196        InputMap.add_action("turn_left", [Key.A, Key.LEFT])
197        InputMap.add_action("turn_right", [Key.D, Key.RIGHT])
198        InputMap.add_action("fire", [Key.SPACE])
199        InputMap.add_action("start", [Key.ENTER])
200
201    def on_process(self, dt):
202        self._blink += dt
203        if Input.is_action_just_pressed("start"):
204            self.tree.change_scene(AsteroidsGame())
205
206    def on_draw(self, renderer):
207        title = "ASTEROIDS"
208        tw = renderer.text_width(title, 6)
209        renderer.draw_text(title, (WIDTH // 2 - tw // 2, 120), scale=6, colour=(1.0, 1.0, 1.0))
210
211        # Draw decorative ship
212        cx = WIDTH // 2
213        pts = Node2D(position=Vec2(cx, 300)).transform_points([p * 2.5 for p in SHIP_SHAPE])
214        renderer.draw_lines(pts, closed=True)
215
216        # Controls
217        renderer.draw_text("W/UP  THRUST", (cx - 100, 370), scale=2, colour=(0.71, 0.71, 0.71))
218        renderer.draw_text("A/D   TURN", (cx - 100, 395), scale=2, colour=(0.71, 0.71, 0.71))
219        renderer.draw_text("SPACE FIRE", (cx - 100, 420), scale=2, colour=(0.71, 0.71, 0.71))
220
221        if int(self._blink * 2) % 2 == 0:
222            prompt = "PRESS ENTER TO START"
223            pw = renderer.text_width(prompt, 3)
224            renderer.draw_text(prompt, (WIDTH // 2 - pw // 2, 490), scale=3, colour=(0.78, 0.78, 0.78))
225
226
227# ============================================================================
228# Game Scene
229# ============================================================================
230
231
232class AsteroidsGame(Node2D):
233    start_asteroids = Property(4, range=(1, 12))
234    lives = Property(3, range=(1, 10))
235
236    def __init__(self, **kwargs):
237        super().__init__(name="AsteroidsGame", **kwargs)
238        self.ship = self.add_child(Ship(name="Ship"))
239        self._score = 0
240        self._lives = self.lives
241        self._wave = 0
242
243    def on_ready(self):
244        @self.ship.fired.connect
245        def on_fire():
246            fwd = self.ship.forward
247            self.add_child(
248                Bullet(
249                    name="Bullet",
250                    position=Vec2(self.ship.position) + fwd * 15,
251                    direction=fwd,
252                )
253            )
254
255        @self.ship.died.connect
256        def on_died():
257            self._lives -= 1
258            if self._lives <= 0:
259                self.tree.change_scene(GameOver(self._score))
260                return
261            self.ship.respawn()
262
263        self._spawn_wave()
264
265    def _spawn_wave(self):
266        self._wave += 1
267        for i in range(self.start_asteroids + self._wave - 1):
268            pos = Vec2(
269                random.choice([random.uniform(0, 100), random.uniform(WIDTH - 100, WIDTH)]),
270                random.choice([random.uniform(0, 100), random.uniform(HEIGHT - 100, HEIGHT)]),
271            )
272            self.add_child(Asteroid(name=f"Asteroid{i}", position=pos))
273
274    def on_physics_process(self, dt: float):
275        if not self.tree:
276            return
277        # Bullet-asteroid collisions via groups
278        for bullet in self.tree.get_group("bullets"):
279            for asteroid in bullet.get_overlapping(group="asteroids"):
280                self._score += SCORES[asteroid.size_class]
281                for piece in asteroid.split():
282                    self.add_child(piece)
283                asteroid.destroy()
284                bullet.destroy()
285                break
286
287        # Ship-asteroid collisions
288        if not self.ship.is_invincible:
289            if self.ship.get_overlapping(group="asteroids"):
290                self.ship.died()
291
292        # Next wave?
293        if not self.tree or not self.tree.get_group("asteroids"):
294            self._spawn_wave()
295
296    def on_draw(self, renderer):
297        # HUD: score
298        renderer.draw_text(f"SCORE {self._score:05d}", (10, 10), scale=2, colour=(1.0, 1.0, 1.0))
299        # HUD: draw remaining lives as small ships
300        for i in range(self._lives):
301            pts = Node2D(position=Vec2(WIDTH - 80 + i * 25, 18)).transform_points(SHIP_SHAPE)
302            renderer.draw_lines(pts, closed=True)
303
304
305# ============================================================================
306# GameOver
307# ============================================================================
308
309
310class GameOver(Node):
311    def __init__(self, score=0, **kwargs):
312        super().__init__(name="GameOver", **kwargs)
313        self.score = score
314        self._blink = 0.0
315
316    def on_process(self, dt):
317        self._blink += dt
318        if Input.is_action_just_pressed("start"):
319            self.tree.change_scene(MainMenu())
320
321    def on_draw(self, renderer):
322        title = "GAME OVER"
323        tw = renderer.text_width(title, 5)
324        renderer.draw_text(title, (WIDTH // 2 - tw // 2, 180), scale=5, colour=(1.0, 0.2, 0.2))
325
326        score_text = f"SCORE  {self.score:05d}"
327        sw = renderer.text_width(score_text, 3)
328        renderer.draw_text(score_text, (WIDTH // 2 - sw // 2, 300), scale=3, colour=(1.0, 1.0, 1.0))
329
330        if int(self._blink * 2) % 2 == 0:
331            prompt = "PRESS ENTER TO CONTINUE"
332            pw = renderer.text_width(prompt, 2)
333            renderer.draw_text(prompt, (WIDTH // 2 - pw // 2, 400), scale=2, colour=(0.78, 0.78, 0.78))
334
335
336# ============================================================================
337# Main
338# ============================================================================
339
340
341if __name__ == "__main__":
342    App("Asteroids", WIDTH, HEIGHT).run(MainMenu())