Space Invaders 3D¶
Classic arcade game with 3D meshes.
▶ Run in browserTags: game 3d collision waves
Same gameplay as the 2D version, rendered with the 3D pipeline.
Controls: A/D or Left/Right, or mouse/touch - Move Space or left-click/tap - Fire Enter or click/tap - Start / Restart
Source¶
1"""Space Invaders 3D: Classic arcade game with 3D meshes.
2
3# /// simvx
4# tags = ["game", "3d", "collision", "waves"]
5# web = { width = 1024, height = 768, root = "MainMenu" }
6# ///
7
8Same gameplay as the 2D version, rendered with the 3D pipeline.
9
10Controls:
11 A/D or Left/Right, or mouse/touch - Move
12 Space or left-click/tap - Fire
13 Enter or click/tap - Start / Restart
14"""
15
16
17import random
18
19from simvx.core import (
20 Camera3D,
21 Input,
22 InputMap,
23 Key,
24 Material,
25 Mesh,
26 MeshInstance3D,
27 MouseButton,
28 Node3D,
29 Property,
30 Signal,
31 Text2D,
32 Vec3,
33 screen_to_ray,
34)
35from simvx.graphics import App
36
37
38class Body3D(Node3D):
39 """A manually integrated 3D actor with a collision radius + group overlap query.
40
41 Space Invaders is an arcade game with NO physics simulation: aliens, bullets
42 and the player move by directly setting ``position`` and collisions are plain
43 sphere-vs-sphere radius tests. This is deliberately NOT a ``CharacterBody3D``
44 (the seam character runs a stepped collide-and-slide world); it is a light
45 ``Node3D`` carrying a radius and a group-scoped overlap poll (the durable
46 replacement for the old arcade ``CharacterBody3D.get_overlapping(group=)``).
47 """
48
49 def __init__(self, radius: float = 0.5, **kwargs):
50 super().__init__(**kwargs)
51 self.radius = float(radius)
52 self.velocity = Vec3()
53
54 def get_overlapping(self, group: str) -> list[Body3D]:
55 """Other ``Body3D`` nodes in ``group`` whose radius overlaps ours."""
56 if not self.tree:
57 return []
58 hits: list[Body3D] = []
59 for b in self.tree.get_group(group):
60 if b is self or not isinstance(b, Body3D):
61 continue
62 d = b.world_position - self.world_position
63 rr = self.radius + b.radius
64 if float(d.x) ** 2 + float(d.y) ** 2 + float(d.z) ** 2 <= rr * rr:
65 hits.append(b)
66 return hits
67
68
69WIDTH, HEIGHT = 1024, 768
70AREA_W = 30.0 # X extent (-15 to +15)
71AREA_H = 24.0 # Z extent (-12 to +12)
72
73# Alien type definitions: (mesh_factory, colour, points)
74ALIEN_TYPES = [
75 ("cone", (1.0, 0.3, 0.3, 1.0), 30), # Squid: red cone
76 ("cube", (0.3, 1.0, 0.3, 1.0), 20), # Crab: green cube
77 ("sphere", (0.3, 0.5, 1.0, 1.0), 10), # Octopus: blue sphere
78]
79
80# Shared meshes (created on first use)
81_meshes: dict[str, Mesh] = {}
82
83
84def _get_mesh(name: str) -> Mesh:
85 if name not in _meshes:
86 if name == "cone":
87 _meshes[name] = Mesh.cone(0.4, 0.8, segments=8)
88 elif name == "cube":
89 _meshes[name] = Mesh.cube(0.7)
90 elif name == "sphere":
91 _meshes[name] = Mesh.sphere(0.4, rings=6, segments=8)
92 elif name == "bullet":
93 _meshes[name] = Mesh.sphere(0.12, rings=4, segments=4)
94 elif name == "player":
95 _meshes[name] = Mesh.cube(0.8)
96 elif name == "barrier_block":
97 _meshes[name] = Mesh.cube(0.35)
98 return _meshes[name]
99
100
101# ============================================================================
102# Alien
103# ============================================================================
104
105
106class Alien(Body3D):
107 def __init__(self, alien_type=0, **kwargs):
108 super().__init__(radius=0.5, **kwargs)
109 self.add_to_group("aliens")
110 mesh_name, colour, points = ALIEN_TYPES[min(alien_type, 2)]
111 self.points = points
112 self.alien_type = alien_type
113 self.add_child(
114 MeshInstance3D(
115 name="Mesh",
116 mesh=_get_mesh(mesh_name),
117 material=Material(colour=colour),
118 )
119 )
120
121
122# ============================================================================
123# Player
124# ============================================================================
125
126
127class Player(Body3D):
128 speed = Property(15.0)
129
130 def __init__(self, **kwargs):
131 super().__init__(radius=0.5, **kwargs)
132 self.add_to_group("player")
133 self._cooldown = 0.0
134 self.fired = Signal()
135 self.camera = None # set by Game so we can unproject the pointer
136 self._pointer_steer = False
137 self.add_child(
138 MeshInstance3D(
139 name="Mesh",
140 mesh=_get_mesh("player"),
141 material=Material(colour=(0.3, 1.0, 0.3, 1.0)),
142 )
143 )
144
145 def _pointer_world_x(self):
146 """World-space X under the cursor on the play plane (y=0), or None."""
147 if self.camera is None:
148 return None
149 w, h = self.app.width, self.app.height
150 origin, direction = screen_to_ray(
151 Input.mouse_position, (w, h), self.camera.view_matrix, self.camera.projection_matrix(w / h)
152 )
153 if abs(direction.y) < 1e-6:
154 return None
155 t = -origin.y / direction.y
156 return float((origin + direction * t).x)
157
158 def on_fixed_update(self, dt):
159 kb_left = Input.is_action_pressed("move_left")
160 kb_right = Input.is_action_pressed("move_right")
161 # Keyboard takes priority; any pointer motion re-enables cursor steering.
162 if kb_left or kb_right:
163 self._pointer_steer = False
164 elif float(Input.mouse_delta.x) or float(Input.mouse_delta.y):
165 self._pointer_steer = True
166
167 if kb_left:
168 self.position.x -= self.speed * dt
169 if kb_right:
170 self.position.x += self.speed * dt
171
172 if self._pointer_steer:
173 target_x = self._pointer_world_x()
174 if target_x is not None:
175 step = self.speed * dt
176 dxp = target_x - self.position.x
177 if abs(dxp) > 0.05:
178 self.position.x += max(-step, min(step, dxp))
179
180 self.position.x = max(-AREA_W / 2 + 1, min(AREA_W / 2 - 1, self.position.x))
181
182 self._cooldown -= dt
183 if Input.is_action_pressed("fire") and self._cooldown <= 0:
184 self._cooldown = 0.4
185 self.fired()
186
187
188# ============================================================================
189# Bullet
190# ============================================================================
191
192
193class Bullet(Body3D):
194 def __init__(self, direction=-1, **kwargs):
195 super().__init__(radius=0.15, **kwargs)
196 self.direction = direction
197 self.speed = 25.0
198 if direction < 0:
199 self.add_to_group("player_bullets")
200 colour = (1.0, 1.0, 0.3, 1.0)
201 else:
202 self.add_to_group("alien_bullets")
203 colour = (1.0, 0.5, 0.2, 1.0)
204 self.add_child(
205 MeshInstance3D(
206 name="Mesh",
207 mesh=_get_mesh("bullet"),
208 material=Material(colour=colour),
209 )
210 )
211
212 def on_fixed_update(self, dt):
213 self.position.z += self.direction * self.speed * dt
214 if abs(self.position.z) > AREA_H / 2 + 2:
215 self.destroy()
216
217
218# ============================================================================
219# Barrier
220# ============================================================================
221
222BARRIER_PATTERN = [
223 " ##### ",
224 " ####### ",
225 "#########",
226 "#########",
227 "### ###",
228 "## ##",
229]
230
231
232class Barrier(Node3D):
233 def __init__(self, **kwargs):
234 super().__init__(**kwargs)
235 self._blocks: list[MeshInstance3D] = []
236
237 def on_ready(self):
238 mesh = _get_mesh("barrier_block")
239 mat = Material(colour=(0.2, 0.8, 0.4, 1.0))
240 bw = len(BARRIER_PATTERN[0])
241 bh = len(BARRIER_PATTERN)
242 spacing = 0.4
243 ox = -(bw - 1) * spacing / 2
244 oz = -(bh - 1) * spacing / 2
245 for row_i, row_str in enumerate(BARRIER_PATTERN):
246 for col_i, ch in enumerate(row_str):
247 if ch == "#":
248 block = self.add_child(
249 MeshInstance3D(
250 name=f"B_{row_i}_{col_i}",
251 mesh=mesh,
252 material=mat,
253 position=Vec3(ox + col_i * spacing, 0, oz + row_i * spacing),
254 )
255 )
256 self._blocks.append(block)
257
258 def hit(self, pos):
259 """Remove barrier blocks near the hit position."""
260 hit_any = False
261 for block in list(self._blocks):
262 bpos = self.position + block.position
263 dx = pos.x - bpos.x
264 dz = pos.z - bpos.z
265 if dx * dx + dz * dz < 0.5:
266 block.destroy()
267 self._blocks.remove(block)
268 hit_any = True
269 return hit_any
270
271
272# ============================================================================
273# MainMenu
274# ============================================================================
275
276
277class MainMenu(Node3D):
278 def __init__(self, **kwargs):
279 super().__init__(name="MainMenu", **kwargs)
280 self._blink_timer = 0.0
281
282 self.add_child(
283 Camera3D(
284 name="Camera",
285 position=Vec3(0, 35, 0),
286 fov=60,
287 )
288 ).look_at(Vec3(0, 0, 0), up=Vec3(0, 0, -1))
289
290 # Title
291 self._title = self.add_child(Text2D(text="SPACE INVADERS 3D", align="centre", font_scale=4.0))
292
293 # Legend
294 self._legend = [
295 self.add_child(Text2D(text="SQUID = 30 PTS", align="centre", font_scale=2.0, colour=(1.0, 0.31, 0.31))),
296 self.add_child(Text2D(text="CRAB = 20 PTS", align="centre", font_scale=2.0, colour=(0.31, 1.0, 0.31))),
297 self.add_child(Text2D(text="OCTO = 10 PTS", align="centre", font_scale=2.0, colour=(0.31, 0.51, 1.0))),
298 ]
299
300 # Blink prompt
301 self._prompt = self.add_child(Text2D(text="PRESS ENTER TO START", align="centre", font_scale=2.0))
302
303 # Controls hint
304 self._controls = self.add_child(
305 Text2D(text="MOVE A/D ARROWS or MOUSE FIRE SPACE or CLICK", align="centre", font_scale=1.4)
306 )
307
308 def _layout(self):
309 w, h = self.app.width, self.app.height
310 cx = w / 2
311 self._title.position = (cx, h * 0.12)
312 self._legend[0].position = (cx, h * 0.30)
313 self._legend[1].position = (cx, h * 0.37)
314 self._legend[2].position = (cx, h * 0.44)
315 self._prompt.position = (cx, h * 0.62)
316 self._controls.position = (cx, h - 48)
317
318 def on_ready(self):
319 InputMap.add_action("move_left", [Key.A, Key.LEFT])
320 InputMap.add_action("move_right", [Key.D, Key.RIGHT])
321 InputMap.add_action("fire", [Key.SPACE, MouseButton.LEFT])
322 InputMap.add_action("start", [Key.ENTER, MouseButton.LEFT])
323 self._layout()
324
325 def on_update(self, dt):
326 self._layout()
327 self._blink_timer += dt
328 self._prompt.text = "PRESS ENTER TO START" if int(self._blink_timer * 2) % 2 == 0 else ""
329 if Input.is_action_just_pressed("start"):
330 self.tree.change_scene(Game())
331
332
333# ============================================================================
334# Game
335# ============================================================================
336
337
338class Game(Node3D):
339 def __init__(self, **kwargs):
340 super().__init__(name="Game", **kwargs)
341
342 # Camera
343 self.camera = self.add_child(
344 Camera3D(
345 name="Camera",
346 position=Vec3(0, 35, 0),
347 fov=60,
348 )
349 )
350 self.camera.look_at(Vec3(0, 0, 0), up=Vec3(0, 0, -1))
351
352 self.player = self.add_child(
353 Player(
354 name="Player",
355 position=Vec3(0, 0, AREA_H / 2 - 2),
356 )
357 )
358
359 self.score = 0
360 self.lives = 3
361 self._alien_dir = 1
362 self._alien_speed = 2.0
363 self._alien_shoot_timer = 0.0
364 self._alien_shoot_interval = 1.0
365
366 # HUD
367 self._score_text = self.add_child(
368 Text2D(
369 text="SCORE 00000",
370 position=(10, 10), font_scale=2.0,
371 )
372 )
373 self._lives_text = self.add_child(Text2D(text="LIVES 3", align="right", font_scale=2.0))
374 self._controls = self.add_child(
375 Text2D(text="A/D ARROWS or MOUSE MOVE SPACE or CLICK FIRE", align="centre", font_scale=1.4)
376 )
377
378 def _layout(self):
379 w, h = self.app.width, self.app.height
380 self._lives_text.position = (w - 10, 10)
381 self._controls.position = (w / 2, h - 30)
382
383 def on_ready(self):
384 self.player.camera = self.camera
385 self._layout()
386
387 @self.player.fired.connect
388 def on_fire():
389 self.add_child(
390 Bullet(
391 direction=-1,
392 name="PBullet",
393 position=Vec3(self.player.position.x, 0, self.player.position.z - 1),
394 )
395 )
396
397 self._spawn_aliens()
398 self._spawn_barriers()
399
400 def _spawn_aliens(self):
401 row_types = [0, 1, 1, 2, 2]
402 start_x = -5 * 1.5
403 start_z = -AREA_H / 2 + 3
404 for row in range(5):
405 for col in range(11):
406 x = start_x + col * 1.5
407 z = start_z + row * 1.4
408 self.add_child(
409 Alien(
410 alien_type=row_types[row],
411 name=f"Alien_{row}_{col}",
412 position=Vec3(x, 0, z),
413 )
414 )
415
416 def _spawn_barriers(self):
417 spacing = AREA_W / 5
418 for i in range(4):
419 bx = -AREA_W / 2 + spacing * (i + 1)
420 self.add_child(
421 Barrier(
422 name=f"Barrier_{i}",
423 position=Vec3(bx, 0, AREA_H / 2 - 5),
424 )
425 )
426
427 def on_update(self, dt):
428 tree = self.tree
429 if not tree:
430 return
431 self._layout()
432 aliens = tree.get_group("aliens")
433 if not aliens:
434 self._alien_speed = 2.0
435 self._spawn_aliens()
436 return
437
438 # Speed scales inversely with remaining count
439 self._alien_speed = 2.0 + (55 - len(aliens)) * 0.3
440
441 # Move aliens horizontally
442 dx = self._alien_dir * self._alien_speed * dt
443 edge_hit = False
444 for alien in aliens:
445 alien.position.x += dx
446 if alien.position.x > AREA_W / 2 - 1 or alien.position.x < -AREA_W / 2 + 1:
447 edge_hit = True
448
449 if edge_hit:
450 self._alien_dir *= -1
451 for alien in aliens:
452 alien.position.z += 0.8
453 for alien in aliens:
454 if alien.position.z > AREA_H / 2 - 3:
455 tree.change_scene(GameOver(self.score))
456 return
457
458 # Alien shooting
459 self._alien_shoot_timer -= dt
460 if self._alien_shoot_timer <= 0 and aliens:
461 self._alien_shoot_timer = self._alien_shoot_interval
462 shooter = random.choice(list(aliens))
463 self.add_child(
464 Bullet(
465 direction=1,
466 name="ABullet",
467 position=Vec3(shooter.position.x, 0, shooter.position.z + 0.5),
468 )
469 )
470
471 # Update HUD
472 self._score_text.text = f"SCORE {self.score:05d}"
473 self._lives_text.text = f"LIVES {self.lives}"
474
475 def on_fixed_update(self, dt):
476 tree = self.tree
477 if not tree:
478 return
479
480 barriers = self.find_all(Barrier, direct=True)
481
482 # Player bullets vs aliens
483 for bullet in list(tree.get_group("player_bullets")):
484 hits = bullet.get_overlapping(group="aliens")
485 if hits:
486 alien = hits[0]
487 self.score += alien.points
488 alien.destroy()
489 bullet.destroy()
490 continue
491 for barrier in barriers:
492 if barrier.hit(bullet.position):
493 bullet.destroy()
494 break
495
496 # Alien bullets vs player
497 for bullet in list(tree.get_group("alien_bullets")):
498 hits = bullet.get_overlapping(group="player")
499 if hits:
500 bullet.destroy()
501 self.lives -= 1
502 if self.lives <= 0:
503 tree.change_scene(GameOver(self.score))
504 return
505 self.player.position = Vec3(0, 0, AREA_H / 2 - 2)
506 break
507 for barrier in barriers:
508 if barrier.hit(bullet.position):
509 bullet.destroy()
510 break
511
512
513# ============================================================================
514# GameOver
515# ============================================================================
516
517
518class GameOver(Node3D):
519 def __init__(self, score=0, **kwargs):
520 super().__init__(name="GameOver", **kwargs)
521 self.score = score
522 self._blink_timer = 0.0
523
524 self.add_child(
525 Camera3D(
526 name="Camera",
527 position=Vec3(0, 35, 0),
528 fov=60,
529 )
530 ).look_at(Vec3(0, 0, 0), up=Vec3(0, 0, -1))
531
532 self._title = self.add_child(
533 Text2D(text="GAME OVER", align="centre", font_scale=4.0, colour=(1.0, 0.2, 0.2))
534 )
535 self._score_text = self.add_child(Text2D(text=f"SCORE {score:05d}", align="centre", font_scale=2.5))
536 self._prompt = self.add_child(Text2D(text="PRESS ENTER TO CONTINUE", align="centre", font_scale=2.0))
537
538 def _layout(self):
539 w, h = self.app.width, self.app.height
540 cx = w / 2
541 self._title.position = (cx, h * 0.28)
542 self._score_text.position = (cx, h * 0.46)
543 self._prompt.position = (cx, h * 0.62)
544
545 def on_ready(self):
546 self._layout()
547
548 def on_update(self, dt):
549 self._layout()
550 self._blink_timer += dt
551 self._prompt.text = "PRESS ENTER TO CONTINUE" if int(self._blink_timer * 2) % 2 == 0 else ""
552 if Input.is_action_just_pressed("start"):
553 self.tree.change_scene(MainMenu())
554
555
556# ============================================================================
557# Main
558# ============================================================================
559
560
561if __name__ == "__main__":
562 App(title="Space Invaders 3D (Vulkan)", width=WIDTH, height=HEIGHT, physics_fps=60).run(MainMenu())