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