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