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
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())