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 - Move Space - Fire Enter - 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 - Move
12 Space - Fire
13 Enter - Start / Restart
14"""
15
16
17import random
18
19from simvx.core import (
20 Camera3D,
21 CharacterBody3D,
22 Input,
23 InputMap,
24 Key,
25 Material,
26 Mesh,
27 MeshInstance3D,
28 Node3D,
29 Property,
30 Signal,
31 Text2D,
32 Vec3,
33)
34from simvx.graphics import App
35
36WIDTH, HEIGHT = 1024, 768
37AREA_W = 30.0 # X extent (-15 to +15)
38AREA_H = 24.0 # Z extent (-12 to +12)
39
40# Alien type definitions: (mesh_factory, colour, points)
41ALIEN_TYPES = [
42 ("cone", (1.0, 0.3, 0.3, 1.0), 30), # Squid: red cone
43 ("cube", (0.3, 1.0, 0.3, 1.0), 20), # Crab: green cube
44 ("sphere", (0.3, 0.5, 1.0, 1.0), 10), # Octopus: blue sphere
45]
46
47# Shared meshes (created on first use)
48_meshes: dict[str, Mesh] = {}
49
50
51def _get_mesh(name: str) -> Mesh:
52 if name not in _meshes:
53 if name == "cone":
54 _meshes[name] = Mesh.cone(0.4, 0.8, segments=8)
55 elif name == "cube":
56 _meshes[name] = Mesh.cube(0.7)
57 elif name == "sphere":
58 _meshes[name] = Mesh.sphere(0.4, rings=6, segments=8)
59 elif name == "bullet":
60 _meshes[name] = Mesh.sphere(0.12, rings=4, segments=4)
61 elif name == "player":
62 _meshes[name] = Mesh.cube(0.8)
63 elif name == "barrier_block":
64 _meshes[name] = Mesh.cube(0.35)
65 return _meshes[name]
66
67
68# ============================================================================
69# Alien
70# ============================================================================
71
72
73class Alien(CharacterBody3D):
74 def __init__(self, alien_type=0, **kwargs):
75 super().__init__(collision=0.5, **kwargs)
76 self.add_to_group("aliens")
77 mesh_name, colour, points = ALIEN_TYPES[min(alien_type, 2)]
78 self.points = points
79 self.alien_type = alien_type
80 self.add_child(
81 MeshInstance3D(
82 name="Mesh",
83 mesh=_get_mesh(mesh_name),
84 material=Material(colour=colour),
85 )
86 )
87
88
89# ============================================================================
90# Player
91# ============================================================================
92
93
94class Player(CharacterBody3D):
95 speed = Property(15.0)
96
97 def __init__(self, **kwargs):
98 super().__init__(collision=0.5, **kwargs)
99 self.add_to_group("player")
100 self._cooldown = 0.0
101 self.fired = Signal()
102 self.add_child(
103 MeshInstance3D(
104 name="Mesh",
105 mesh=_get_mesh("player"),
106 material=Material(colour=(0.3, 1.0, 0.3, 1.0)),
107 )
108 )
109
110 def on_physics_process(self, dt):
111 if Input.is_action_pressed("move_left"):
112 self.position.x -= self.speed * dt
113 if Input.is_action_pressed("move_right"):
114 self.position.x += self.speed * dt
115 self.position.x = max(-AREA_W / 2 + 1, min(AREA_W / 2 - 1, self.position.x))
116
117 self._cooldown -= dt
118 if Input.is_action_pressed("fire") and self._cooldown <= 0:
119 self._cooldown = 0.4
120 self.fired()
121
122
123# ============================================================================
124# Bullet
125# ============================================================================
126
127
128class Bullet(CharacterBody3D):
129 def __init__(self, direction=-1, **kwargs):
130 super().__init__(collision=0.15, **kwargs)
131 self.direction = direction
132 self.speed = 25.0
133 if direction < 0:
134 self.add_to_group("player_bullets")
135 colour = (1.0, 1.0, 0.3, 1.0)
136 else:
137 self.add_to_group("alien_bullets")
138 colour = (1.0, 0.5, 0.2, 1.0)
139 self.add_child(
140 MeshInstance3D(
141 name="Mesh",
142 mesh=_get_mesh("bullet"),
143 material=Material(colour=colour),
144 )
145 )
146
147 def on_physics_process(self, dt):
148 self.position.z += self.direction * self.speed * dt
149 if abs(self.position.z) > AREA_H / 2 + 2:
150 self.destroy()
151
152
153# ============================================================================
154# Barrier
155# ============================================================================
156
157BARRIER_PATTERN = [
158 " ##### ",
159 " ####### ",
160 "#########",
161 "#########",
162 "### ###",
163 "## ##",
164]
165
166
167class Barrier(Node3D):
168 def __init__(self, **kwargs):
169 super().__init__(**kwargs)
170 self._blocks: list[MeshInstance3D] = []
171
172 def on_ready(self):
173 mesh = _get_mesh("barrier_block")
174 mat = Material(colour=(0.2, 0.8, 0.4, 1.0))
175 bw = len(BARRIER_PATTERN[0])
176 bh = len(BARRIER_PATTERN)
177 spacing = 0.4
178 ox = -(bw - 1) * spacing / 2
179 oz = -(bh - 1) * spacing / 2
180 for row_i, row_str in enumerate(BARRIER_PATTERN):
181 for col_i, ch in enumerate(row_str):
182 if ch == "#":
183 block = self.add_child(
184 MeshInstance3D(
185 name=f"B_{row_i}_{col_i}",
186 mesh=mesh,
187 material=mat,
188 position=Vec3(ox + col_i * spacing, 0, oz + row_i * spacing),
189 )
190 )
191 self._blocks.append(block)
192
193 def hit(self, pos):
194 """Remove barrier blocks near the hit position."""
195 hit_any = False
196 for block in list(self._blocks):
197 bpos = self.position + block.position
198 dx = pos.x - bpos.x
199 dz = pos.z - bpos.z
200 if dx * dx + dz * dz < 0.5:
201 block.destroy()
202 self._blocks.remove(block)
203 hit_any = True
204 return hit_any
205
206
207# ============================================================================
208# MainMenu
209# ============================================================================
210
211
212class MainMenu(Node3D):
213 def __init__(self, **kwargs):
214 super().__init__(name="MainMenu", **kwargs)
215 self._blink_timer = 0.0
216
217 self.add_child(
218 Camera3D(
219 name="Camera",
220 position=Vec3(0, 35, 0),
221 fov=60,
222 )
223 ).look_at(Vec3(0, 0, 0), up=Vec3(0, 0, -1))
224
225 # Title
226 self.add_child(Text2D(text="SPACE INVADERS 3D", x=280, y=80, font_scale=4.0))
227
228 # Legend
229 self.add_child(Text2D(text="SQUID = 30 PTS", x=350, y=240, font_scale=2.0, colour=(1.0, 0.31, 0.31)))
230 self.add_child(Text2D(text="CRAB = 20 PTS", x=350, y=290, font_scale=2.0, colour=(0.31, 1.0, 0.31)))
231 self.add_child(Text2D(text="OCTO = 10 PTS", x=350, y=340, font_scale=2.0, colour=(0.31, 0.51, 1.0)))
232
233 # Blink prompt
234 self._prompt = self.add_child(
235 Text2D(
236 text="PRESS ENTER TO START",
237 x=320,
238 y=500,
239 font_scale=2.0,
240 )
241 )
242
243 def on_ready(self):
244 InputMap.add_action("move_left", [Key.A, Key.LEFT])
245 InputMap.add_action("move_right", [Key.D, Key.RIGHT])
246 InputMap.add_action("fire", [Key.SPACE])
247 InputMap.add_action("start", [Key.ENTER])
248
249 def on_process(self, dt):
250 self._blink_timer += dt
251 self._prompt.text = "PRESS ENTER TO START" if int(self._blink_timer * 2) % 2 == 0 else ""
252 if Input.is_action_just_pressed("start"):
253 self.tree.change_scene(Game())
254
255
256# ============================================================================
257# Game
258# ============================================================================
259
260
261class Game(Node3D):
262 def __init__(self, **kwargs):
263 super().__init__(name="Game", **kwargs)
264
265 # Camera
266 self.camera = self.add_child(
267 Camera3D(
268 name="Camera",
269 position=Vec3(0, 35, 0),
270 fov=60,
271 )
272 )
273 self.camera.look_at(Vec3(0, 0, 0), up=Vec3(0, 0, -1))
274
275 self.player = self.add_child(
276 Player(
277 name="Player",
278 position=Vec3(0, 0, AREA_H / 2 - 2),
279 )
280 )
281
282 self.score = 0
283 self.lives = 3
284 self._alien_dir = 1
285 self._alien_speed = 2.0
286 self._alien_shoot_timer = 0.0
287 self._alien_shoot_interval = 1.0
288
289 # HUD
290 self._score_text = self.add_child(
291 Text2D(
292 text="SCORE 00000",
293 x=10,
294 y=10,
295 font_scale=2.0,
296 )
297 )
298 self._lives_text = self.add_child(
299 Text2D(
300 text="LIVES 3",
301 x=880,
302 y=10,
303 font_scale=2.0,
304 )
305 )
306
307 def on_ready(self):
308 @self.player.fired.connect
309 def on_fire():
310 self.add_child(
311 Bullet(
312 direction=-1,
313 name="PBullet",
314 position=Vec3(self.player.position.x, 0, self.player.position.z - 1),
315 )
316 )
317
318 self._spawn_aliens()
319 self._spawn_barriers()
320
321 def _spawn_aliens(self):
322 row_types = [0, 1, 1, 2, 2]
323 start_x = -5 * 1.5
324 start_z = -AREA_H / 2 + 3
325 for row in range(5):
326 for col in range(11):
327 x = start_x + col * 1.5
328 z = start_z + row * 1.4
329 self.add_child(
330 Alien(
331 alien_type=row_types[row],
332 name=f"Alien_{row}_{col}",
333 position=Vec3(x, 0, z),
334 )
335 )
336
337 def _spawn_barriers(self):
338 spacing = AREA_W / 5
339 for i in range(4):
340 bx = -AREA_W / 2 + spacing * (i + 1)
341 self.add_child(
342 Barrier(
343 name=f"Barrier_{i}",
344 position=Vec3(bx, 0, AREA_H / 2 - 5),
345 )
346 )
347
348 def on_process(self, dt):
349 tree = self.tree
350 if not tree:
351 return
352 aliens = tree.get_group("aliens")
353 if not aliens:
354 self._alien_speed = 2.0
355 self._spawn_aliens()
356 return
357
358 # Speed scales inversely with remaining count
359 self._alien_speed = 2.0 + (55 - len(aliens)) * 0.3
360
361 # Move aliens horizontally
362 dx = self._alien_dir * self._alien_speed * dt
363 edge_hit = False
364 for alien in aliens:
365 alien.position.x += dx
366 if alien.position.x > AREA_W / 2 - 1 or alien.position.x < -AREA_W / 2 + 1:
367 edge_hit = True
368
369 if edge_hit:
370 self._alien_dir *= -1
371 for alien in aliens:
372 alien.position.z += 0.8
373 for alien in aliens:
374 if alien.position.z > AREA_H / 2 - 3:
375 tree.change_scene(GameOver(self.score))
376 return
377
378 # Alien shooting
379 self._alien_shoot_timer -= dt
380 if self._alien_shoot_timer <= 0 and aliens:
381 self._alien_shoot_timer = self._alien_shoot_interval
382 shooter = random.choice(list(aliens))
383 self.add_child(
384 Bullet(
385 direction=1,
386 name="ABullet",
387 position=Vec3(shooter.position.x, 0, shooter.position.z + 0.5),
388 )
389 )
390
391 # Update HUD
392 self._score_text.text = f"SCORE {self.score:05d}"
393 self._lives_text.text = f"LIVES {self.lives}"
394
395 def on_physics_process(self, dt):
396 tree = self.tree
397 if not tree:
398 return
399
400 barriers = self.find_all(Barrier, recursive=False)
401
402 # Player bullets vs aliens
403 for bullet in list(tree.get_group("player_bullets")):
404 hits = bullet.get_overlapping(group="aliens")
405 if hits:
406 alien = hits[0]
407 self.score += alien.points
408 alien.destroy()
409 bullet.destroy()
410 continue
411 for barrier in barriers:
412 if barrier.hit(bullet.position):
413 bullet.destroy()
414 break
415
416 # Alien bullets vs player
417 for bullet in list(tree.get_group("alien_bullets")):
418 hits = bullet.get_overlapping(group="player")
419 if hits:
420 bullet.destroy()
421 self.lives -= 1
422 if self.lives <= 0:
423 tree.change_scene(GameOver(self.score))
424 return
425 self.player.position = Vec3(0, 0, AREA_H / 2 - 2)
426 break
427 for barrier in barriers:
428 if barrier.hit(bullet.position):
429 bullet.destroy()
430 break
431
432
433# ============================================================================
434# GameOver
435# ============================================================================
436
437
438class GameOver(Node3D):
439 def __init__(self, score=0, **kwargs):
440 super().__init__(name="GameOver", **kwargs)
441 self.score = score
442 self._blink_timer = 0.0
443
444 self.add_child(
445 Camera3D(
446 name="Camera",
447 position=Vec3(0, 35, 0),
448 fov=60,
449 )
450 ).look_at(Vec3(0, 0, 0), up=Vec3(0, 0, -1))
451
452 self.add_child(
453 Text2D(
454 text="GAME OVER",
455 x=350,
456 y=200,
457 font_scale=4.0,
458 colour=(1.0, 0.2, 0.2),
459 )
460 )
461 self.add_child(
462 Text2D(
463 text=f"SCORE {score:05d}",
464 x=380,
465 y=340,
466 font_scale=2.5,
467 )
468 )
469 self._prompt = self.add_child(
470 Text2D(
471 text="PRESS ENTER TO CONTINUE",
472 x=300,
473 y=460,
474 font_scale=2.0,
475 )
476 )
477
478 def on_process(self, dt):
479 self._blink_timer += dt
480 self._prompt.text = "PRESS ENTER TO CONTINUE" if int(self._blink_timer * 2) % 2 == 0 else ""
481 if Input.is_action_just_pressed("start"):
482 self.tree.change_scene(MainMenu())
483
484
485# ============================================================================
486# Main
487# ============================================================================
488
489
490if __name__ == "__main__":
491 App(title="Space Invaders 3D (Vulkan)", width=WIDTH, height=HEIGHT, physics_fps=60).run(MainMenu())