Asteroids 2D

Play Demo

Asteroids — Classic arcade game built with the engine (Vulkan backend). Run: uv run python packages/graphics/examples/game_asteroids2d.py

Source Code

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