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.13"
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 simvx.core import ( # noqa: E402
37 AudioStream,
38 AudioStreamPlayer,
39 Camera2D,
40 Input,
41 InputMap,
42 Key,
43 MouseButton,
44 Node,
45 Node2D,
46 Timer,
47 Vec2,
48)
49from simvx.core.ui import AnchorPreset, Control, Panel # noqa: E402
50from simvx.graphics import App # noqa: E402
51
52from nodes.hud import HUD # noqa: E402
53from nodes.mob import Mob # noqa: E402
54from nodes.player import Player # 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_process 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 (lazy; fall back gracefully if a file is missing).
127 self.music = self._make_audio("House In a Forest Loop.ogg", loop=True, volume_db=-8.0)
128 self.death_sound = self._make_audio("gameover.wav", loop=False, volume_db=-2.0)
129
130 # Mob spawn / score timers.
131 self.mob_timer = self.add_child(Timer(self.SPAWN_INTERVAL, one_shot=False, name="MobTimer"))
132 self.mob_timer.timeout.connect(self._on_mob_timer)
133
134 self.score_timer = self.add_child(Timer(self.SCORE_INTERVAL, one_shot=False, name="ScoreTimer"))
135 self.score_timer.timeout.connect(self._on_score_timer)
136
137 self.start_timer = self.add_child(Timer(self.START_DELAY, one_shot=True, name="StartTimer"))
138 self.start_timer.timeout.connect(self._on_start_timer)
139
140 # Splash screen, identical to Godot's: title visible, prompt visible,
141 # waiting for input.
142 self.player.kill()
143 self.hud.message_text = "Dodge the Creeps"
144 self.hud.message_visible = True
145 self.hud.show_prompt = True
146 self._can_restart = True
147
148 # ------------------------------------------------------------------
149 # Audio helpers
150 # ------------------------------------------------------------------
151
152 def _make_audio(self, name: str, *, loop: bool, volume_db: float) -> AudioStreamPlayer | None:
153 path = ASSETS / name
154 if not path.exists():
155 return None
156 try:
157 stream = AudioStream(str(path))
158 except Exception:
159 return None
160 return self.add_child(AudioStreamPlayer(
161 stream=stream,
162 loop=loop,
163 autoplay=False,
164 volume_db=volume_db,
165 name=path.stem.replace(" ", "_"),
166 ))
167
168 # ------------------------------------------------------------------
169 # Game flow
170 # ------------------------------------------------------------------
171
172 def _live_size(self):
173 """Current window dimensions in pixels."""
174 if self.tree:
175 return float(self.tree.screen_size[0]), float(self.tree.screen_size[1])
176 return float(WIDTH), float(HEIGHT)
177
178 def _new_game(self):
179 if self._game_active:
180 return
181 # Clear any leftover mobs from a previous run.
182 for mob in list(self.tree.get_group("mobs")):
183 mob.destroy()
184 self.score = 0
185 self.hud.update_score(self.score)
186 self.hud.hide_start_prompt()
187 self.hud.show_message("Get Ready")
188 # Start position derived from current window size, not the baked WIDTH/HEIGHT.
189 sw, sh = self._live_size()
190 self.player.start(Vec2(sw / 2, sh - 270))
191 self._game_active = True
192 self._can_restart = False
193 self.start_timer.start()
194 if self.music is not None:
195 self.music.play()
196
197 def _on_start_timer(self):
198 self.mob_timer.start()
199 self.score_timer.start()
200
201 def _on_score_timer(self):
202 self.score += 1
203 self.hud.update_score(self.score)
204
205 def _on_mob_timer(self):
206 # Pick a random edge: 0=top, 1=right, 2=bottom, 3=left, then a random
207 # offset along that edge. Direction is the inward normal plus a small
208 # random spread. Bounds come from the live window size.
209 sw, sh = self._live_size()
210 edge = random.randrange(4)
211 if edge == 0:
212 pos = Vec2(random.uniform(0, sw), -40)
213 direction = math.pi / 2 # downward
214 elif edge == 1:
215 pos = Vec2(sw + 40, random.uniform(0, sh))
216 direction = math.pi # leftward
217 elif edge == 2:
218 pos = Vec2(random.uniform(0, sw), sh + 40)
219 direction = -math.pi / 2 # upward
220 else:
221 pos = Vec2(-40, random.uniform(0, sh))
222 direction = 0.0 # rightward
223 direction += random.uniform(-math.pi / 4, math.pi / 4)
224 speed = random.uniform(*self.MOB_SPEED_RANGE)
225 mob = Mob(screen_size=Vec2(sw, sh), name=f"Mob{random.randrange(1 << 20):x}")
226 self.add_child(mob)
227 mob.configure(pos, direction, speed)
228
229 def _on_player_hit(self):
230 # Triggered by Main when overlap is detected; the player has already
231 # been hidden via kill().
232 if not self._game_active:
233 return
234 self._game_active = False
235 self.mob_timer.stop()
236 self.score_timer.stop()
237 self.hud.show_game_over()
238 if self.music is not None:
239 self.music.stop()
240 if self.death_sound is not None:
241 self.death_sound.play()
242 # Re-enable restart input after the game-over splash (~3 seconds, same
243 # cadence as Godot's HUD timer + 1-second hold).
244 restart_delay = self.add_child(Timer(
245 self.hud.MESSAGE_FADE_SEC + self.hud.GAME_OVER_HOLD_SEC + 0.1,
246 one_shot=True, autostart=True, name="RestartDelay"))
247 restart_delay.timeout.connect(lambda: setattr(self, "_can_restart", True))
248 restart_delay.timeout.connect(restart_delay.destroy)
249
250 # ------------------------------------------------------------------
251 # Per-frame logic
252 # ------------------------------------------------------------------
253
254 def on_physics_process(self, dt: float):
255 # Player-mob collision via the engine's group-overlap query.
256 if self._game_active:
257 for _mob in self.player.get_overlapping(group="mobs"):
258 self.player.kill()
259 self.player.hit()
260 break
261
262 def on_process(self, dt: float):
263 # Keep the camera centred on the live window so resize works.
264 if self.tree:
265 sw, sh = self._live_size()
266 self.camera.position = Vec2(sw / 2, sh / 2)
267
268 if (self._can_restart and not self._game_active
269 and (Input.is_action_just_pressed("start_game")
270 or Input.is_action_just_pressed("restart_click"))):
271 self._new_game()
272
273 HINT_COLOUR = (0.70, 0.70, 0.70, 1.0)
274 WHITE = (1.0, 1.0, 1.0, 1.0)
275
276 def on_draw(self, renderer):
277 if self.tree is None:
278 return
279 sw, sh = float(self.tree.screen_size[0]), float(self.tree.screen_size[1])
280
281 def line_h(s):
282 return s * 16
283
284 def fit(text: str, target_w: float, max_scale: int) -> int:
285 for s in range(max_scale, 0, -1):
286 if renderer.text_width(text, s) <= target_w:
287 return s
288 return 1
289
290 def draw_centered(text: str, scale: int, y: float, colour=self.WHITE):
291 w = renderer.text_width(text, scale)
292 renderer.draw_text(text, (sw / 2 - w / 2, y), scale=scale, colour=colour)
293
294 # Splash text: title + (optional) prompt, vertically stacked, centred.
295 if self.hud.message_visible and self.hud.message_text:
296 title_scale = fit(self.hud.message_text, target_w=sw * 0.85, max_scale=6)
297 prompt_scale = fit("Press [Space] / Click", target_w=sw * 0.9, max_scale=2)
298 block_h = line_h(title_scale)
299 if self.hud.show_prompt:
300 block_h += 24 + line_h(prompt_scale)
301 y = sh / 2 - block_h / 2
302 draw_centered(self.hud.message_text, title_scale, y, colour=self.WHITE)
303 y += line_h(title_scale) + 24
304 if self.hud.show_prompt:
305 draw_centered("Press [Space] / Click", prompt_scale, y, colour=self.HINT_COLOUR)
306
307 # Controls panel: bottom-right, vertical, left-justified.
308 lines = ["WASD/ARROWS: MOVE", "SPACE: START", "ESC: QUIT"]
309 widest = max(lines, key=len)
310 scale = fit(widest, target_w=sw * 0.30, max_scale=2)
311 widest_w = renderer.text_width(widest, scale)
312 panel_x = sw - widest_w - 8
313 y = sh - line_h(scale) * len(lines) - 8
314 for line in lines:
315 renderer.draw_text(line, (panel_x, y), scale=scale, colour=self.HINT_COLOUR)
316 y += line_h(scale)
317
318
319# ---------------------------------------------------------------------------
320# Entry point
321# ---------------------------------------------------------------------------
322
323
324def main():
325 test_mode = "--test" in sys.argv
326 app = App(width=WIDTH, height=HEIGHT, title="Dodge the Creeps", visible=not test_mode)
327 if test_mode:
328 # Render N frames headlessly, then exit cleanly.
329 app.run_headless(Main(), frames=120)
330 # No app.quit() needed; run_headless tears down the app on return.
331 else:
332 app.run(Main())
333
334
335if __name__ == "__main__":
336 main()