Dodge the Creeps

Godot’s first 2D tutorial, wandering mobs, top-down dodge.

▶ Run in browser

Upstream: 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()