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.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()