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