Squash the Creeps¶
Godot’s first 3D tutorial, jump on mobs, character controller.
▶ Run in browserUpstream: https://github.com/godotengine/godot-demo-projects/tree/master/3d/squash_the_creeps
Tags: port tier-0
Run desktop: uv run python ported_games/squash_the_creeps/simvx_port/main.py
Web export: uv run simvx export web ported_games/squash_the_creeps/simvx_port/main.py -o ported_games/squash_the_creeps/simvx_port/web/index.html
Controls: WASD / arrows Move on the ground plane Space Jump (and bounce when landing on a creep) R / Enter Restart after game over Esc Quit
Source¶
1"""Squash the Creeps: Godot's first 3D tutorial, jump on mobs, character controller.
2
3# /// simvx
4# tags = ["port", "tier-0"]
5# upstream = "https://github.com/godotengine/godot-demo-projects/tree/master/3d/squash_the_creeps"
6# web = { width = 800, height = 600, responsive = true }
7# ///
8
9Run desktop:
10 uv run python ported_games/squash_the_creeps/simvx_port/main.py
11
12Web export:
13 uv run simvx export web ported_games/squash_the_creeps/simvx_port/main.py \
14 -o ported_games/squash_the_creeps/simvx_port/web/index.html
15
16Controls:
17 WASD / arrows Move on the ground plane
18 Space Jump (and bounce when landing on a creep)
19 R / Enter Restart after game over
20 Esc Quit
21"""
22
23from __future__ import annotations
24
25import os
26import sys
27from pathlib import Path
28
29# Allow `python main.py` from anywhere: make the port directory importable
30# so `nodes.player` etc. resolve regardless of cwd.
31_PORT_DIR = Path(__file__).resolve().parent
32if str(_PORT_DIR) not in sys.path:
33 sys.path.insert(0, str(_PORT_DIR))
34
35from simvx.core import ( # noqa: E402
36 Camera3D,
37 Input,
38 InputMap,
39 Key,
40 MouseButton,
41 Node,
42 Property,
43 Text2D,
44 Timer,
45 Vec3,
46)
47from simvx.graphics import App # noqa: E402
48
49from nodes.arena import ( # noqa: E402
50 Arena,
51 camera_offset,
52 is_off_arena,
53 random_spawn_position,
54)
55from nodes.mob import Mob # noqa: E402
56from nodes.player import Player # noqa: E402
57
58
59VIEWPORT_W = 1024
60VIEWPORT_H = 768
61
62STATE_MENU = "menu"
63STATE_PLAY = "play"
64STATE_OVER = "over"
65
66HINT_COLOUR = (0.70, 0.70, 0.70, 1.0)
67
68
69class SquashTheCreeps(Node):
70 """Top-level game scene."""
71
72 spawn_interval = Property(0.5, range=(0.1, 5.0), hint="Seconds between mob spawns")
73
74 def __init__(self, **kwargs):
75 super().__init__(**kwargs)
76 self._score = 0
77 self._state = STATE_MENU
78 self._screen_w = VIEWPORT_W
79 self._screen_h = VIEWPORT_H
80
81 self._arena = self.add_child(Arena(name="Arena"))
82
83 cam_pos, cam_target = camera_offset()
84 self._camera = self.add_child(
85 Camera3D(name="Camera", position=cam_pos, fov=48.6, far=80.0)
86 )
87 self._camera.look_at(cam_target)
88
89 self._player = self.add_child(Player(name="Player"))
90 self._player.hit.connect(self._on_player_hit)
91 self._player.squashed_mob.connect(self._on_mob_squashed)
92
93 # Spawn timer: paused until the player leaves the menu.
94 self._mob_timer = self.add_child(
95 Timer(duration=self.spawn_interval, one_shot=False, autostart=False, name="MobTimer")
96 )
97 self._mob_timer.timeout.connect(self._on_mob_timer)
98
99 # HUD: Text2D is repositioned each frame from the live screen size.
100 self._score_text = self.add_child(
101 Text2D(
102 name="Score",
103 text="Score: 0",
104 x=24,
105 y=24,
106 font_scale=2.4,
107 colour=(1.0, 1.0, 1.0, 1.0),
108 )
109 )
110
111 # -- lifecycle ----------------------------------------------------------
112
113 def on_ready(self):
114 # InputMap calls MUST live in the root's on_ready: module-scope
115 # registration is silently dropped by the web exporter.
116 InputMap.add_action("move_left", [Key.A, Key.LEFT])
117 InputMap.add_action("move_right", [Key.D, Key.RIGHT])
118 InputMap.add_action("move_forward", [Key.W, Key.UP])
119 InputMap.add_action("move_back", [Key.S, Key.DOWN])
120 # Mobile / mobile-friendly: tap = jump (mouse press surfaces as touch in web runtime).
121 InputMap.add_action("jump", [Key.SPACE, MouseButton.LEFT])
122 InputMap.add_action("retry", [Key.R, Key.ENTER, Key.SPACE, MouseButton.LEFT])
123 InputMap.add_action("quit", [Key.ESCAPE])
124 # Hide the player until the run starts.
125 self._player.visible = False
126
127 def on_process(self, dt: float):
128 if Input.is_action_just_pressed("quit"):
129 self.app.quit()
130 return
131
132 # Track current window size for HUD positioning.
133 if self.tree:
134 self._screen_w, self._screen_h = (
135 float(self.tree.screen_size[0]),
136 float(self.tree.screen_size[1]),
137 )
138
139 if self._state == STATE_MENU:
140 if Input.is_action_just_pressed("retry"):
141 self._begin_run()
142 return
143
144 if self._state == STATE_OVER:
145 if Input.is_action_just_pressed("retry"):
146 self._restart()
147 return
148
149 # Lose condition: player falls off arena.
150 if is_off_arena(self._player.position):
151 self._player.die()
152
153 # Despawn mobs that wander too far.
154 for mob in list(self.tree.get_group("mob")):
155 if is_off_arena(mob.position):
156 mob.destroy()
157
158 def _begin_run(self) -> None:
159 self._state = STATE_PLAY
160 self._player.visible = True
161 self._mob_timer.start()
162
163 # -- handlers -----------------------------------------------------------
164
165 def _on_mob_timer(self):
166 if self._state != STATE_PLAY:
167 return
168 spawn = random_spawn_position()
169 mob = self.add_child(Mob(name="Mob"))
170 mob.initialize(spawn, self._player.position)
171
172 def _on_mob_squashed(self):
173 self._score += 1
174 self._score_text.text = f"Score: {self._score}"
175
176 def _on_player_hit(self):
177 self._state = STATE_OVER
178 self._mob_timer.stop()
179
180 # -- restart ------------------------------------------------------------
181
182 def _restart(self):
183 self.tree.change_scene(SquashTheCreeps())
184
185 # -- per-frame HUD draw -------------------------------------------------
186
187 def on_draw(self, renderer):
188 sw, sh = self._screen_w, self._screen_h
189
190 def fit(text: str, target_w: float, max_scale: int) -> int:
191 for s in range(max_scale, 0, -1):
192 if renderer.text_width(text, s) <= target_w:
193 return s
194 return 1
195
196 def line_h(s):
197 return s * 16
198
199 def draw_centered(text: str, scale: int, y: float, colour=(1.0, 1.0, 1.0, 1.0)):
200 w = renderer.text_width(text, scale)
201 renderer.draw_text(text, (sw / 2 - w / 2, y), scale=scale, colour=colour)
202
203 # Reposition the score readout to the top-left corner.
204 self._score_text.x = 16
205 self._score_text.y = 12
206
207 # Splash text: title on menu, "GAME OVER + score" on lose.
208 if self._state == STATE_MENU:
209 title_scale = fit("SQUASH THE CREEPS", target_w=sw * 0.85, max_scale=6)
210 prompt_scale = fit("PRESS [SPACE] OR TAP TO PLAY", target_w=sw * 0.85, max_scale=2)
211 block_h = line_h(title_scale) + 24 + line_h(prompt_scale)
212 y = sh / 2 - block_h / 2
213 draw_centered("SQUASH THE CREEPS", title_scale, y, (1.0, 0.95, 0.4, 1.0))
214 y += line_h(title_scale) + 24
215 draw_centered("PRESS [SPACE] OR TAP TO PLAY", prompt_scale, y, HINT_COLOUR)
216 elif self._state == STATE_OVER:
217 game_over_scale = fit("GAME OVER", target_w=sw * 0.7, max_scale=6)
218 score_scale = max(2, game_over_scale - 2)
219 prompt_scale = fit("PRESS [SPACE] OR TAP TO RETRY", target_w=sw * 0.85, max_scale=2)
220 block_h = (line_h(game_over_scale) + 14
221 + line_h(score_scale) + 14
222 + line_h(prompt_scale))
223 y = sh / 2 - block_h / 2
224 draw_centered("GAME OVER", game_over_scale, y, (0.95, 0.4, 0.4, 1.0))
225 y += line_h(game_over_scale) + 14
226 draw_centered(f"SCORE {self._score}", score_scale, y, (1.0, 1.0, 1.0, 1.0))
227 y += line_h(score_scale) + 14
228 draw_centered("PRESS [SPACE] OR TAP TO RETRY", prompt_scale, y, HINT_COLOUR)
229
230 # Bottom-right vertical, left-justified controls panel.
231 lines = [
232 "WASD/ARROWS: MOVE",
233 "SPACE / TAP: JUMP",
234 "ESC: QUIT",
235 ]
236 widest = max(lines, key=len)
237 scale = fit(widest, target_w=sw * 0.30, max_scale=2)
238 widest_w = renderer.text_width(widest, scale)
239 panel_x = sw - widest_w - 8
240 y = sh - line_h(scale) * len(lines) - 8
241 for line in lines:
242 renderer.draw_text(line, (panel_x, y), scale=scale, colour=HINT_COLOUR)
243 y += line_h(scale)
244
245
246def main() -> None:
247 headless = "--test" in sys.argv or os.environ.get("SIMVX_HEADLESS") == "1"
248 app = App(
249 title="Squash the Creeps (SimVX)",
250 width=VIEWPORT_W,
251 height=VIEWPORT_H,
252 physics_fps=60,
253 visible=not headless,
254 )
255 if headless:
256 from simvx.graphics import save_png
257
258 out_dir = _PORT_DIR / "screenshots"
259 out_dir.mkdir(parents=True, exist_ok=True)
260 frames = app.run_headless(
261 SquashTheCreeps(),
262 frames=120,
263 capture_frames=[60, 119],
264 )
265 for idx, frame_no in enumerate([60, 119]):
266 save_png(str(out_dir / f"frame_{frame_no:03d}.png"), frames[idx])
267 print(f"Headless screenshots written to {out_dir}")
268 app.quit()
269 return
270
271 app.run(SquashTheCreeps())
272
273
274if __name__ == "__main__":
275 main()