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