Gem Collector¶
your first 3D game.
▶ Run in browserTags: tutorial game 3d physics signals
Gem Collector¶
Your first 3D game. Nodes, input actions, Properties and signals work exactly as they did in 2D. What is new is space itself and the physics nodes that live in it.
Drive the cube with WASD, the arrow keys, a controller’s left stick or the on-screen pad, and collect every gem before the clock runs out. When the round ends, press R, Enter or Start, or the Play again button.
What it uses¶
Node |
Job in this game |
|---|---|
|
Everything you see: a |
|
The sun. Without a light of your own the scene gets a fixed default key light |
|
Follows the player at a fixed world offset ( |
|
The player: set |
|
The walls, with |
|
The collision geometry of every body and area |
|
Each gem: |
|
The round clock: |
|
An on-screen stick and Start button that |
Each gem turns body_entered into its own collected signal and removes itself.
The root connects every gem to one handler that keeps the score. The last gem
wins, the timer running out loses, and either one pauses the tree with
tree.paused and shows an end screen that keeps running with
update_mode=UpdateMode.ALWAYS.
The full walkthrough is the Your First 3D Game tutorial.
Run it¶
simvx examples run tutorials_gem_collector
simvx examples copy tutorials_gem_collector --to gem_collector # an editable copy
python gem_collector/main.py
Next steps¶
Monolith to Composed: how to split a game into small nodes.
The 3D feature examples: shadows, particles, physics bodies, glTF models and more.
Source¶
1# /// simvx
2# tags = ["tutorial", "game", "3d", "physics", "signals"]
3# web = { width = 800, height = 600, responsive = true }
4# ///
5"""Gem Collector: your first 3D game.
6
7Everything from the 2D tutorials still applies in 3D: nodes, input actions,
8Properties and signals. What is new is space itself, and the physics nodes that
9live in it. Drive the cube with WASD, the arrow keys, a controller's left stick
10or the on-screen pad, and collect every gem before the clock runs out. When the
11round ends, press R, Enter or Start, or the Play again button.
12
13## Camera, light and meshes
14
15A visible object is a `MeshInstance3D`: a `Mesh` for the shape (`Mesh.box`,
16`Mesh.sphere`, `Mesh.plane`) and a `Material` for the look, with a `colour` and
17a `roughness` (0 is glossy, 1 is matte). In 3D, Y is up, so the ground is the
18X/Z plane. A scene with no light of its own gets a fixed default key light, so
19something always shows; a `DirectionalLight3D` replaces it with a sun of your
20choosing. A sun's rays are parallel, so it has a `direction` and no position.
21
22`ChaseCamera` is a `Camera3D` that follows a `target` node. Its `offset` is
23where it sits relative to the target, and with `rotate_with_target=False` that
24offset stays fixed in the world, so the view does not swing as the player
25turns. `half_life` is the lag: the seconds it takes to close half the gap.
26
27## A character body moves and slides
28
29`Player` is a `CharacterBody3D`, a kinematic body that game code moves. Its
30collision geometry is a `CollisionShape3D` child holding a `BoxShape3D`, and
31its look is a separate `MeshInstance3D` child. Movement runs in
32`on_fixed_update`, on the fixed physics step: set `velocity`, then call
33`move_and_slide(dt)`, which moves the body and slides it along anything solid
34in the way. The walls are `PhysicsBody3D` nodes with `mode=BodyMode.STATIC`.
35The player never leaves the ground, so it needs no gravity.
36
37`Input.value("move")` is one `Vec2` from whichever device the player is using,
38never longer than 1. Its `y` maps onto world Z: up on the stick is away from
39the camera.
40
41## Areas report what enters them
42
43Each `Gem` is an `Area3D`, a region that detects bodies without blocking them.
44When a body enters, the area emits `body_entered` with that body. The gem checks
45it is the player, calls its own class-level `collected` signal and removes
46itself with `destroy()`. The root connects every gem's `collected` to one
47handler that keeps the score, so a gem never touches the score.
48
49## A Timer, and the end of a round
50
51A `Timer` counts `duration` seconds down and emits `timeout`; the HUD reads
52`time_left` each frame. Collecting the last gem wins, and the timeout loses.
53Either way the root sets `tree.paused`, which stops every node that resolves to
54`UpdateMode.PAUSABLE` (the default), and shows an `EndScreen` built with
55`update_mode=UpdateMode.ALWAYS`, so it alone keeps running and hearing input.
56Restarting unpauses and calls `tree.change_scene` with a fresh `GemCollector`.
57
58## An on-screen pad is a controller
59
60`TouchController(buttons=())` draws a stick and a Start button for a touch
61player and publishes them as a controller, so `move` (bound to
62`Controller.LEFT_STICK`) already reads it. It leaves the screen once a real
63controller is used.
64"""
65
66import math
67
68from simvx.core import (
69 Anchor,
70 Area3D,
71 Axis2D,
72 BodyMode,
73 BoxShape3D,
74 Button,
75 CanvasLayer,
76 CharacterBody3D,
77 ChaseCamera,
78 CollisionShape3D,
79 Colour,
80 Control,
81 Controller,
82 DirectionalLight3D,
83 Input,
84 Key,
85 Label,
86 Material,
87 Mesh,
88 MeshInstance3D,
89 Node,
90 Panel,
91 PhysicsBody3D,
92 Property,
93 Signal,
94 SphereShape3D,
95 Timer,
96 TouchController,
97 UpdateMode,
98 Vec3,
99 on_input,
100)
101from simvx.graphics import App
102
103FIELD = 11.0 # half-width of the square play area
104GEM_COUNT = 8
105ROUND_TIME = 30.0 # seconds to collect every gem
106
107
108class Player(CharacterBody3D):
109 """A cube that the `move` action slides over the ground."""
110
111 speed = Property(8.0, range=(1, 20))
112
113 def __init__(self, **kwargs):
114 super().__init__(**kwargs)
115 self.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=(0.5, 0.5, 0.5))))
116 self.add_child(MeshInstance3D(mesh=Mesh.box(1.0), material=Material(colour=(0.3, 0.7, 1.0), roughness=0.4)))
117
118 def on_fixed_update(self, dt: float):
119 move = Input.value("move")
120 self.velocity = Vec3(move.x, 0.0, move.y) * self.speed
121 self.move_and_slide(dt)
122
123
124class Gem(Area3D):
125 """A spinning gem that announces the player reaching it, then disappears."""
126
127 collected = Signal()
128
129 def __init__(self, **kwargs):
130 super().__init__(**kwargs)
131 self.add_child(CollisionShape3D(shape=SphereShape3D(radius=0.6)))
132 gold = Material(colour=(1.0, 0.84, 0.2), roughness=0.25)
133 self.add_child(MeshInstance3D(mesh=Mesh.sphere(0.5), material=gold))
134
135 def on_ready(self):
136 self.body_entered.connect(self.on_body_entered)
137
138 def on_update(self, dt: float):
139 self.rotate_y(2.5 * dt)
140
141 def on_body_entered(self, body):
142 if isinstance(body, Player):
143 self.collected.emit()
144 self.destroy()
145
146
147def wall(half_extents: tuple, position: tuple) -> PhysicsBody3D:
148 """A static box the player slides along."""
149 body = PhysicsBody3D(mode=BodyMode.STATIC, position=position)
150 body.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=half_extents)))
151 size = Vec3(*half_extents) * 2
152 body.add_child(MeshInstance3D(mesh=Mesh.box(size), material=Material(colour=(0.4, 0.45, 0.55), roughness=0.8)))
153 return body
154
155
156class GemCollector(Node):
157 """Builds the field and keeps the score and the clock."""
158
159 input_context = {"move": [Axis2D.WASD, Axis2D.ARROWS, Controller.LEFT_STICK]}
160 score = Property(0)
161
162 def on_ready(self):
163 self.add_child(DirectionalLight3D(direction=(-1, -2, -1), intensity=1.3, colour=(1.0, 0.97, 0.9)))
164 slate = Material(colour=(0.24, 0.27, 0.32), roughness=0.9)
165 self.add_child(MeshInstance3D(mesh=Mesh.plane(2 * FIELD + 2), material=slate))
166 edge = FIELD + 0.5
167 for half, position in (
168 ((edge, 1.0, 0.5), (0, 1.0, -edge)),
169 ((edge, 1.0, 0.5), (0, 1.0, edge)),
170 ((0.5, 1.0, edge), (-edge, 1.0, 0)),
171 ((0.5, 1.0, edge), (edge, 1.0, 0)),
172 ):
173 self.add_child(wall(half, position))
174
175 self.player = self.add_child(Player(position=(0, 0.5, 0)))
176 self.add_child(ChaseCamera(target=self.player, offset=Vec3(0, 12, 10), rotate_with_target=False, half_life=0.3))
177
178 for i in range(GEM_COUNT):
179 angle = i * math.tau / GEM_COUNT
180 radius = FIELD * (0.4 if i % 2 else 0.8)
181 gem = self.add_child(Gem(position=(math.cos(angle) * radius, 0.6, math.sin(angle) * radius)))
182 gem.collected.connect(self.on_gem_collected)
183
184 self.timer = self.add_child(Timer(duration=ROUND_TIME, autostart=True))
185 self.timer.timeout.connect(lambda: self.end_round("Out of time"))
186
187 self.hud = hud = self.add_child(CanvasLayer())
188 self.score_label = hud.add_child(Label(anchor=Anchor.TOP_LEFT, inset=16, font_size=28))
189 self.time_label = hud.add_child(Label(anchor=Anchor.TOP_RIGHT, inset=16, size=(160, 36), font_size=28))
190 self.add_child(TouchController(buttons=()))
191
192 # Example help overlay: not part of the technique shown.
193 hint = hud.add_child(Label("WASD, arrows, a stick or the pad", anchor=Anchor.TOP_LEFT, inset=(16, 56)))
194 hint.text_colour = (0.72, 0.75, 0.8, 1.0)
195
196 def on_update(self, dt: float):
197 self.show_hud()
198
199 def show_hud(self):
200 self.score_label.text = f"Gems: {self.score} / {GEM_COUNT}"
201 self.time_label.text = f"Time: {math.ceil(max(0.0, self.timer.time_left))}"
202
203 def on_gem_collected(self):
204 self.score += 1
205 if self.score == GEM_COUNT:
206 self.end_round("You win!")
207
208 def end_round(self, message: str):
209 self.show_hud() # the last frame's numbers, since a paused root stops updating
210 self.timer.stop()
211 self.tree.paused = True
212 screen = self.hud.add_child(EndScreen(message, update_mode=UpdateMode.ALWAYS))
213 screen.restart_requested.connect(self.restart)
214
215 def restart(self):
216 self.tree.paused = False
217 self.tree.change_scene(GemCollector())
218
219
220class EndScreen(Control):
221 """A dimmed overlay with the result and a way to play again."""
222
223 input_context = {"restart": [Key.R, Key.ENTER, Controller.START]}
224 restart_requested = Signal()
225
226 def __init__(self, message: str, **kwargs):
227 super().__init__(anchor=Anchor.FULL_RECT, **kwargs)
228 scrim = self.add_child(Panel(anchor=Anchor.FULL_RECT))
229 scrim.bg_colour = Colour.rgba(0.0, 0.0, 0.0, 0.6)
230 self.add_child(
231 Label(message, anchor=Anchor.CENTER, inset=(0, 0, 0, 90), size=(400, 48), font_size=40, alignment="centre")
232 )
233 again = self.add_child(Button("Play again", anchor=Anchor.CENTER, inset=(0, 60, 0, 0), size=(200, 44)))
234 again.pressed.connect(self.restart_requested.emit)
235
236 @on_input("restart")
237 def on_restart(self, _event):
238 self.restart_requested.emit()
239
240
241if __name__ == "__main__":
242 App(title="Gem Collector", width=800, height=600).run(GemCollector())