Asteroids 2D¶

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

Source Code¶

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