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