Dodge the Creeps¶
Godot’s first 2D tutorial, wandering mobs, top-down dodge.
▶ Run in browserUpstream: https://github.com/godotengine/godot-demo-projects/tree/master/2d/dodge_the_creeps
Tags: port tier-0
Usage: cd ported_games/dodge_the_creeps && uv run python simvx_port/main.py cd ported_games/dodge_the_creeps && uv run python simvx_port/main.py –test
Source¶
1#!/usr/bin/env python3
2"""Dodge the Creeps: Godot's first 2D tutorial, wandering mobs, top-down dodge.
3
4# /// simvx
5# tags = ["port", "tier-0"]
6# upstream = "https://github.com/godotengine/godot-demo-projects/tree/master/2d/dodge_the_creeps"
7# web = { width = 480, height = 720, responsive = true }
8# ///
9
10Usage:
11 cd ported_games/dodge_the_creeps && uv run python simvx_port/main.py
12 cd ported_games/dodge_the_creeps && uv run python simvx_port/main.py --test
13"""
14
15# /// script
16# requires-python = ">=3.14"
17# dependencies = [
18# "simvx-core",
19# "simvx-graphics",
20# "numpy",
21# "pillow",
22# ]
23# ///
24
25from __future__ import annotations
26
27import math
28import random
29import sys
30from pathlib import Path
31
32# Allow `python simvx_port/main.py` (without an installed package) by exposing
33# the `nodes` subpackage as a top-level module group.
34sys.path.insert(0, str(Path(__file__).resolve().parent))
35
36from nodes.audio import make_gameover # noqa: E402
37from nodes.hud import HUD # noqa: E402
38from nodes.mob import Mob # noqa: E402
39from nodes.player import Player # noqa: E402
40
41from simvx.core import ( # noqa: E402
42 AudioClip,
43 AudioPlayer,
44 Camera2D,
45 Input,
46 InputMap,
47 Key,
48 MouseButton,
49 Node,
50 Timer,
51 Vec2,
52)
53from simvx.core.ui import AnchorPreset, Panel # noqa: E402
54from simvx.graphics import App # noqa: E402
55
56WIDTH, HEIGHT = 480, 720
57ASSETS = Path(__file__).resolve().parent / "assets"
58
59
60# ---------------------------------------------------------------------------
61# Background: a solid colour rectangle behind everything else, anchored to
62# the full viewport so it scales with the window. Mirrors the Godot demo's
63# ColorRect at (0.219608, 0.372549, 0.380392).
64# ---------------------------------------------------------------------------
65
66
67class Background(Panel):
68 BG = (0.219608, 0.372549, 0.380392, 1.0)
69
70 def __init__(self, **kwargs):
71 super().__init__(name="Background", **kwargs)
72 self.set_anchor_preset(AnchorPreset.FULL_RECT)
73 self.bg_colour = self.BG
74
75
76# ---------------------------------------------------------------------------
77# Main scene
78# ---------------------------------------------------------------------------
79
80
81class Main(Node):
82 """Root scene. Owns the player, mob spawner, score timer, and HUD."""
83
84 SPAWN_INTERVAL = 0.5 # seconds; Godot MobTimer.wait_time
85 SCORE_INTERVAL = 1.0 # seconds; Godot ScoreTimer default
86 START_DELAY = 2.0 # seconds; Godot StartTimer.wait_time
87 MOB_SPEED_RANGE = (150.0, 250.0)
88 START_POSITION = Vec2(WIDTH / 2, HEIGHT - 270) # ~(240, 450)
89
90 def __init__(self, **kwargs):
91 super().__init__(name="Main", **kwargs)
92 self.score = 0
93 self._game_active = False
94 self._can_restart = False
95
96 def on_ready(self):
97 # Input map: must live in on_ready so the web exporter picks it up.
98 InputMap.add_action("move_left", [Key.A, Key.LEFT])
99 InputMap.add_action("move_right", [Key.D, Key.RIGHT])
100 InputMap.add_action("move_up", [Key.W, Key.UP])
101 InputMap.add_action("move_down", [Key.S, Key.DOWN])
102 InputMap.add_action("start_game", [Key.SPACE, Key.ENTER])
103 InputMap.add_action("restart_click", [MouseButton.LEFT])
104 # Mobile / touch: left-click-and-hold steers the player toward the
105 # cursor. Touches surface as MouseButton.LEFT in the web runtime.
106 InputMap.add_action("touch_move", [MouseButton.LEFT])
107
108 # Window title can't be changed yet (App owns it), but score/state UI
109 # all live in the HUD child.
110
111 # Background (anchored, scales with window).
112 self.add_child(Background())
113
114 # Camera: keeps the world in screen pixels. Position is updated each
115 # frame in on_update so the world centres on the live window.
116 self.camera = self.add_child(Camera2D(name="Camera", position=Vec2(WIDTH / 2, HEIGHT / 2)))
117
118 # Player: registered first so HUD draws on top of it.
119 self.player = self.add_child(Player(screen_size=Vec2(WIDTH, HEIGHT), name="Player"))
120 self.player.hit.connect(self._on_player_hit)
121
122 # HUD: anchored Control widgets (Label).
123 self.hud = self.add_child(HUD())
124 self.hud.start_game.connect(self._new_game)
125
126 # Audio: bundled music streams from disk; the death cue is synthesised
127 # at load time (no third-party sound file is bundled).
128 self.music = self._make_audio("House In a Forest Loop.ogg", loop=True, volume_db=-8.0)
129 self.death_sound = self.add_child(AudioPlayer(
130 stream=make_gameover(), loop=False, autoplay=False, volume_db=-2.0, name="death_sound",
131 ))
132
133 # Mob spawn / score timers.
134 self.mob_timer = self.add_child(Timer(self.SPAWN_INTERVAL, one_shot=False, name="MobTimer"))
135 self.mob_timer.timeout.connect(self._on_mob_timer)
136
137 self.score_timer = self.add_child(Timer(self.SCORE_INTERVAL, one_shot=False, name="ScoreTimer"))
138 self.score_timer.timeout.connect(self._on_score_timer)
139
140 self.start_timer = self.add_child(Timer(self.START_DELAY, one_shot=True, name="StartTimer"))
141 self.start_timer.timeout.connect(self._on_start_timer)
142
143 # The root's on_draw renders the splash from the HUD's plain-attribute
144 # state (message_text / message_visible / show_prompt), which the HUD
145 # mutates on its OWN schedule -- a message-fade Timer and the game-over
146 # coroutine -- with no Property write to auto-dirty this node. Those
147 # cross-node pokes can't cleanly reach the root, so mark the root
148 # `dynamic`: its on_draw re-captures every frame (a cheap per-node patch
149 # of a few text ops; the rest of the scene still frame-skips) and the
150 # splash can never freeze mid-transition.
151 self.dynamic = True
152
153 # Splash screen, identical to Godot's: title visible, prompt visible,
154 # waiting for input.
155 self.player.kill()
156 self.hud.message_text = "Dodge the Creeps"
157 self.hud.message_visible = True
158 self.hud.show_prompt = True
159 self._can_restart = True
160
161 # ------------------------------------------------------------------
162 # Audio helpers
163 # ------------------------------------------------------------------
164
165 def _make_audio(self, name: str, *, loop: bool, volume_db: float) -> AudioPlayer | None:
166 path = ASSETS / name
167 if not path.exists():
168 return None
169 try:
170 stream = AudioClip(str(path))
171 except Exception:
172 return None
173 return self.add_child(AudioPlayer(
174 stream=stream,
175 loop=loop,
176 autoplay=False,
177 volume_db=volume_db,
178 name=path.stem.replace(" ", "_"),
179 ))
180
181 # ------------------------------------------------------------------
182 # Game flow
183 # ------------------------------------------------------------------
184
185 def _live_size(self):
186 """Current window dimensions in pixels."""
187 if self.tree:
188 return float(self.tree.screen_size[0]), float(self.tree.screen_size[1])
189 return float(WIDTH), float(HEIGHT)
190
191 def _new_game(self):
192 if self._game_active:
193 return
194 # Clear any leftover mobs from a previous run.
195 for mob in list(self.tree.get_group("mobs")):
196 mob.destroy()
197 self.score = 0
198 self.hud.update_score(self.score)
199 self.hud.hide_start_prompt()
200 self.hud.show_message("Get Ready")
201 # Start position derived from current window size, not the baked WIDTH/HEIGHT.
202 sw, sh = self._live_size()
203 self.player.start(Vec2(sw / 2, sh - 270))
204 self._game_active = True
205 self._can_restart = False
206 self.start_timer.start()
207 if self.music is not None:
208 self.music.play()
209
210 def _on_start_timer(self):
211 self.mob_timer.start()
212 self.score_timer.start()
213
214 def _on_score_timer(self):
215 self.score += 1
216 self.hud.update_score(self.score)
217
218 def _on_mob_timer(self):
219 # Pick a random edge: 0=top, 1=right, 2=bottom, 3=left, then a random
220 # offset along that edge. Direction is the inward normal plus a small
221 # random spread. Bounds come from the live window size.
222 sw, sh = self._live_size()
223 edge = random.randrange(4)
224 if edge == 0:
225 pos = Vec2(random.uniform(0, sw), -40)
226 direction = math.pi / 2 # downward
227 elif edge == 1:
228 pos = Vec2(sw + 40, random.uniform(0, sh))
229 direction = math.pi # leftward
230 elif edge == 2:
231 pos = Vec2(random.uniform(0, sw), sh + 40)
232 direction = -math.pi / 2 # upward
233 else:
234 pos = Vec2(-40, random.uniform(0, sh))
235 direction = 0.0 # rightward
236 direction += random.uniform(-math.pi / 4, math.pi / 4)
237 speed = random.uniform(*self.MOB_SPEED_RANGE)
238 mob = Mob(screen_size=Vec2(sw, sh), name=f"Mob{random.randrange(1 << 20):x}")
239 self.add_child(mob)
240 mob.configure(pos, direction, speed)
241
242 def _on_player_hit(self):
243 # Triggered by Main when overlap is detected; the player has already
244 # been hidden via kill().
245 if not self._game_active:
246 return
247 self._game_active = False
248 self.mob_timer.stop()
249 self.score_timer.stop()
250 self.hud.show_game_over()
251 if self.music is not None:
252 self.music.stop()
253 if self.death_sound is not None:
254 self.death_sound.play()
255 # Re-enable restart input after the game-over splash (~3 seconds, same
256 # cadence as Godot's HUD timer + 1-second hold).
257 restart_delay = self.add_child(Timer(
258 self.hud.MESSAGE_FADE_SEC + self.hud.GAME_OVER_HOLD_SEC + 0.1,
259 one_shot=True, autostart=True, name="RestartDelay"))
260 restart_delay.timeout.connect(lambda: setattr(self, "_can_restart", True))
261 restart_delay.timeout.connect(restart_delay.destroy)
262
263 # ------------------------------------------------------------------
264 # Per-frame logic
265 # ------------------------------------------------------------------
266
267 def on_fixed_update(self, dt: float):
268 # Player-mob collision via a circle-overlap test over the "mobs" group.
269 if self._game_active and self.player.overlapping_mobs():
270 self.player.kill()
271 self.player.hit()
272
273 def on_update(self, dt: float):
274 # Keep the camera centred on the live window so resize works.
275 if self.tree:
276 sw, sh = self._live_size()
277 self.camera.position = Vec2(sw / 2, sh / 2)
278
279 if (self._can_restart and not self._game_active
280 and (Input.is_action_just_pressed("start_game")
281 or Input.is_action_just_pressed("restart_click"))):
282 self._new_game()
283
284 HINT_COLOUR = (0.70, 0.70, 0.70, 1.0)
285 WHITE = (1.0, 1.0, 1.0, 1.0)
286
287 def on_draw(self, renderer):
288 if self.tree is None:
289 return
290 sw, sh = float(self.tree.screen_size[0]), float(self.tree.screen_size[1])
291
292 def line_h(s):
293 return s * 16
294
295 def fit(text: str, target_w: float, max_scale: int) -> int:
296 for s in range(max_scale, 0, -1):
297 if renderer.text_width(text, s) <= target_w:
298 return s
299 return 1
300
301 def draw_centered(text: str, scale: int, y: float, colour=self.WHITE):
302 w = renderer.text_width(text, scale)
303 renderer.draw_text(text, (sw / 2 - w / 2, y), scale=scale, colour=colour)
304
305 # Splash text: title + (optional) prompt, vertically stacked, centred.
306 if self.hud.message_visible and self.hud.message_text:
307 title_scale = fit(self.hud.message_text, target_w=sw * 0.85, max_scale=6)
308 prompt_scale = fit("Press [Space] / Click", target_w=sw * 0.9, max_scale=2)
309 block_h = line_h(title_scale)
310 if self.hud.show_prompt:
311 block_h += 24 + line_h(prompt_scale)
312 y = sh / 2 - block_h / 2
313 draw_centered(self.hud.message_text, title_scale, y, colour=self.WHITE)
314 y += line_h(title_scale) + 24
315 if self.hud.show_prompt:
316 draw_centered("Press [Space] / Click", prompt_scale, y, colour=self.HINT_COLOUR)
317
318 # Controls panel: bottom-right, vertical, left-justified.
319 lines = ["WASD/ARROWS: MOVE", "SPACE: START", "ESC: QUIT"]
320 widest = max(lines, key=len)
321 scale = fit(widest, target_w=sw * 0.30, max_scale=2)
322 widest_w = renderer.text_width(widest, scale)
323 panel_x = sw - widest_w - 8
324 y = sh - line_h(scale) * len(lines) - 8
325 for line in lines:
326 renderer.draw_text(line, (panel_x, y), scale=scale, colour=self.HINT_COLOUR)
327 y += line_h(scale)
328
329
330# ---------------------------------------------------------------------------
331# Entry point
332# ---------------------------------------------------------------------------
333
334
335def main():
336 test_mode = "--test" in sys.argv
337 app = App(width=WIDTH, height=HEIGHT, title="Dodge the Creeps", visible=not test_mode)
338 if test_mode:
339 # Render N frames headlessly, then exit cleanly.
340 app.run_headless(Main(), frames=120)
341 # No app.quit() needed; run_headless tears down the app on return.
342 else:
343 app.run(Main())
344
345
346if __name__ == "__main__":
347 main()