nodes/runner.py

Part of Pixel Runner.

  1"""Pixel Runner: root scene replacing the pygame ``runner_video.py`` while loop.
  2
  3The original game has two states (start/end screen vs gameplay). Both live in
  4this single root node; ``self._active`` flips between them and the corresponding
  5nodes show/hide. This mirrors the upstream code structure and keeps the port
  6faithful to "two screens of state" rather than splitting into multiple scenes.
  7
  8Layout is recomputed only when the window actually changes size: ``on_ready``
  9lays out once and connects the tree's ``screen_resized`` signal, so a steady
 10frame reassigns nothing.
 11"""
 12
 13import random
 14
 15from simvx.core import (
 16    AudioPlayer,
 17    Input,
 18    InputMap,
 19    Key,
 20    MouseButton,
 21    Node2D,
 22    Sprite2D,
 23    Text2D,
 24    Timer,
 25    Vec2,
 26)
 27
 28from .assets import GRAPHICS
 29from .audio import make_music
 30from .obstacle import Fly, Snail
 31from .player import Player
 32
 33WIDTH, HEIGHT = 800, 400
 34HORIZON_Y = 300  # ground meets sky here (pygame: ``screen.blit(ground_surface, (0, 300))``)
 35HINT_LINES = ("SPACE / TAP: JUMP", "ESC: QUIT")
 36HINT_LINE_H = 18.0  # font_scale 1.0 is a 16 px em, plus a little leading
 37
 38
 39class Runner(Node2D):
 40    """Game root. Owns background, player, obstacles, HUD, music, and timers."""
 41
 42    def __init__(self, **kwargs):
 43        super().__init__(name="Runner", **kwargs)
 44        self._active = False  # False = start/over screen, True = gameplay
 45        self._score = 0
 46        self._best = 0
 47        self._game_time = 0.0  # seconds survived since last start
 48        self._horizon = float(HORIZON_Y)
 49
 50        # --- Backgrounds (always drawn, both screens) -----------------------
 51        # Sky: 800x300 PNG; ground: 800x168 PNG. Pygame blits each at native
 52        # size with screen.blit, Sprite2D positions by centre, so offset by
 53        # half-size to align top-left.
 54        self.sky = self.add_child(
 55            Sprite2D(
 56                texture=str(GRAPHICS / "Sky.png"),
 57                position=Vec2(WIDTH / 2, 150),
 58                width=WIDTH,
 59                height=300,
 60                name="Sky",
 61            )
 62        )
 63        self.ground = self.add_child(
 64            Sprite2D(
 65                texture=str(GRAPHICS / "ground.png"),
 66                position=Vec2(WIDTH / 2, HORIZON_Y + 84),  # 168 / 2 = 84
 67                width=WIDTH,
 68                height=168,
 69                name="Ground",
 70            )
 71        )
 72
 73        # --- Title-screen player portrait (2x scaled stand frame) ----------
 74        self.title_portrait = self.add_child(
 75            Sprite2D(
 76                texture=str(GRAPHICS / "player" / "player_stand.png"),
 77                position=Vec2(WIDTH / 2, 200),
 78                width=136,
 79                height=168,  # native 68x84 doubled
 80                name="TitlePortrait",
 81            )
 82        )
 83
 84        # --- Player (hidden on title screen) -------------------------------
 85        self.player = self.add_child(Player())
 86        self.player.visible = False
 87
 88        # --- Obstacle spawn timer (1.5 s, repeating) -----------------------
 89        self.spawn_timer = self.add_child(Timer(1.5, one_shot=False, name="SpawnTimer"))
 90        self.spawn_timer.timeout.connect(self._spawn_obstacle)
 91
 92        # --- Music (procedural chiptune loop, low volume so SFX is audible) -
 93        self.music = self.add_child(
 94            AudioPlayer(
 95                stream=make_music(),
 96                bus="Music",
 97                volume_db=-12.0,
 98                loop=True,
 99                autoplay=True,
100                name="Music",
101            )
102        )
103
104        # --- HUD labels (Text2D goes through MSDF overlay pass, always on top) -
105        # ``align="centre"`` anchors each label on its own measured width, so the
106        # layout only has to supply the horizontal centre of the window.
107        self.title_label = self.add_child(
108            Text2D(
109                text="Pixel Runner",
110                position=(WIDTH / 2, 50),
111                font_scale=4.0,
112                align="centre",
113                colour=(0.43, 0.77, 0.66, 1.0),
114                name="TitleLabel",
115            )
116        )
117        self.message_label = self.add_child(
118            Text2D(
119                text="Press SPACE to run",
120                position=(WIDTH / 2, 320),
121                font_scale=2.0,
122                align="centre",
123                colour=(0.43, 0.77, 0.66, 1.0),
124                name="MessageLabel",
125            )
126        )
127        self.score_label = self.add_child(
128            Text2D(
129                text="Score: 0",
130                position=(WIDTH / 2, 20),
131                font_scale=2.5,
132                align="centre",
133                colour=(0.25, 0.25, 0.25, 1.0),
134                name="ScoreLabel",
135            )
136        )
137        self.score_label.visible = False
138        # Controls hint, bottom-right. Text2D rather than a draw_text call in
139        # on_draw: the root draws before its children, so a hand-drawn hint would
140        # end up underneath the background sprites.
141        self.hint_labels = [
142            self.add_child(
143                Text2D(
144                    text=line,
145                    font_scale=1.0,
146                    align="right",
147                    colour=(0.25, 0.25, 0.25, 1.0),
148                    name=f"Hint{i}",
149                )
150            )
151            for i, line in enumerate(HINT_LINES)
152        ]
153
154    # ------------------------------------------------------------------
155    # Lifecycle
156    # ------------------------------------------------------------------
157
158    def on_ready(self):
159        # CRITICAL: InputMap.add_action MUST live in the root's on_ready:
160        # the web exporter skips main() and module-level statements, so any
161        # action registered there is silently lost in the browser build.
162        # Jump on space / up / W / left-click (mobile tap = mouse-down).
163        InputMap.add_action("jump", [Key.SPACE, Key.UP, Key.W, MouseButton.LEFT])
164        InputMap.add_action("start", [Key.SPACE, Key.ENTER])
165        InputMap.add_action("quit", [Key.ESCAPE])
166        # Mouse-button start (matches pygame ``MOUSEBUTTONDOWN`` jump)
167        InputMap.add_action("click", [MouseButton.LEFT])
168
169        # Lay out once, then only when the window changes size.
170        self._fit_to_window(self.tree.screen_size)
171        self.tree.screen_resized.connect(self._fit_to_window)
172        self._show_title()
173
174    def on_exit_tree(self):
175        self.tree.screen_resized.disconnect(self._fit_to_window)
176
177    def on_update(self, dt: float):
178        # Quit cleanly via App.quit(): never sys.exit (leaks miniaudio).
179        if Input.is_action_just_pressed("quit"):
180            self.app.quit()
181            return
182
183        if self._active:
184            self._game_time += dt
185            new_score = int(self._game_time)
186            if new_score != self._score:
187                self._score = new_score
188                self._refresh_hud()
189            self._check_collisions()
190        else:
191            # Wait for start input (key or tap).
192            if Input.is_action_just_pressed("start") or Input.is_action_just_pressed("click"):
193                self._begin_run()
194
195    def _fit_to_window(self, size) -> None:
196        """Fit sprites and labels to the window. Driven by ``screen_resized``."""
197        sw, sh = float(size[0]), float(size[1])
198        # A minimised window reports no area. There is no layout to compute for it,
199        # and every band below would come out empty, so wait for a real size.
200        if sw < 1.0 or sh < 1.0:
201            return
202        self._horizon = sh * 0.75  # ground meets sky 3/4 down (matches 300/400 default)
203        horizon = self._horizon
204        # Sky fills top portion; ground fills bottom portion. Both bands keep at
205        # least one pixel so a very short window still leaves them drawable.
206        self.sky.position = Vec2(sw / 2, horizon / 2)
207        self.sky.width = int(sw)
208        self.sky.height = max(1, int(horizon))
209        self.ground.position = Vec2(sw / 2, horizon + (sh - horizon) / 2)
210        self.ground.width = int(sw)
211        self.ground.height = max(1, int(sh - horizon))
212        # Title portrait centred above the horizon.
213        self.title_portrait.position = Vec2(sw / 2, horizon - 100)
214        # Centre-aligned labels only need the window's horizontal centre.
215        self.title_label.position = (sw / 2, sh * 0.12)
216        self.message_label.position = (sw / 2, horizon + 20)
217        self.score_label.position = (sw / 2, sh * 0.05)
218        # Controls hint stacked in the bottom-right corner.
219        base_y = sh - HINT_LINE_H * len(self.hint_labels) - 8.0
220        for i, label in enumerate(self.hint_labels):
221            label.position = (sw - 8.0, base_y + i * HINT_LINE_H)
222        # Tell the player + obstacles already on screen where the ground now sits.
223        self.player.set_horizon(horizon)
224        for o in self.tree.group("obstacles"):
225            o.set_horizon(horizon)
226
227    def _refresh_hud(self):
228        """Sync Text2D widgets to current state. Cheap; called when state flips
229        and once per second from on_update."""
230        if self._active:
231            self.title_label.visible = False
232            self.message_label.visible = False
233            self.score_label.visible = True
234            self.score_label.text = f"Score: {self._score}"
235        else:
236            self.title_label.visible = True
237            self.message_label.visible = True
238            self.score_label.visible = False
239            if self._score == 0:
240                self.message_label.text = "Press SPACE to run"
241            else:
242                self.message_label.text = f"Your score: {self._score}   Best: {self._best}"
243
244    # ------------------------------------------------------------------
245    # State transitions
246    # ------------------------------------------------------------------
247
248    def _show_title(self):
249        self._active = False
250        self.player.visible = False
251        self.title_portrait.visible = True
252        self.spawn_timer.stop()
253        self._refresh_hud()
254        # Clear any obstacles still on screen.
255        for o in list(self.tree.group("obstacles")) if self.tree else []:
256            o.destroy()
257
258    def _begin_run(self):
259        self._active = True
260        self._game_time = 0.0
261        self._score = 0
262        self.title_portrait.visible = False
263        self.player.visible = True
264        self.player.reset()
265        self.spawn_timer.start()
266        self._refresh_hud()
267
268    def _end_run(self):
269        self._best = max(self._best, self._score)
270        self._show_title()
271
272    # ------------------------------------------------------------------
273    # Spawning + collision
274    # ------------------------------------------------------------------
275
276    def _spawn_obstacle(self):
277        if not self._active:
278            return
279        # Pygame: choice(["fly", "snail", "snail", "snail"]), fly ~25 % of time.
280        kind = random.choice([Fly, Snail, Snail, Snail])
281        # Just off the right edge of the *current* window, so obstacles always
282        # walk in rather than popping into view on a wide screen.
283        x = float(self.tree.screen_size[0]) + random.uniform(100.0, 300.0)
284        self.add_child(kind(x=x, horizon=self._horizon))
285
286    def _check_collisions(self):
287        if not self.tree:
288            return
289        player_rect = self.player.rect
290        for o in self.tree.group("obstacles"):
291            if player_rect.intersects(o.rect):
292                self._end_run()
293                return