Squash the Creeps

Godot’s first 3D tutorial, jump on mobs, character controller.

▶ Run in browser

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