nodes/play_scene.pyΒΆ
Part of Clumsy Bird.
1"""PlayScene: the active game: bird, pipes, ground, HUD, score, restart."""
2
3import random
4
5from config import (
6 ASSETS,
7 PIPE_GAP,
8 PIPE_MAX_GAP_FRACTION,
9 PIPE_MAX_TOP_FRACTION,
10 PIPE_MIN_TOP_FRACTION,
11 PIPE_SPAWN_INTERVAL,
12 PIPE_WIDTH,
13 ground_top,
14)
15
16from simvx.core import AudioPlayer, Controller, Key, Mouse, Node, Property, Signal, Sprite2D, Vec2, on_input
17from simvx.core.coroutines import wait
18from simvx.core.ui import Anchor, Button, Control, HBoxContainer, Label, PointerFilter
19
20from .audio import make_lose, make_score, make_theme
21from .bird import Bird
22from .ground import Ground
23from .hud import ScoreHUD
24from .pipe import PipePair
25
26
27class GameOverPanel(Control):
28 """Centred 'press to restart' overlay shown after a crash.
29
30 The panel is a readout, not a control: it ignores the pointer so the tap
31 that restarts the run reaches the game underneath it.
32 """
33
34 def __init__(self, score: int, **kwargs):
35 super().__init__(**kwargs)
36 self.anchor = Anchor.FULL_RECT
37 self.pointer_filter = PointerFilter.IGNORE
38
39 # Game-over banner sprite, centred above the middle of the panel.
40 # A full-rect Control sits at the origin, so its children's local
41 # coordinates are viewport coordinates.
42 self._banner = self.add_child(
43 Sprite2D(
44 texture=str(ASSETS / "gameover.png"),
45 width=245,
46 height=132,
47 name="GameOverBanner",
48 )
49 )
50
51 # Score readout, centred and pushed below the middle by half its top inset.
52 score_lbl = self.add_child(
53 Label(f"Score: {score}", name="Score", anchor=Anchor.CENTER, size=(400, 60), inset=(0, 40, 0, 0))
54 )
55 score_lbl.font_size = 40
56 score_lbl.alignment = "centre"
57 score_lbl.text_colour = (1.0, 1.0, 1.0, 1.0)
58 score_lbl.pointer_filter = PointerFilter.IGNORE
59
60 # Prompt
61 prompt = self.add_child(
62 Label(
63 "Tap, SPACE or (A) to restart",
64 name="Prompt",
65 anchor=Anchor.CENTER,
66 size=(500, 40),
67 inset=(0, 160, 0, 0),
68 )
69 )
70 prompt.font_size = 22
71 prompt.alignment = "centre"
72 prompt.text_colour = (1.0, 1.0, 0.6, 1.0)
73 prompt.pointer_filter = PointerFilter.IGNORE
74
75 def on_update(self, dt: float):
76 width, height = self.viewport_size
77 self._banner.position = Vec2(width / 2, height / 2 - 110)
78
79
80class PlayScene(Node):
81 """Root gameplay node. Holds bird, pipes, ground, music, HUD.
82
83 ``input_context`` is the canonical registration path: it is in force
84 while this scene is mounted, and a restart builds a fresh scene that
85 brings its own, so a restart never loses its bindings.
86 """
87
88 #: One verb, three devices. ``Mouse.LEFT`` is also the touch route:
89 #: the web runtime delivers a tap as a left click, and the whole screen is
90 #: the button, which is why this game draws no on-screen pad.
91 input_context = {
92 "flap": [Key.SPACE, Mouse.LEFT, Controller.A],
93 "quit": [Key.ESCAPE],
94 }
95
96 pipe_gap = Property(PIPE_GAP, range=(120, 400), hint="Vertical hole height between pipes")
97 spawn_interval = Property(PIPE_SPAWN_INTERVAL, range=(0.5, 4.0), hint="Seconds between pipe pairs")
98
99 score_changed = Signal(int)
100 game_over = Signal()
101
102 def __init__(self, **kwargs):
103 super().__init__(name="PlayScene", **kwargs)
104 self.score = 0
105 self._game_over_shown = False
106 self._restart_ready = False
107 self._spawn_handle = None
108 self._music: AudioPlayer | None = None
109
110 # ------------------------------------------------------------------
111 # Lifecycle
112 # ------------------------------------------------------------------
113
114 def on_ready(self):
115 # Background sprite: drawn first so everything else is on top. It
116 # fills the space above the ground strip, resized every frame.
117 self.background = self.add_child(Sprite2D(texture=str(ASSETS / "bg.png"), name="Background"))
118
119 # Gameplay actors
120 self.bird = self.add_child(Bird(name="Bird"))
121 self.ground = self.add_child(Ground(name="Ground"))
122
123 # HUD on top
124 self.hud = self.add_child(ScoreHUD(name="HUD"))
125
126 # "Get Ready" splash, removed on first flap.
127 self.get_ready = self.add_child(
128 Sprite2D(
129 texture=str(ASSETS / "getready.png"),
130 width=405,
131 height=134,
132 name="GetReady",
133 )
134 )
135
136 # Background music: procedural loop baked once (see nodes/audio.py).
137 self._music = self.add_child(
138 AudioPlayer(
139 stream=make_theme(),
140 bus="Music",
141 loop=True,
142 autoplay=True,
143 volume_db=-6.0,
144 name="Music",
145 )
146 )
147
148 # Lose / pass SFX (procedural)
149 self._lose_player = self.add_child(AudioPlayer(stream=make_lose(), bus="SFX", name="LoseSFX"))
150
151 self._score_player = self.add_child(AudioPlayer(stream=make_score(), bus="SFX", name="ScoreSFX"))
152
153 # The controls, pressable, along the bottom edge: each button shows its
154 # action's key or pad button and presses it. The row itself ignores the
155 # pointer, so a tap beside the buttons still flaps.
156 controls = self.add_child(
157 HBoxContainer(name="Controls", anchor=Anchor.BOTTOM_WIDE, size=(0, 42), inset=(12, 0, 0, 10))
158 )
159 controls.alignment = "centre"
160 controls.separation = 12.0
161 controls.pointer_filter = PointerFilter.IGNORE
162 controls.add_child(Button("Flap", action="flap", name="Flap"))
163 controls.add_child(Button("Quit", action="quit", name="Quit"))
164
165 # Wire signals
166 self.bird.flapped.connect(self._on_first_flap, once=True)
167 self.bird.crashed.connect(self._on_crashed)
168
169 @on_input("flap")
170 def _on_flap_input(self, event) -> bool:
171 """Flap, or restart once the game-over prompt is up.
172
173 A discrete action reads best as a handler: it runs once per press,
174 before any physics tick, and is handed the event that caused it.
175 """
176 if self._restart_ready:
177 self._restart()
178 else:
179 self.bird.flap()
180 return True
181
182 def _restart(self):
183 self.tree.change_scene(PlayScene())
184
185 @on_input("quit")
186 def _on_quit_input(self, event) -> bool:
187 self.app.quit()
188 return True
189
190 def on_update(self, dt: float):
191 """Lay the full-screen art out against the window, every frame.
192
193 Nothing announces a resize, so the size is read rather than
194 remembered: the background covers whatever is above the ground line
195 and the splash sits in the middle of it.
196 """
197 width, height = self.viewport_size
198 sky_height = ground_top(height)
199 self.background.width = width
200 self.background.height = sky_height
201 self.background.position = Vec2(width / 2, sky_height / 2)
202 if self.get_ready and self.get_ready.parent:
203 self.get_ready.position = Vec2(width / 2, height / 2 - 60)
204
205 def on_fixed_update(self, dt: float):
206 # Bird vs pipes / ground
207 if self.bird.alive:
208 hit_pipe = self.bird.overlaps_group("pipes")
209 hit_ground = self.bird.overlaps_group("ground_collider")
210 if hit_pipe or hit_ground:
211 self.bird.die()
212 # If it hit the ground first, freeze on the ground.
213 if hit_ground:
214 self.bird.position.y = ground_top(self.viewport_size.y) - 30
215 self.bird.velocity.y = 0
216
217 # ------------------------------------------------------------------
218 # Pipe spawning coroutine
219 # ------------------------------------------------------------------
220
221 def _on_first_flap(self):
222 """First flap kicks off the active phase: bird unfreezes, pipes spawn."""
223 self.bird.start()
224 if self.get_ready and self.get_ready.parent:
225 self.get_ready.destroy()
226 self._spawn_handle = self.start_coroutine(self._spawn_pipes())
227
228 def _spawn_pipes(self):
229 """Coroutine: spawn one pipe pair every spawn_interval seconds."""
230 while self.bird.alive:
231 self._spawn_one_pipe_pair()
232 yield from wait(self.spawn_interval)
233
234 def _spawn_one_pipe_pair(self):
235 """Create one pipe pair just off the right edge with a randomised gap.
236
237 The hole is placed within the playable height, which is the window
238 minus the ground strip, so a short window narrows the range rather
239 than dropping the hole into the ground.
240 """
241 width, height = self.viewport_size
242 play_height = ground_top(height)
243 gap = min(self.pipe_gap, play_height * PIPE_MAX_GAP_FRACTION)
244 top = random.uniform(play_height * PIPE_MIN_TOP_FRACTION, play_height * PIPE_MAX_TOP_FRACTION)
245 pair = self.add_child(
246 PipePair(
247 gap_centre_y=top + gap / 2,
248 gap_size=gap,
249 x=width + PIPE_WIDTH,
250 name=f"PipePair_{int(top)}",
251 )
252 )
253 pair.passed.connect(self._on_pipe_passed)
254
255 def _on_pipe_passed(self):
256 self.score += 1
257 self.hud.set_score(self.score)
258 self.score_changed.emit(self.score)
259 self._score_player.stop()
260 self._score_player.play()
261
262 # ------------------------------------------------------------------
263 # Game over
264 # ------------------------------------------------------------------
265
266 def _on_crashed(self):
267 if self._game_over_shown:
268 return
269 self._game_over_shown = True
270 self.game_over.emit()
271 if self._music:
272 self._music.stop()
273 self._lose_player.play()
274 # Stop spawning more pipes
275 if self._spawn_handle:
276 self._spawn_handle.cancel()
277 self._spawn_handle = None
278 # Show game-over panel after a short beat so the death animation reads
279 self.start_coroutine(self._show_game_over_after(0.6))
280
281 def _show_game_over_after(self, delay: float):
282 yield from wait(delay)
283 self.add_child(GameOverPanel(self.score, name="GameOverPanel"))
284 # Only accept a restart once the prompt is on screen, so the tap that
285 # killed the bird cannot skip straight past the score.
286 self._restart_ready = True