Asteroids 3D

Top-down arcade game with 3D objects.

▶ Run in browser

Tags: game 3d collision shooting

Same gameplay as Asteroids 2D, rendered with the 3D pipeline.

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

Source

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