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