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 simvx.core import (  # noqa: E402
 36    Camera3D,
 37    Input,
 38    InputMap,
 39    Key,
 40    MouseButton,
 41    Node,
 42    Property,
 43    Text2D,
 44    Timer,
 45    Vec3,
 46)
 47from simvx.graphics import App  # noqa: E402
 48
 49from nodes.arena import (  # noqa: E402
 50    Arena,
 51    camera_offset,
 52    is_off_arena,
 53    random_spawn_position,
 54)
 55from nodes.mob import Mob  # noqa: E402
 56from nodes.player import Player  # noqa: E402
 57
 58
 59VIEWPORT_W = 1024
 60VIEWPORT_H = 768
 61
 62STATE_MENU = "menu"
 63STATE_PLAY = "play"
 64STATE_OVER = "over"
 65
 66HINT_COLOUR = (0.70, 0.70, 0.70, 1.0)
 67
 68
 69class SquashTheCreeps(Node):
 70    """Top-level game scene."""
 71
 72    spawn_interval = Property(0.5, range=(0.1, 5.0), hint="Seconds between mob spawns")
 73
 74    def __init__(self, **kwargs):
 75        super().__init__(**kwargs)
 76        self._score = 0
 77        self._state = STATE_MENU
 78        self._screen_w = VIEWPORT_W
 79        self._screen_h = VIEWPORT_H
 80
 81        self._arena = self.add_child(Arena(name="Arena"))
 82
 83        cam_pos, cam_target = camera_offset()
 84        self._camera = self.add_child(
 85            Camera3D(name="Camera", position=cam_pos, fov=48.6, far=80.0)
 86        )
 87        self._camera.look_at(cam_target)
 88
 89        self._player = self.add_child(Player(name="Player"))
 90        self._player.hit.connect(self._on_player_hit)
 91        self._player.squashed_mob.connect(self._on_mob_squashed)
 92
 93        # Spawn timer: paused until the player leaves the menu.
 94        self._mob_timer = self.add_child(
 95            Timer(duration=self.spawn_interval, one_shot=False, autostart=False, name="MobTimer")
 96        )
 97        self._mob_timer.timeout.connect(self._on_mob_timer)
 98
 99        # HUD: Text2D is repositioned each frame from the live screen size.
100        self._score_text = self.add_child(
101            Text2D(
102                name="Score",
103                text="Score: 0",
104                x=24,
105                y=24,
106                font_scale=2.4,
107                colour=(1.0, 1.0, 1.0, 1.0),
108            )
109        )
110
111    # -- lifecycle ----------------------------------------------------------
112
113    def on_ready(self):
114        # InputMap calls MUST live in the root's on_ready: module-scope
115        # registration is silently dropped by the web exporter.
116        InputMap.add_action("move_left", [Key.A, Key.LEFT])
117        InputMap.add_action("move_right", [Key.D, Key.RIGHT])
118        InputMap.add_action("move_forward", [Key.W, Key.UP])
119        InputMap.add_action("move_back", [Key.S, Key.DOWN])
120        # Mobile / mobile-friendly: tap = jump (mouse press surfaces as touch in web runtime).
121        InputMap.add_action("jump", [Key.SPACE, MouseButton.LEFT])
122        InputMap.add_action("retry", [Key.R, Key.ENTER, Key.SPACE, MouseButton.LEFT])
123        InputMap.add_action("quit", [Key.ESCAPE])
124        # Hide the player until the run starts.
125        self._player.visible = False
126
127    def on_process(self, dt: float):
128        if Input.is_action_just_pressed("quit"):
129            self.app.quit()
130            return
131
132        # Track current window size for HUD positioning.
133        if self.tree:
134            self._screen_w, self._screen_h = (
135                float(self.tree.screen_size[0]),
136                float(self.tree.screen_size[1]),
137            )
138
139        if self._state == STATE_MENU:
140            if Input.is_action_just_pressed("retry"):
141                self._begin_run()
142            return
143
144        if self._state == STATE_OVER:
145            if Input.is_action_just_pressed("retry"):
146                self._restart()
147            return
148
149        # Lose condition: player falls off arena.
150        if is_off_arena(self._player.position):
151            self._player.die()
152
153        # Despawn mobs that wander too far.
154        for mob in list(self.tree.get_group("mob")):
155            if is_off_arena(mob.position):
156                mob.destroy()
157
158    def _begin_run(self) -> None:
159        self._state = STATE_PLAY
160        self._player.visible = True
161        self._mob_timer.start()
162
163    # -- handlers -----------------------------------------------------------
164
165    def _on_mob_timer(self):
166        if self._state != STATE_PLAY:
167            return
168        spawn = random_spawn_position()
169        mob = self.add_child(Mob(name="Mob"))
170        mob.initialize(spawn, self._player.position)
171
172    def _on_mob_squashed(self):
173        self._score += 1
174        self._score_text.text = f"Score: {self._score}"
175
176    def _on_player_hit(self):
177        self._state = STATE_OVER
178        self._mob_timer.stop()
179
180    # -- restart ------------------------------------------------------------
181
182    def _restart(self):
183        self.tree.change_scene(SquashTheCreeps())
184
185    # -- per-frame HUD draw -------------------------------------------------
186
187    def on_draw(self, renderer):
188        sw, sh = self._screen_w, self._screen_h
189
190        def fit(text: str, target_w: float, max_scale: int) -> int:
191            for s in range(max_scale, 0, -1):
192                if renderer.text_width(text, s) <= target_w:
193                    return s
194            return 1
195
196        def line_h(s):
197            return s * 16
198
199        def draw_centered(text: str, scale: int, y: float, colour=(1.0, 1.0, 1.0, 1.0)):
200            w = renderer.text_width(text, scale)
201            renderer.draw_text(text, (sw / 2 - w / 2, y), scale=scale, colour=colour)
202
203        # Reposition the score readout to the top-left corner.
204        self._score_text.x = 16
205        self._score_text.y = 12
206
207        # Splash text: title on menu, "GAME OVER + score" on lose.
208        if self._state == STATE_MENU:
209            title_scale = fit("SQUASH THE CREEPS", target_w=sw * 0.85, max_scale=6)
210            prompt_scale = fit("PRESS [SPACE] OR TAP TO PLAY", target_w=sw * 0.85, max_scale=2)
211            block_h = line_h(title_scale) + 24 + line_h(prompt_scale)
212            y = sh / 2 - block_h / 2
213            draw_centered("SQUASH THE CREEPS", title_scale, y, (1.0, 0.95, 0.4, 1.0))
214            y += line_h(title_scale) + 24
215            draw_centered("PRESS [SPACE] OR TAP TO PLAY", prompt_scale, y, HINT_COLOUR)
216        elif self._state == STATE_OVER:
217            game_over_scale = fit("GAME OVER", target_w=sw * 0.7, max_scale=6)
218            score_scale = max(2, game_over_scale - 2)
219            prompt_scale = fit("PRESS [SPACE] OR TAP TO RETRY", target_w=sw * 0.85, max_scale=2)
220            block_h = (line_h(game_over_scale) + 14
221                       + line_h(score_scale) + 14
222                       + line_h(prompt_scale))
223            y = sh / 2 - block_h / 2
224            draw_centered("GAME OVER", game_over_scale, y, (0.95, 0.4, 0.4, 1.0))
225            y += line_h(game_over_scale) + 14
226            draw_centered(f"SCORE  {self._score}", score_scale, y, (1.0, 1.0, 1.0, 1.0))
227            y += line_h(score_scale) + 14
228            draw_centered("PRESS [SPACE] OR TAP TO RETRY", prompt_scale, y, HINT_COLOUR)
229
230        # Bottom-right vertical, left-justified controls panel.
231        lines = [
232            "WASD/ARROWS: MOVE",
233            "SPACE / TAP: JUMP",
234            "ESC: QUIT",
235        ]
236        widest = max(lines, key=len)
237        scale = fit(widest, target_w=sw * 0.30, max_scale=2)
238        widest_w = renderer.text_width(widest, scale)
239        panel_x = sw - widest_w - 8
240        y = sh - line_h(scale) * len(lines) - 8
241        for line in lines:
242            renderer.draw_text(line, (panel_x, y), scale=scale, colour=HINT_COLOUR)
243            y += line_h(scale)
244
245
246def main() -> None:
247    headless = "--test" in sys.argv or os.environ.get("SIMVX_HEADLESS") == "1"
248    app = App(
249        title="Squash the Creeps (SimVX)",
250        width=VIEWPORT_W,
251        height=VIEWPORT_H,
252        physics_fps=60,
253        visible=not headless,
254    )
255    if headless:
256        from simvx.graphics import save_png
257
258        out_dir = _PORT_DIR / "screenshots"
259        out_dir.mkdir(parents=True, exist_ok=True)
260        frames = app.run_headless(
261            SquashTheCreeps(),
262            frames=120,
263            capture_frames=[60, 119],
264        )
265        for idx, frame_no in enumerate([60, 119]):
266            save_png(str(out_dir / f"frame_{frame_no:03d}.png"), frames[idx])
267        print(f"Headless screenshots written to {out_dir}")
268        app.quit()
269        return
270
271    app.run(SquashTheCreeps())
272
273
274if __name__ == "__main__":
275    main()