Asteroids 3D¶
Top-down arcade game with 3D objects.
▶ Run in browserTags: 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 Hold click / tap - Steer toward the pointer while thrusting and firing
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 Hold click / tap - Steer toward the pointer while thrusting and firing
15"""
16
17
18import math
19import random
20
21from simvx.core import (
22 Camera3D,
23 Input,
24 InputMap,
25 Key,
26 Material,
27 Mesh,
28 MeshInstance3D,
29 MouseButton,
30 Node3D,
31 Property,
32 Quat,
33 Signal,
34 Text2D,
35 Timer,
36 Vec3,
37)
38from simvx.graphics import App
39
40# Play area: XZ plane
41AREA_W = 40.0 # X extent (-20 to +20)
42AREA_H = 30.0 # Z extent (-15 to +15)
43
44
45class Body3D(Node3D):
46 """A manually integrated 3D actor with a collision radius + group overlap query.
47
48 Asteroids is a wrap-around arcade game with NO physics simulation: every actor
49 integrates ``position += velocity * dt`` itself and collisions are plain
50 sphere-vs-sphere radius tests on the XZ plane. This is deliberately NOT a
51 ``CharacterBody3D`` (the seam character runs a stepped collide-and-slide world);
52 it is a light ``Node3D`` carrying a radius and a group-scoped overlap poll.
53 """
54
55 def __init__(self, radius: float = 0.5, **kwargs):
56 super().__init__(**kwargs)
57 self.radius = float(radius)
58 self.velocity = Vec3()
59
60 def get_overlapping(self, group: str) -> list[Body3D]:
61 """Other ``Body3D`` nodes in ``group`` whose radius overlaps ours."""
62 if not self.tree:
63 return []
64 hits: list[Body3D] = []
65 for b in self.tree.get_group(group):
66 if b is self or not isinstance(b, Body3D):
67 continue
68 d = b.world_position - self.world_position
69 rr = self.radius + b.radius
70 if float(d.x) ** 2 + float(d.y) ** 2 + float(d.z) ** 2 <= rr * rr:
71 hits.append(b)
72 return hits
73
74
75def _wrap_xz(pos, margin=1.0):
76 """Wrap a position on the XZ plane, keeping Y=0."""
77 hx = AREA_W / 2 + margin
78 hz = AREA_H / 2 + margin
79 return Vec3(
80 (pos.x + hx) % (2 * hx) - hx,
81 0,
82 (pos.z + hz) % (2 * hz) - hz,
83 )
84
85
86# ============================================================================
87# Ship
88# ============================================================================
89
90
91class Ship(Body3D):
92 turn_speed = Property(200.0, range=(50, 400), hint="Degrees per second")
93 thrust_power = Property(20.0, range=(5, 50))
94 max_speed = Property(25.0, range=(5, 60))
95 drag = Property(0.98, range=(0.9, 1.0))
96
97 def __init__(self, **kwargs):
98 super().__init__(radius=0.8, **kwargs)
99 self.fired = Signal()
100 self.died = Signal()
101 self._thrusting = False
102 self._invincible = 0.0
103 self._visible = True
104
105 self.fire_timer = self.add_child(Timer(0.15, name="FireTimer"))
106
107 # Ship body: cone
108 self._mesh = self.add_child(
109 MeshInstance3D(
110 name="Body",
111 mesh=Mesh.cone(0.5, 1.4, segments=8),
112 material=Material(colour=(0.7, 0.85, 1.0, 1.0)),
113 )
114 )
115 self._mesh.rotation = Quat.from_euler(math.radians(-90), 0, 0)
116
117 # Exhaust flame
118 self._thrust_mesh = self.add_child(
119 MeshInstance3D(
120 name="Thrust",
121 mesh=Mesh.cone(0.25, 0.7, segments=6),
122 material=Material(colour=(1.0, 0.5, 0.1, 1.0)),
123 position=Vec3(0, 0, 0.9),
124 )
125 )
126 self._thrust_mesh.rotation = Quat.from_euler(math.radians(90), 0, 0)
127
128 def on_fixed_update(self, dt: float):
129 step = math.radians(self.turn_speed) * dt
130 if Input.is_action_pressed("turn_left"):
131 self.rotate_y(step)
132 if Input.is_action_pressed("turn_right"):
133 self.rotate_y(-step)
134
135 # Pointer controls: steer toward the cursor and thrust/fire while the left
136 # button is held (web touch arrives as MouseButton.LEFT). The overhead
137 # camera maps screen +x -> world +X and screen +y -> world +Z, so the
138 # cursor offset from screen centre is the desired heading on the XZ plane.
139 pointer_held = Input.is_mouse_button_pressed(MouseButton.LEFT)
140 if pointer_held and self.app is not None:
141 m = Input.mouse_position
142 tx, tz = float(m.x) - self.app.width / 2, float(m.y) - self.app.height / 2
143 if tx * tx + tz * tz > 400: # ignore tiny offsets near screen centre
144 target = math.atan2(-tx, -tz)
145 fwd = self.forward
146 current = math.atan2(-float(fwd.x), -float(fwd.z))
147 diff = (target - current + math.pi) % math.tau - math.pi
148 self.rotate_y(max(-step, min(step, diff)))
149
150 self._thrusting = Input.is_action_pressed("thrust") or pointer_held
151 if self._thrusting:
152 fwd = Vec3(self.forward.x, 0, self.forward.z).normalized()
153 self.velocity += fwd * (self.thrust_power * dt)
154 speed = self.velocity.length()
155 if speed > self.max_speed:
156 self.velocity = self.velocity.normalized() * self.max_speed
157
158 self.velocity = Vec3(self.velocity.x * self.drag, 0, self.velocity.z * self.drag)
159 self.position += self.velocity * dt
160 self.position = _wrap_xz(self.position)
161
162 # Show/hide thrust flame
163 self._thrust_mesh.scale = Vec3(1) if self._thrusting else Vec3(0)
164
165 # Shooting
166 if (Input.is_action_pressed("fire") or pointer_held) and self.fire_timer.stopped:
167 self.fire_timer.start()
168 self.fired()
169
170 # Invincibility blink
171 if self._invincible > 0:
172 self._invincible -= dt
173 self._visible = int(self._invincible * 10) % 2 == 0
174 else:
175 self._visible = True
176 self._mesh.scale = Vec3(1 if self._visible else 0)
177
178 def respawn(self):
179 self.position = Vec3()
180 self.velocity = Vec3()
181 self.rotation = Quat()
182 self._invincible = 2.0
183
184 @property
185 def is_invincible(self):
186 return self._invincible > 0
187
188
189# ============================================================================
190# Bullet
191# ============================================================================
192
193
194class Bullet(Body3D):
195 speed = Property(35.0)
196
197 def __init__(self, direction: Vec3 = None, **kwargs):
198 super().__init__(radius=0.2, **kwargs)
199 self.add_to_group("bullets")
200 if direction:
201 d = Vec3(direction.x, 0, direction.z).normalized()
202 self.velocity = d * self.speed
203
204 t = self.add_child(Timer(1.5, name="Lifetime"))
205 t.timeout.connect(self.destroy)
206 t.start()
207
208 self.add_child(
209 MeshInstance3D(
210 name="Mesh",
211 mesh=Mesh.sphere(0.15, rings=4, segments=4),
212 material=Material(colour=(1.0, 1.0, 0.3, 1.0)),
213 )
214 )
215
216 def on_fixed_update(self, dt: float):
217 self.position += self.velocity * dt
218 self.position = _wrap_xz(self.position)
219
220
221# ============================================================================
222# Asteroid
223# ============================================================================
224
225SIZES = {"large": 2.5, "medium": 1.3, "small": 0.6}
226SCORES = {"large": 20, "medium": 50, "small": 100}
227
228_asteroid_meshes: dict[str, Mesh] = {}
229
230
231def _get_asteroid_mesh(size_class: str) -> Mesh:
232 if size_class not in _asteroid_meshes:
233 _asteroid_meshes[size_class] = Mesh.sphere(SIZES[size_class], rings=6, segments=8)
234 return _asteroid_meshes[size_class]
235
236
237class Asteroid(Body3D):
238 size_class = Property("large", enum=["large", "medium", "small"])
239
240 def __init__(self, size_class="large", **kwargs):
241 radius = SIZES[size_class]
242 super().__init__(radius=radius, **kwargs)
243 self.size_class = size_class
244 self.add_to_group("asteroids")
245
246 self._spin_axis = Vec3(random.uniform(-1, 1), random.uniform(-1, 1), random.uniform(-1, 1)).normalized()
247 self._spin_speed = math.radians(random.uniform(30, 90))
248
249 angle = random.uniform(0, math.tau)
250 speed = random.uniform(2, 8)
251 self.velocity = Vec3(math.cos(angle) * speed, 0, math.sin(angle) * speed)
252
253 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)}
254 self.add_child(
255 MeshInstance3D(
256 name="Mesh",
257 mesh=_get_asteroid_mesh(size_class),
258 material=Material(colour=colours[size_class]),
259 )
260 )
261
262 def on_fixed_update(self, dt: float):
263 self.position += self.velocity * dt
264 self.position = _wrap_xz(self.position, margin=SIZES[self.size_class])
265 self.rotate(self._spin_axis, self._spin_speed * dt)
266
267 def split(self) -> list[Asteroid]:
268 next_size = {"large": "medium", "medium": "small"}.get(self.size_class)
269 if not next_size:
270 return []
271 return [
272 Asteroid(name="Asteroid", size_class=next_size, position=Vec3(self.position.x, 0, self.position.z))
273 for _ in range(2)
274 ]
275
276
277# ============================================================================
278# Game Scene
279# ============================================================================
280
281
282class AsteroidsGame(Node3D):
283 start_asteroids = Property(4, range=(1, 12))
284 lives = Property(3, range=(1, 10))
285
286 def __init__(self, **kwargs):
287 super().__init__(name="AsteroidsGame", **kwargs)
288
289 # Fixed overhead camera
290 self.camera = self.add_child(
291 Camera3D(
292 name="Camera",
293 position=Vec3(0, 35, 0),
294 fov=60,
295 )
296 )
297 self.camera.look_at(Vec3(0, 0, 0), up=Vec3(0, 0, -1))
298
299 self.ship = self.add_child(Ship(name="Ship"))
300 self._score = 0
301 self._lives = self.lives
302 self._wave = 0
303 self._game_over = False
304
305 # HUD
306 self._score_text = self.add_child(
307 Text2D(
308 text="SCORE 0 LIVES 3 WAVE 1",
309 position=(10, 10), font_scale=2.0,
310 )
311 )
312 # Centred over the play area; anchored to the live window size each frame.
313 self._status_text = self.add_child(
314 Text2D(
315 text="",
316 align="centre", font_scale=3.0,
317 )
318 )
319 # Controls hint pinned to the bottom of the window (repositioned per frame).
320 self._controls_text = self.add_child(
321 Text2D(
322 text="W/UP THRUST A/D TURN SPACE FIRE HOLD CLICK/TAP TO FLY + FIRE",
323 align="centre", font_scale=1.2, colour=(0.75, 0.75, 0.75, 1.0),
324 )
325 )
326
327 def on_ready(self):
328 InputMap.add_action("thrust", [Key.W, Key.UP])
329 InputMap.add_action("turn_left", [Key.A, Key.LEFT])
330 InputMap.add_action("turn_right", [Key.D, Key.RIGHT])
331 InputMap.add_action("fire", [Key.SPACE])
332
333 @self.ship.fired.connect
334 def on_fire():
335 fwd = self.ship.forward
336 spawn = self.ship.position + fwd * 1.0
337 self.add_child(
338 Bullet(
339 name="Bullet",
340 position=Vec3(spawn.x, 0, spawn.z),
341 direction=fwd,
342 )
343 )
344
345 @self.ship.died.connect
346 def on_died():
347 self._lives -= 1
348 if self._lives <= 0:
349 self._game_over = True
350 self._status_text.text = "GAME OVER\nPRESS FIRE OR TAP TO RESTART"
351 else:
352 self.ship.respawn()
353
354 self._spawn_wave()
355
356 def _spawn_wave(self):
357 self._wave += 1
358 for i in range(self.start_asteroids + self._wave - 1):
359 edge = random.choice(["left", "right", "top", "bottom"])
360 if edge == "left":
361 x, z = -AREA_W / 2, random.uniform(-AREA_H / 2, AREA_H / 2)
362 elif edge == "right":
363 x, z = AREA_W / 2, random.uniform(-AREA_H / 2, AREA_H / 2)
364 elif edge == "top":
365 x, z = random.uniform(-AREA_W / 2, AREA_W / 2), -AREA_H / 2
366 else:
367 x, z = random.uniform(-AREA_W / 2, AREA_W / 2), AREA_H / 2
368 self.add_child(Asteroid(name=f"Asteroid{i}", position=Vec3(x, 0, z)))
369
370 def on_fixed_update(self, dt: float):
371 if self._game_over:
372 return
373
374 # Bullet-asteroid collisions
375 for bullet in self.tree.get_group("bullets"):
376 for asteroid in bullet.get_overlapping(group="asteroids"):
377 self._score += SCORES[asteroid.size_class]
378 for piece in asteroid.split():
379 self.add_child(piece)
380 asteroid.destroy()
381 bullet.destroy()
382 break
383
384 # Ship-asteroid collisions
385 if not self.ship.is_invincible:
386 if self.ship.get_overlapping(group="asteroids"):
387 self.ship.died()
388
389 # Next wave
390 if not self.tree.get_group("asteroids") and not self._game_over:
391 self._spawn_wave()
392
393 def _restart(self):
394 for asteroid in list(self.tree.get_group("asteroids")):
395 asteroid.destroy()
396 for bullet in list(self.tree.get_group("bullets")):
397 bullet.destroy()
398 self._score = 0
399 self._lives = self.lives
400 self._wave = 0
401 self._game_over = False
402 self._status_text.text = ""
403 self.ship.respawn()
404 self._spawn_wave()
405
406 def on_update(self, dt: float):
407 self._score_text.text = f"SCORE {self._score} LIVES {self._lives} WAVE {self._wave}"
408
409 # Keep the centred status + bottom controls hint pinned to the live window.
410 if self.app is not None:
411 self._status_text.position = (self.app.width / 2, self.app.height / 2)
412 self._controls_text.position = (self.app.width / 2, self.app.height - 28)
413
414 if self._game_over and (
415 Input.is_action_just_pressed("fire") or Input.is_mouse_button_just_pressed(MouseButton.LEFT)
416 ):
417 self._restart()
418
419
420# ============================================================================
421# Main
422# ============================================================================
423
424
425if __name__ == "__main__":
426 App(title="Asteroids 3D (Vulkan)", width=1024, height=768, physics_fps=60).run(AsteroidsGame())