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