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 ASSETS, GROUND_Y, HEIGHT, PIPE_GAP, PIPE_MAX_TOP, PIPE_MIN_TOP, PIPE_SPAWN_INTERVAL, WIDTH
6
7from simvx.core import (
8 AudioPlayer,
9 Key,
10 MouseButton,
11 Node,
12 Property,
13 Signal,
14 Sprite2D,
15 Vec2,
16 on_input,
17)
18from simvx.core.coroutines import wait
19from simvx.core.ui import AnchorPreset, BottomControlsStrip, Control, Label
20
21from .audio import make_lose, make_score, make_theme
22from .bird import Bird
23from .ground import Ground
24from .hud import ScoreHUD
25from .pipe import PipePair
26
27
28class GameOverPanel(Control):
29 """Centred 'press to restart' overlay shown after a crash."""
30
31 def __init__(self, score: int, **kwargs):
32 super().__init__(**kwargs)
33 self.set_anchor_preset(AnchorPreset.FULL_RECT)
34
35 # Game-over banner sprite, centred near the top of the panel.
36 self.add_child(
37 Sprite2D(
38 texture=str(ASSETS / "gameover.png"),
39 width=245,
40 height=132,
41 position=Vec2(WIDTH / 2, HEIGHT / 2 - 110),
42 name="GameOverBanner",
43 )
44 )
45
46 # Score readout. A centred control takes its box from the margin pair
47 # of each axis, so both margins are set (half the width either side).
48 score_lbl = self.add_child(Label(f"Score: {score}", name="Score"))
49 score_lbl.set_anchor_preset(AnchorPreset.CENTER)
50 score_lbl.margin_left, score_lbl.margin_right = -200, 200
51 score_lbl.margin_top, score_lbl.margin_bottom = -10, 50
52 score_lbl.font_size = 40
53 score_lbl.alignment = "center"
54 score_lbl.text_colour = (1.0, 1.0, 1.0, 1.0)
55
56 # Prompt
57 prompt = self.add_child(Label("Press SPACE / Click to restart", name="Prompt"))
58 prompt.set_anchor_preset(AnchorPreset.CENTER)
59 prompt.margin_left, prompt.margin_right = -250, 250
60 prompt.margin_top, prompt.margin_bottom = 60, 100
61 prompt.font_size = 22
62 prompt.alignment = "center"
63 prompt.text_colour = (1.0, 1.0, 0.6, 1.0)
64
65
66class PlayScene(Node):
67 """Root gameplay node. Holds bird, pipes, ground, music, HUD.
68
69 ``input_actions`` is the canonical registration path: the scene tree
70 consumes it when the root mounts and re-applies it on every
71 ``change_scene``, so a restart never loses its bindings.
72 """
73
74 input_actions = {
75 "flap": [Key.SPACE, MouseButton.LEFT],
76 "quit": [Key.ESCAPE],
77 }
78
79 pipe_gap = Property(PIPE_GAP, range=(120, 400), hint="Vertical hole height between pipes")
80 spawn_interval = Property(PIPE_SPAWN_INTERVAL, range=(0.5, 4.0), hint="Seconds between pipe pairs")
81
82 score_changed = Signal(int)
83 game_over = Signal()
84
85 def __init__(self, **kwargs):
86 super().__init__(name="PlayScene", **kwargs)
87 self.score = 0
88 self._game_over_shown = False
89 self._restart_ready = False
90 self._restart_queued = False
91 self._spawn_handle = None
92 self._music: AudioPlayer | None = None
93
94 # ------------------------------------------------------------------
95 # Lifecycle
96 # ------------------------------------------------------------------
97
98 def on_ready(self):
99 # Background sprite: drawn first so everything else is on top.
100 self.add_child(
101 Sprite2D(
102 texture=str(ASSETS / "bg.png"),
103 width=WIDTH,
104 height=504,
105 position=Vec2(WIDTH / 2, 252),
106 name="Background",
107 )
108 )
109
110 # Gameplay actors
111 self.bird = self.add_child(Bird(name="Bird"))
112 self.ground = self.add_child(Ground(name="Ground"))
113
114 # HUD on top
115 self.hud = self.add_child(ScoreHUD(name="HUD"))
116
117 # "Get Ready" splash, faded out on first flap.
118 self.get_ready = self.add_child(
119 Sprite2D(
120 texture=str(ASSETS / "getready.png"),
121 width=405,
122 height=134,
123 position=Vec2(WIDTH / 2, HEIGHT / 2 - 60),
124 name="GetReady",
125 )
126 )
127
128 # Background music: procedural loop baked once (see nodes/audio.py).
129 self._music = self.add_child(
130 AudioPlayer(
131 stream=make_theme(),
132 bus="Music",
133 loop=True,
134 autoplay=True,
135 volume_db=-6.0,
136 name="Music",
137 )
138 )
139
140 # Lose / pass SFX (procedural)
141 self._lose_player = self.add_child(AudioPlayer(stream=make_lose(), bus="SFX", name="LoseSFX"))
142
143 self._score_player = self.add_child(AudioPlayer(stream=make_score(), bus="SFX", name="ScoreSFX"))
144
145 # Controls hint strip pinned along the bottom edge.
146 strip = self.add_child(BottomControlsStrip(hints=["SPACE / CLICK flap", "ESC quit"], name="Controls"))
147 strip.place_bottom_strip(28)
148
149 # Wire signals
150 self.bird.flapped.connect(self._on_first_flap, once=True)
151 self.bird.crashed.connect(self._on_crashed)
152
153 @on_input(action="flap")
154 def _on_flap_input(self, event) -> bool:
155 """Route the flap action off the event itself.
156
157 Polling ``is_action_just_pressed`` from ``on_fixed_update`` drops taps
158 on a display faster than the fixed tick rate: those frames run no fixed
159 tick at all, and the just-pressed edge is cleared every render frame.
160 """
161 if self._restart_ready:
162 # change_scene tears this node down, so never run it mid-dispatch.
163 self._restart_queued = True
164 else:
165 self.bird.flap()
166 return True
167
168 @on_input(action="quit")
169 def _on_quit_input(self, event) -> bool:
170 self.app.quit()
171 return True
172
173 def on_fixed_update(self, dt: float):
174 if not self.tree:
175 return
176
177 if self._restart_queued:
178 self.tree.change_scene(PlayScene())
179 return
180
181 # Bird vs pipes / ground
182 if self.bird.alive:
183 hit_pipe = self.bird.overlaps_group("pipes")
184 hit_ground = self.bird.overlaps_group("ground_collider")
185 if hit_pipe or hit_ground:
186 self.bird.die()
187 # If it hit the ground first, freeze on the ground.
188 if hit_ground:
189 self.bird.position.y = GROUND_Y - 30
190 self.bird.velocity.y = 0
191
192 # ------------------------------------------------------------------
193 # Pipe spawning coroutine
194 # ------------------------------------------------------------------
195
196 def _on_first_flap(self):
197 """First flap kicks off the active phase: bird unfreezes, pipes spawn."""
198 self.bird.start()
199 if self.get_ready and self.get_ready.parent:
200 self.get_ready.destroy()
201 self._spawn_handle = self.start_coroutine(self._spawn_pipes())
202
203 def _spawn_pipes(self):
204 """Coroutine: spawn one pipe pair every spawn_interval seconds."""
205 while self.bird.alive:
206 self._spawn_one_pipe_pair()
207 yield from wait(self.spawn_interval)
208
209 def _spawn_one_pipe_pair(self):
210 """Create one pipe pair at the right edge with a randomised gap."""
211 top = random.uniform(PIPE_MIN_TOP, PIPE_MAX_TOP)
212 gap_centre = top + self.pipe_gap / 2
213 pair = self.add_child(
214 PipePair(
215 gap_centre_y=gap_centre,
216 gap_size=self.pipe_gap,
217 x=WIDTH + 100,
218 name=f"PipePair_{int(top)}",
219 )
220 )
221 pair.passed.connect(self._on_pipe_passed)
222
223 def _on_pipe_passed(self):
224 self.score += 1
225 self.hud.set_score(self.score)
226 self.score_changed(self.score)
227 self._score_player.stop()
228 self._score_player.play()
229
230 # ------------------------------------------------------------------
231 # Game over
232 # ------------------------------------------------------------------
233
234 def _on_crashed(self):
235 if self._game_over_shown:
236 return
237 self._game_over_shown = True
238 self.game_over()
239 if self._music:
240 self._music.stop()
241 self._lose_player.play()
242 # Stop spawning more pipes
243 if self._spawn_handle:
244 self._spawn_handle.cancel()
245 self._spawn_handle = None
246 # Show game-over panel after a short beat so the death animation reads
247 self.start_coroutine(self._show_game_over_after(0.6))
248
249 def _show_game_over_after(self, delay: float):
250 yield from wait(delay)
251 self.add_child(GameOverPanel(self.score, name="GameOverPanel"))
252 # Only accept a restart once the prompt is on screen, so the tap that
253 # killed the bird cannot skip straight past the score.
254 self._restart_ready = True