Squash the Creeps¶
a 3D arcade port of Godot’s first 3D game tutorial.
▶ Run in browserUpstream: https://github.com/godotengine/godot-demo-projects/tree/master/3d/squash_the_creeps
Licence: this port's own code is offered under MIT, not the SimVX Examples Licence the rest of the gallery carries. See ATTRIBUTION.md for the upstream it re-implements, the terms of anything it bundles, and the credit each one requires.
Ports live in the repository only, not in the simvx-examples distribution, because each is a derivative work licensed individually against the game it re-implements. Read it with git clone https://git.simvx.com/simvx/simvx.
Tags: port tier-0
A from-scratch re-implementation of “Squash the Creeps”, the game built in Godot’s “Your First 3D Game” tutorial. Creeps march in from the arena edge; jump on one from above to squash it and score. Let one reach you on the ground, or wander off the arena yourself, and the run ends.
What it demonstrates:
A jump / gravity / bounce controller on
CharacterBody3D.move_and_sliderunning against a static ground bodyCollision layers plus a masked
node.physics.overlapquery for stomp and contact detectionPlayer, creeps and arena built entirely from procedural
MeshprimitivesTimer-driven spawning and signal wiring (hit,squashed_mob)A
WorldEnvironmentgradient sky with fog and ACES tonemapping, lit by a single directional sunA resize-aware retained-2D HUD (
on_drawplusqueue_redraw) with text fitted to the windowOne typed
moveaction fed by the keys, a controller stick and the drawnTouchControlleralike, declared by the nodes that read it
Run: simvx examples run ports_squash_the_creeps simvx examples run ports_squash_the_creeps –test # headless smoke run
Web export: simvx export web main.py -o squash_the_creeps.html
Controls: WASD / arrows / stick Move Space / A Jump, and bounce when you land on a creep R / Enter / Space / tap / A Start, and restart after game over Esc Quit
On a phone the drawn controller is the whole control scheme: its stick steers and its A button jumps. It leaves the screen as soon as a real controller is used.
Music “House In a Forest Loop” by HorrorPen (CC-BY 3.0). See ATTRIBUTION.md for the full upstream credits and licences.
Source files¶
File |
Summary |
Lines |
|---|---|---|
Squash the Creeps: a 3D arcade port of Godot’s first 3D game tutorial. |
334 |
|
Squash the Creeps: node modules. |
1 |
|
Arena: static ground body, decorative pillars, lighting and sky. |
180 |
|
What the player can do, declared once and carried by the node that reads it. |
32 |
|
Mob: chases the player; dies when squashed or off-screen. |
138 |
|
Player: a CharacterBody3D steered by keys, stick or the drawn pad, with jump and bounce. |
214 |
Source¶
1# /// simvx
2# tags = ["port", "tier-0"]
3# upstream = "https://github.com/godotengine/godot-demo-projects/tree/master/3d/squash_the_creeps"
4# web = { width = 800, height = 600, responsive = true }
5# ///
6"""Squash the Creeps: a 3D arcade port of Godot's first 3D game tutorial.
7
8A from-scratch re-implementation of "Squash the Creeps", the game built in
9Godot's "Your First 3D Game" tutorial. Creeps march in from the arena edge;
10jump on one from above to squash it and score. Let one reach you on the ground,
11or wander off the arena yourself, and the run ends.
12
13What it demonstrates:
14
15- A jump / gravity / bounce controller on ``CharacterBody3D.move_and_slide``
16 running against a static ground body
17- Collision layers plus a masked ``node.physics.overlap`` query for stomp and
18 contact detection
19- Player, creeps and arena built entirely from procedural ``Mesh`` primitives
20- ``Timer``-driven spawning and signal wiring (``hit``, ``squashed_mob``)
21- A ``WorldEnvironment`` gradient sky with fog and ACES tonemapping, lit by a
22 single directional sun
23- A resize-aware retained-2D HUD (``on_draw`` plus ``queue_redraw``) with text
24 fitted to the window
25- One typed ``move`` action fed by the keys, a controller stick and the drawn
26 ``TouchController`` alike, declared by the nodes that read it
27
28Run:
29 simvx examples run ports_squash_the_creeps
30 simvx examples run ports_squash_the_creeps --test # headless smoke run
31
32Web export:
33 simvx export web main.py -o squash_the_creeps.html
34
35Controls:
36 WASD / arrows / stick Move
37 Space / A Jump, and bounce when you land on a creep
38 R / Enter / Space / tap / A Start, and restart after game over
39 Esc Quit
40
41On a phone the drawn controller is the whole control scheme: its stick steers and
42its A button jumps. It leaves the screen as soon as a real controller is used.
43
44Music "House In a Forest Loop" by HorrorPen (CC-BY 3.0). See ATTRIBUTION.md for
45the full upstream credits and licences.
46"""
47
48from __future__ import annotations
49
50import os
51import sys
52from pathlib import Path
53
54_PORT_DIR = Path(__file__).resolve().parent
55
56from nodes.arena import ( # noqa: E402
57 CAMERA_FOV,
58 PLAYER_MARGIN,
59 Arena,
60 camera_offset,
61 is_off_arena,
62 random_spawn_position,
63)
64from nodes.controls import SHELL # noqa: E402
65from nodes.mob import Mob # noqa: E402
66from nodes.player import Player # noqa: E402
67
68from simvx.core import ( # noqa: E402
69 AudioClip,
70 AudioPlayer,
71 Camera3D,
72 CanvasLayer,
73 Controller,
74 Input,
75 Node,
76 Property,
77 Text2D,
78 Timer,
79)
80from simvx.core.ui import TouchController # noqa: E402
81from simvx.graphics import App # noqa: E402
82
83ASSETS = _PORT_DIR / "assets"
84
85# The window the game opens at. Layout reads the live size, never this.
86WINDOW_W = 1024
87WINDOW_H = 768
88
89STATE_MENU = "menu"
90STATE_PLAY = "play"
91STATE_OVER = "over"
92
93HINT_COLOUR = (0.70, 0.70, 0.70, 1.0)
94# The controls panel sits over grass, sky and the bright arena edge strip, so
95# it gets a dimming plate behind it rather than relying on the backdrop.
96CONTROLS_COLOUR = (0.97, 0.97, 0.95, 1.0)
97CONTROLS_PLATE = (0.0, 0.0, 0.0, 0.62)
98
99
100class SquashTheCreeps(Node):
101 """Top-level game scene."""
102
103 # What the scene itself answers for. The tree registers these when it
104 # takes the root, so a web export and a test that loads this scene both
105 # get them without anything being declared from `main()`. Moving and
106 # jumping are the player's own business and live on the player.
107 input_context = SHELL
108
109 spawn_interval = Property(
110 0.5,
111 range=(0.1, 5.0),
112 hint="Seconds between mob spawns",
113 on_change="_on_spawn_interval_changed",
114 )
115
116 def __init__(self, **kwargs):
117 super().__init__(**kwargs)
118 self._score = 0
119 self._state = STATE_MENU
120
121 self._arena = self.add_child(Arena(name="Arena"))
122
123 cam_pos, cam_target = camera_offset()
124 self._camera = self.add_child(Camera3D(name="Camera", position=cam_pos, fov=CAMERA_FOV, far=120.0))
125 self._camera.look_at(cam_target)
126
127 self._player = self.add_child(Player(name="Player"))
128 self._player.hit.connect(self._on_player_hit)
129 self._player.squashed_mob.connect(self._on_mob_squashed)
130
131 # Spawn timer: paused until the player leaves the menu.
132 self._mob_timer = self.add_child(
133 Timer(duration=self.spawn_interval, one_shot=False, autostart=False, name="MobTimer")
134 )
135 self._mob_timer.timeout.connect(self._on_mob_timer)
136
137 # HUD: the score label is pinned to the top-left corner and only ever
138 # has its text rewritten; the splash and controls overlay in on_draw is
139 # the resize-aware half of the HUD.
140 # HUD text rides a CanvasLayer, pinned to the screen and out of the tonemap.
141 self._hud_layer = self.add_child(CanvasLayer(name="HUD"))
142 self._score_text = self._hud_layer.add_child(
143 Text2D(
144 name="Score",
145 text="Score: 0",
146 position=(16, 12),
147 font_scale=2.4,
148 colour=(1.0, 1.0, 1.0, 1.0),
149 )
150 )
151
152 # The pad on the glass. It publishes a real controller, so the
153 # player's stick and jump bindings and this scene's Controller.A
154 # already read it and nothing further is wired. It leaves the screen
155 # the moment a real controller is used.
156 self.add_child(TouchController(buttons=(Controller.A,), name="TouchPad"))
157
158 # Music: the loop bundled with the upstream tutorial, started with the run.
159 self._music = self._make_music()
160
161 def _make_music(self) -> AudioPlayer | None:
162 """Add the bundled music loop, or None when it was not shipped."""
163 path = ASSETS / "music_loop.ogg"
164 if not path.exists():
165 return None
166 return self.add_child(
167 AudioPlayer(
168 name="Music",
169 stream=AudioClip(str(path)),
170 bus="Music",
171 loop=True,
172 autoplay=False,
173 volume_db=-9.0,
174 )
175 )
176
177 def _on_spawn_interval_changed(self) -> None:
178 """Push a live edit of the tunable through to the running spawn timer."""
179 self._mob_timer.duration = self.spawn_interval
180
181 # -- lifecycle ----------------------------------------------------------
182
183 def on_update(self, dt: float):
184 if Input.just_pressed("quit"):
185 self.app.quit()
186 return
187
188 if self._state == STATE_MENU:
189 if Input.just_pressed("start"):
190 self._begin_run()
191 return
192
193 if self._state == STATE_OVER:
194 if Input.just_pressed("start"):
195 self._restart()
196 return
197
198 # Lose condition: the player strays past the marked boundary.
199 if is_off_arena(self._player.position, margin=PLAYER_MARGIN):
200 self._player.die()
201
202 # Despawn mobs that wander too far.
203 for mob in self.tree.group("mob"):
204 if is_off_arena(mob.position):
205 mob.destroy()
206
207 def _begin_run(self) -> None:
208 self._state = STATE_PLAY
209 self._player.start()
210 self._mob_timer.start()
211 if self._music is not None:
212 self._music.play()
213 # State drives the on_draw splash text; dirty the retained 2D layer so
214 # the title overlay is cleared once play begins.
215 self.queue_redraw()
216
217 # -- handlers -----------------------------------------------------------
218
219 def _on_mob_timer(self):
220 if self._state != STATE_PLAY:
221 return
222 spawn = random_spawn_position()
223 mob = self.add_child(Mob(name="Mob"))
224 mob.initialize(spawn, self._player.position)
225
226 def _on_mob_squashed(self):
227 self._score += 1
228 self._score_text.text = f"Score: {self._score}"
229
230 def _on_player_hit(self):
231 self._state = STATE_OVER
232 self._mob_timer.stop()
233 if self._music is not None:
234 self._music.stop()
235 # Reveal the GAME OVER splash: state change must re-run on_draw.
236 self.queue_redraw()
237
238 # -- restart ------------------------------------------------------------
239
240 def _restart(self):
241 # Deferred: a scene swapped from inside the frame walk detaches these
242 # children while the walk is still visiting them.
243 self.call_deferred(self.tree.change_scene, SquashTheCreeps())
244
245 # -- per-frame HUD draw -------------------------------------------------
246
247 def on_draw(self, renderer):
248 sw, sh = self.viewport_size
249
250 def fit(text: str, target_w: float, max_scale: int) -> int:
251 for s in range(max_scale, 0, -1):
252 if renderer.text_width(text, s) <= target_w:
253 return s
254 return 1
255
256 def line_h(s):
257 return s * 16
258
259 def draw_centered(text: str, scale: int, y: float, colour=(1.0, 1.0, 1.0, 1.0)):
260 w = renderer.text_width(text, scale)
261 renderer.text(text, (sw / 2 - w / 2, y), fill=colour, scale=scale)
262
263 # Splash text: title on menu, "GAME OVER + score" on lose.
264 if self._state == STATE_MENU:
265 title_scale = fit("SQUASH THE CREEPS", target_w=sw * 0.85, max_scale=6)
266 prompt_scale = fit("PRESS SPACE, TAP OR (A) TO PLAY", target_w=sw * 0.85, max_scale=2)
267 block_h = line_h(title_scale) + 24 + line_h(prompt_scale)
268 y = sh / 2 - block_h / 2
269 draw_centered("SQUASH THE CREEPS", title_scale, y, (1.0, 0.95, 0.4, 1.0))
270 y += line_h(title_scale) + 24
271 draw_centered("PRESS SPACE, TAP OR (A) TO PLAY", prompt_scale, y, HINT_COLOUR)
272 elif self._state == STATE_OVER:
273 game_over_scale = fit("GAME OVER", target_w=sw * 0.7, max_scale=6)
274 score_scale = max(2, game_over_scale - 2)
275 prompt_scale = fit("PRESS SPACE, TAP OR (A) TO RETRY", target_w=sw * 0.85, max_scale=2)
276 block_h = line_h(game_over_scale) + 14 + line_h(score_scale) + 14 + line_h(prompt_scale)
277 y = sh / 2 - block_h / 2
278 draw_centered("GAME OVER", game_over_scale, y, (0.95, 0.4, 0.4, 1.0))
279 y += line_h(game_over_scale) + 14
280 draw_centered(f"SCORE {self._score}", score_scale, y, (1.0, 1.0, 1.0, 1.0))
281 y += line_h(score_scale) + 14
282 draw_centered("PRESS SPACE, TAP OR (A) TO RETRY", prompt_scale, y, HINT_COLOUR)
283
284 # Bottom-right vertical, left-justified controls panel.
285 lines = [
286 "MOVE: WASD / STICK",
287 "JUMP: SPACE / (A)",
288 "QUIT: ESC",
289 ]
290 widest = max(lines, key=len)
291 scale = fit(widest, target_w=sw * 0.30, max_scale=2)
292 widest_w = renderer.text_width(widest, scale)
293 pad = 6.0
294 panel_w = widest_w + pad * 2
295 panel_h = line_h(scale) * len(lines) + pad * 2
296 panel_x = sw - panel_w - 8
297 panel_y = sh - panel_h - 8
298 renderer.rect((panel_x, panel_y), (panel_w, panel_h), fill=CONTROLS_PLATE)
299 y = panel_y + pad
300 for line in lines:
301 renderer.text(line, (panel_x + pad, y), fill=CONTROLS_COLOUR, scale=scale)
302 y += line_h(scale)
303
304
305def main() -> None:
306 headless = "--test" in sys.argv or os.environ.get("SIMVX_HEADLESS") == "1"
307 app = App(
308 title="Squash the Creeps (SimVX)",
309 width=WINDOW_W,
310 height=WINDOW_H,
311 physics_fps=60,
312 visible=not headless,
313 )
314 if headless:
315 from simvx.graphics import save_png
316
317 out_dir = _PORT_DIR / "screenshots"
318 out_dir.mkdir(parents=True, exist_ok=True)
319 frames = app.run_headless(
320 SquashTheCreeps(),
321 frames=120,
322 capture_frames=[60, 119],
323 )
324 for idx, frame_no in enumerate([60, 119]):
325 save_png(frames[idx], str(out_dir / f"frame_{frame_no:03d}.png"))
326 print(f"Headless screenshots written to {out_dir}")
327 app.quit()
328 return
329
330 app.run(SquashTheCreeps())
331
332
333if __name__ == "__main__":
334 main()