Asteroids 3D

Asteroids 3D — Top-down arcade game with 3D objects (Vulkan Backend). Same gameplay as the SDL3 version, rendered with Vulkan.

Run: uv run python packages/graphics/examples/asteroids3d.py

Controls: W/Up - Thrust A/D or Left/Right - Turn Space - Fire

Source Code

  1"""
  2Asteroids 3D — Top-down arcade game with 3D objects (Vulkan Backend).
  3Same gameplay as the SDL3 version, rendered with Vulkan.
  4
  5Run: uv run python packages/graphics/examples/asteroids3d.py
  6
  7Controls:
  8    W/Up - Thrust
  9    A/D or Left/Right - Turn
 10    Space - Fire
 11"""
 12
 13
 14import math
 15import random
 16
 17from simvx.core import (
 18    Camera3D,
 19    CharacterBody3D,
 20    Input,
 21    InputMap,
 22    Key,
 23    Material,
 24    Mesh,
 25    MeshInstance3D,
 26    Node3D,
 27    Property,
 28    Quat,
 29    Signal,
 30    Text2D,
 31    Timer,
 32    Vec3,
 33)
 34from simvx.graphics import App
 35
 36# Play area — XZ plane
 37AREA_W = 40.0  # X extent (-20 to +20)
 38AREA_H = 30.0  # Z extent (-15 to +15)
 39
 40
 41def _wrap_xz(pos, margin=1.0):
 42    """Wrap a position on the XZ plane, keeping Y=0."""
 43    hx = AREA_W / 2 + margin
 44    hz = AREA_H / 2 + margin
 45    return Vec3(
 46        (pos.x + hx) % (2 * hx) - hx,
 47        0,
 48        (pos.z + hz) % (2 * hz) - hz,
 49    )
 50
 51
 52# ============================================================================
 53# Ship
 54# ============================================================================
 55
 56
 57class Ship(CharacterBody3D):
 58    turn_speed = Property(200.0, range=(50, 400), hint="Degrees per second")
 59    thrust_power = Property(20.0, range=(5, 50))
 60    max_speed = Property(25.0, range=(5, 60))
 61    drag = Property(0.98, range=(0.9, 1.0))
 62
 63    def __init__(self, **kwargs):
 64        super().__init__(collision=0.8, **kwargs)
 65        self.fired = Signal()
 66        self.died = Signal()
 67        self._thrusting = False
 68        self._invincible = 0.0
 69        self._visible = True
 70
 71        self.fire_timer = self.add_child(Timer(0.15, name="FireTimer"))
 72
 73        # Ship body — cone
 74        self._mesh = self.add_child(
 75            MeshInstance3D(
 76                name="Body",
 77                mesh=Mesh.cone(0.5, 1.4, segments=8),
 78                material=Material(colour=(0.7, 0.85, 1.0, 1.0)),
 79            )
 80        )
 81        self._mesh.rotation = Quat.from_euler(math.radians(-90), 0, 0)
 82
 83        # Exhaust flame
 84        self._thrust_mesh = self.add_child(
 85            MeshInstance3D(
 86                name="Thrust",
 87                mesh=Mesh.cone(0.25, 0.7, segments=6),
 88                material=Material(colour=(1.0, 0.5, 0.1, 1.0)),
 89                position=Vec3(0, 0, 0.9),
 90            )
 91        )
 92        self._thrust_mesh.rotation = Quat.from_euler(math.radians(90), 0, 0)
 93
 94    def physics_process(self, dt: float):
 95        if Input.is_action_pressed("turn_left"):
 96            self.rotate_y(math.radians(self.turn_speed) * dt)
 97        if Input.is_action_pressed("turn_right"):
 98            self.rotate_y(-math.radians(self.turn_speed) * dt)
 99
100        self._thrusting = Input.is_action_pressed("thrust")
101        if self._thrusting:
102            fwd = Vec3(self.forward.x, 0, self.forward.z).normalized()
103            self.velocity += fwd * (self.thrust_power * dt)
104            speed = self.velocity.length()
105            if speed > self.max_speed:
106                self.velocity = self.velocity.normalized() * self.max_speed
107
108        self.velocity = Vec3(self.velocity.x * self.drag, 0, self.velocity.z * self.drag)
109        self.position += self.velocity * dt
110        self.position = _wrap_xz(self.position)
111
112        # Show/hide thrust flame
113        self._thrust_mesh.scale = Vec3(1) if self._thrusting else Vec3(0)
114
115        # Shooting
116        if Input.is_action_pressed("fire") and self.fire_timer.stopped:
117            self.fire_timer.start()
118            self.fired()
119
120        # Invincibility blink
121        if self._invincible > 0:
122            self._invincible -= dt
123            self._visible = int(self._invincible * 10) % 2 == 0
124        else:
125            self._visible = True
126        self._mesh.scale = Vec3(1 if self._visible else 0)
127
128    def respawn(self):
129        self.position = Vec3()
130        self.velocity = Vec3()
131        self.rotation = Quat()
132        self._invincible = 2.0
133
134    @property
135    def is_invincible(self):
136        return self._invincible > 0
137
138
139# ============================================================================
140# Bullet
141# ============================================================================
142
143
144class Bullet(CharacterBody3D):
145    speed = Property(35.0)
146
147    def __init__(self, direction: Vec3 = None, **kwargs):
148        super().__init__(collision=0.2, **kwargs)
149        self.add_to_group("bullets")
150        if direction:
151            d = Vec3(direction.x, 0, direction.z).normalized()
152            self.velocity = d * self.speed
153
154        t = self.add_child(Timer(1.5, name="Lifetime"))
155        t.timeout.connect(self.destroy)
156        t.start()
157
158        self.add_child(
159            MeshInstance3D(
160                name="Mesh",
161                mesh=Mesh.sphere(0.15, rings=4, segments=4),
162                material=Material(colour=(1.0, 1.0, 0.3, 1.0)),
163            )
164        )
165
166    def physics_process(self, dt: float):
167        self.position += self.velocity * dt
168        self.position = _wrap_xz(self.position)
169
170
171# ============================================================================
172# Asteroid
173# ============================================================================
174
175SIZES = {"large": 2.5, "medium": 1.3, "small": 0.6}
176SCORES = {"large": 20, "medium": 50, "small": 100}
177
178_asteroid_meshes: dict[str, Mesh] = {}
179
180
181def _get_asteroid_mesh(size_class: str) -> Mesh:
182    if size_class not in _asteroid_meshes:
183        _asteroid_meshes[size_class] = Mesh.sphere(SIZES[size_class], rings=6, segments=8)
184    return _asteroid_meshes[size_class]
185
186
187class Asteroid(CharacterBody3D):
188    size_class = Property("large", enum=["large", "medium", "small"])
189
190    def __init__(self, size_class="large", **kwargs):
191        radius = SIZES[size_class]
192        super().__init__(collision=radius, **kwargs)
193        self.size_class = size_class
194        self.add_to_group("asteroids")
195
196        self._spin_axis = Vec3(random.uniform(-1, 1), random.uniform(-1, 1), random.uniform(-1, 1)).normalized()
197        self._spin_speed = math.radians(random.uniform(30, 90))
198
199        angle = random.uniform(0, math.tau)
200        speed = random.uniform(2, 8)
201        self.velocity = Vec3(math.cos(angle) * speed, 0, math.sin(angle) * speed)
202
203        colours = {"large": (0.6, 0.5, 0.4, 1.0), "medium": (0.7, 0.6, 0.4, 1.0), "small": (0.8, 0.7, 0.5, 1.0)}
204        self.add_child(
205            MeshInstance3D(
206                name="Mesh",
207                mesh=_get_asteroid_mesh(size_class),
208                material=Material(colour=colours[size_class]),
209            )
210        )
211
212    def physics_process(self, dt: float):
213        self.position += self.velocity * dt
214        self.position = _wrap_xz(self.position, margin=SIZES[self.size_class])
215        self.rotate(self._spin_axis, self._spin_speed * dt)
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 [
222            Asteroid(name="Asteroid", size_class=next_size, position=Vec3(self.position.x, 0, self.position.z))
223            for _ in range(2)
224        ]
225
226
227# ============================================================================
228# Game Scene
229# ============================================================================
230
231
232class AsteroidsGame(Node3D):
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
239        # Fixed overhead camera
240        self.camera = self.add_child(
241            Camera3D(
242                name="Camera",
243                position=Vec3(0, 35, 0),
244                fov=60,
245            )
246        )
247        self.camera.look_at(Vec3(0, 0, 0), up=Vec3(0, 0, -1))
248
249        self.ship = self.add_child(Ship(name="Ship"))
250        self._score = 0
251        self._lives = self.lives
252        self._wave = 0
253        self._game_over = False
254
255        # HUD
256        self._score_text = self.add_child(
257            Text2D(
258                text="SCORE 0  LIVES 3  WAVE 1",
259                x=10,
260                y=10,
261                font_scale=2.0,
262            )
263        )
264        self._status_text = self.add_child(
265            Text2D(
266                text="",
267                x=400,
268                y=350,
269                font_scale=3.0,
270            )
271        )
272
273    def ready(self):
274        InputMap.add_action("thrust", [Key.W, Key.UP])
275        InputMap.add_action("turn_left", [Key.A, Key.LEFT])
276        InputMap.add_action("turn_right", [Key.D, Key.RIGHT])
277        InputMap.add_action("fire", [Key.SPACE])
278
279        @self.ship.fired.connect
280        def on_fire():
281            fwd = self.ship.forward
282            spawn = self.ship.position + fwd * 1.0
283            self.add_child(
284                Bullet(
285                    name="Bullet",
286                    position=Vec3(spawn.x, 0, spawn.z),
287                    direction=fwd,
288                )
289            )
290
291        @self.ship.died.connect
292        def on_died():
293            self._lives -= 1
294            if self._lives <= 0:
295                self._game_over = True
296                self._status_text.text = "GAME OVER"
297            else:
298                self.ship.respawn()
299
300        self._spawn_wave()
301
302    def _spawn_wave(self):
303        self._wave += 1
304        for i in range(self.start_asteroids + self._wave - 1):
305            edge = random.choice(["left", "right", "top", "bottom"])
306            if edge == "left":
307                x, z = -AREA_W / 2, random.uniform(-AREA_H / 2, AREA_H / 2)
308            elif edge == "right":
309                x, z = AREA_W / 2, random.uniform(-AREA_H / 2, AREA_H / 2)
310            elif edge == "top":
311                x, z = random.uniform(-AREA_W / 2, AREA_W / 2), -AREA_H / 2
312            else:
313                x, z = random.uniform(-AREA_W / 2, AREA_W / 2), AREA_H / 2
314            self.add_child(Asteroid(name=f"Asteroid{i}", position=Vec3(x, 0, z)))
315
316    def physics_process(self, dt: float):
317        if self._game_over:
318            return
319
320        # Bullet-asteroid collisions
321        for bullet in self.tree.get_group("bullets"):
322            for asteroid in bullet.get_overlapping(group="asteroids"):
323                self._score += SCORES[asteroid.size_class]
324                for piece in asteroid.split():
325                    self.add_child(piece)
326                asteroid.destroy()
327                bullet.destroy()
328                break
329
330        # Ship-asteroid collisions
331        if not self.ship.is_invincible:
332            if self.ship.get_overlapping(group="asteroids"):
333                self.ship.died()
334
335        # Next wave
336        if not self.tree.get_group("asteroids") and not self._game_over:
337            self._spawn_wave()
338
339    def process(self, dt: float):
340        self._score_text.text = f"SCORE {self._score}  LIVES {self._lives}  WAVE {self._wave}"
341
342
343# ============================================================================
344# Main
345# ============================================================================
346
347
348if __name__ == "__main__":
349    App(title="Asteroids 3D (Vulkan)", width=1024, height=768, physics_fps=60).run(AsteroidsGame())