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