Space Invaders 3D

Classic arcade game with 3D meshes.

▶ Run in browser

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