app.py¶

Part of Bloom.

  1"""Bloom root node: screen state machine, global input, gradient backdrop.
  2
  3Owns the menu / play / help / game-over flow, registers input actions (a
  4headless ``--test`` run resets input state, so they MUST be registered here in
  5``on_ready``), and hosts the persistent bottom control bar during play. The
  6full-screen cards (help, result) hang off a CanvasLayer, which pins them to the
  7window and keeps them out of the board's post-processing.
  8"""
  9
 10import math
 11import random
 12import sys
 13
 14import render
 15from board import Owner
 16from game import BloomGame
 17from menu import HowToCard, MainMenu, make_button
 18
 19from simvx.core import (
 20    Anchor,
 21    CanvasLayer,
 22    HBoxContainer,
 23    Input,
 24    Key,
 25    Label,
 26    Node2D,
 27    Signal,
 28    SizingMode,
 29    VBoxContainer,
 30    Vec2,
 31    tween,
 32)
 33from simvx.core.gradient import Gradient
 34
 35BG_TOP = (0.10, 0.085, 0.13, 1.0)  # warm-tinted dusk
 36BG_BOTTOM = (0.06, 0.075, 0.105, 1.0)  # cooler base; hue split echoes the two factions
 37TEXT = (0.93, 0.94, 0.97, 1.0)
 38TEXT_DIM = (0.62, 0.65, 0.74, 1.0)
 39
 40MENU, HELP, PLAY, GAME_OVER = "menu", "help", "play", "game_over"
 41
 42# Quit only makes sense on a desktop window; the browser (Pyodide/emscripten)
 43# build hides it.
 44_DESKTOP = sys.platform != "emscripten"
 45
 46
 47class BloomApp(Node2D):
 48    """Top-level scene root and flow controller."""
 49
 50    input_context = {
 51        "menu": [Key.ESCAPE],
 52        "undo": [Key.Z, Key.BACKSPACE],
 53        "help": [Key.H],
 54        "restart": [Key.R],
 55    }
 56
 57    def __init__(self, show_quit: bool | None = None, **kw):
 58        super().__init__(name="BloomApp", **kw)
 59        self._show_quit = _DESKTOP if show_quit is None else show_quit
 60        self.state = MENU
 61        self.config = {"mode": "ai", "difficulty": "sharp"}
 62        self.menu: MainMenu | None = None
 63        self.help: CanvasLayer | None = None  # the layer wrapping a HowToCard
 64        self.game: BloomGame | None = None
 65        self.bottom: Node2D | None = None
 66        self.overlay: CanvasLayer | None = None  # the layer wrapping a _ResultOverlay
 67        self._return_to = MENU
 68        self.motes: list[dict] = []  # slow background pollen; gated off on small viewports
 69
 70    def on_ready(self):
 71        self._show_menu()
 72
 73    # --- global input -------------------------------------------------------
 74    def on_update(self, dt):
 75        self.queue_redraw()  # gradient backdrop re-fits on viewport resize
 76        self._integrate_motes(dt)
 77        if Input.just_pressed("menu"):
 78            if self.state in (PLAY, GAME_OVER):
 79                self._show_menu()
 80            elif self.state == HELP:
 81                self._close_help()
 82        if self.state == PLAY and self.game is not None:
 83            if Input.just_pressed("undo"):
 84                self.game.undo()
 85            if Input.just_pressed("help"):
 86                self._show_help(PLAY)
 87        # R restarts mid-match and doubles as the keyboard Rematch on the result card.
 88        if self.state in (PLAY, GAME_OVER) and Input.just_pressed("restart"):
 89            self._start_game(self.config)
 90
 91    # --- screen transitions -------------------------------------------------
 92    def _clear(self, *attrs):
 93        for a in attrs:
 94            node = getattr(self, a, None)
 95            if node is not None:
 96                node.destroy()
 97                setattr(self, a, None)
 98
 99    def _show_menu(self):
100        self.state = MENU
101        self._clear("menu", "help", "game", "bottom", "overlay")
102        self.menu = MainMenu(show_quit=self._show_quit)
103        self.menu.start_requested.connect(self._start_game)
104        self.menu.help_requested.connect(lambda: self._show_help(MENU))
105        self.menu.quit_requested.connect(self._quit)
106        self.add_child(self.menu)
107
108    def _show_help(self, return_to):
109        self._return_to = return_to
110        self._clear("help")
111        if return_to == MENU:
112            self._clear("menu")
113        self.state = HELP
114        if self.game is not None:
115            self.game.interactive = False  # the card covers the board: no aiming through it
116        card = HowToCard(name="HowToCard")
117        card.back_requested.connect(self._close_help)
118        self.help = self._overlay_layer("HelpLayer", card)
119
120    def _close_help(self):
121        self._clear("help")
122        if self._return_to == PLAY and self.game is not None:
123            self.state = PLAY
124            self.game.interactive = True
125        else:
126            self._show_menu()
127
128    def _start_game(self, config):
129        self.config = {"mode": config.get("mode", "ai"), "difficulty": config.get("difficulty", "sharp")}
130        self.state = PLAY
131        self._clear("menu", "help", "game", "overlay")
132        self.game = BloomGame(mode=self.config["mode"], difficulty=self.config["difficulty"])
133        self.game.game_over.connect(self._on_game_over)
134        self.add_child(self.game)
135        self._build_bottom_bar()
136
137    def _on_game_over(self, winner_value):
138        self.state = GAME_OVER
139        self._show_result(Owner(winner_value))
140
141    # --- bottom control bar (persists during play) --------------------------
142    def _build_bottom_bar(self):
143        self._clear("bottom")
144        bar = Node2D(name="BottomBar")
145        row = HBoxContainer(anchor=Anchor.BOTTOM_WIDE, inset=(8, 0), size=(0, 40))
146        row.separation = 8
147        # FILL keeps the three buttons sharing the anchored row width, so the bar
148        # re-fits itself on every window resize with no rebuild.
149        row.sizing = SizingMode.FILL
150        menu_b = make_button("Menu (Esc)", height=34, font=14.0)
151        menu_b.pressed.connect(self._show_menu)
152        undo_b = make_button("Undo (Z)", height=34, font=14.0)
153        undo_b.pressed.connect(lambda: self.game and self.game.undo())
154        help_b = make_button("Help (H)", height=34, font=14.0)
155        help_b.pressed.connect(lambda: self._show_help(PLAY))
156        for b in (menu_b, undo_b, help_b):
157            row.add_child(b)
158        bar.add_child(row)
159        self.add_child(bar)
160        self.bottom = bar
161
162    # --- game-over overlay --------------------------------------------------
163    def _show_result(self, winner: Owner):
164        self._clear("overlay")
165        card = _ResultOverlay(winner, self.config["mode"], self.game.board.score())
166        card.rematch_requested.connect(lambda: self._start_game(self.config))
167        card.menu_requested.connect(self._show_menu)
168        self.overlay = self._overlay_layer("ResultLayer", card)
169
170    # --- helpers ------------------------------------------------------------
171    def _overlay_layer(self, name: str, card: Node2D) -> CanvasLayer:
172        """Parent a full-screen card under its own CanvasLayer, and return the layer.
173
174        A card covers the window, so screen pinning is a property of the card
175        itself, not of the individual calls it makes: the layer gives its whole
176        subtree window coordinates and the after-tonemap band, above every other
177        2D. Destroying the layer takes the card with it.
178        """
179        layer = CanvasLayer(name=name, layer=CanvasLayer.Band.OVERLAY)
180        layer.add_child(card)
181        self.add_child(layer)
182        return layer
183
184    def _screen_size(self) -> tuple[float, float]:
185        return render.screen_wh(self.tree)
186
187    def _quit(self):
188        self.app.quit()
189
190    # --- living backdrop ----------------------------------------------------
191    def _seed_motes(self, w, h):
192        rng = random.Random(0xB1009)
193        warm = (0.95, 0.62, 0.32)
194        cool = (0.40, 0.66, 1.0)
195        self.motes = []
196        for i in range(8):
197            self.motes.append(
198                {
199                    "x": rng.uniform(0, w),
200                    "y": rng.uniform(0, h),
201                    "r": rng.uniform(45, 95),
202                    "vy": rng.uniform(8, 18),
203                    "amp": rng.uniform(10, 30),
204                    "wv": rng.uniform(0.2, 0.5),
205                    "phase": rng.uniform(0, 6.283),
206                    "col": warm if i % 2 else cool,
207                }
208            )
209
210    def _integrate_motes(self, dt):
211        w, h = self._screen_size()
212        if w * h < 360000:  # phones: no motes, zero cost
213            self.motes = []
214            return
215        if not self.motes:
216            self._seed_motes(w, h)
217        for m in self.motes:
218            m["y"] -= m["vy"] * dt
219            if m["y"] < -m["r"]:
220                m["y"] = h + m["r"]
221
222    def on_draw(self, renderer):
223        w, h = self._screen_size()
224        renderer.rect((0, 0), (w, h), fill=Gradient(BG_TOP, BG_BOTTOM))
225        now = self.tree.now
226        for m in self.motes:
227            cx = m["x"] + m["amp"] * math.sin(now * m["wv"] + m["phase"])
228            col = (m["col"][0], m["col"][1], m["col"][2], 0.05)
229            render.draw_disc(renderer, cx, m["y"], m["r"], col)
230
231
232class _ResultOverlay(Node2D):
233    """Dim card shown over the final board: result, score, rematch / menu.
234
235    The root parents it under a CanvasLayer, so it draws in window coordinates
236    and composites after tonemap: the result stays crisp over a board that is
237    still blooming behind it.
238    """
239
240    rematch_requested = Signal()
241    menu_requested = Signal()
242
243    def __init__(self, winner: Owner, mode: str, score: tuple[int, int], **kw):
244        super().__init__(name="ResultOverlay", **kw)
245        self.winner = winner
246        self.mode = mode
247        self.warm, self.cool = score
248        self._dim = 0.0
249
250    def on_ready(self):
251        self.start_coroutine(tween(self, "_dim", 0.82, 0.4))
252        col = 420
253        # Centred horizontally; the 80px top inset drops the column 40px below centre.
254        root = VBoxContainer(name="ResultCol", anchor=Anchor.CENTER, inset=(0, 80, 0, 0), size=(col, 160))
255        root.separation = 12
256        root.add_child(self._spacer(col, 70))  # leave room for the drawn title
257        rematch = make_button(
258            "Rematch (R)", col, 50, font=20.0, bg=(0.20, 0.42, 0.26, 1.0), hover=(0.26, 0.52, 0.32, 1.0)
259        )
260        rematch.pressed.connect(self.rematch_requested.emit)
261        root.add_child(rematch)
262        menu = make_button("Main menu (Esc)", col, 42, font=16.0)
263        menu.pressed.connect(self.menu_requested.emit)
264        root.add_child(menu)
265        self.add_child(root)
266
267    def _spacer(self, w, h):
268        s = Label("")
269        s.size = Vec2(w, h)
270        return s
271
272    def on_update(self, dt):
273        self.queue_redraw()  # dynamic on_draw (dim tween + non-Property result text)
274
275    def on_draw(self, renderer):
276        w, h = render.screen_wh(self.tree)
277        renderer.rect((0, 0), (w, h), fill=(0.03, 0.04, 0.06, self._dim))
278        if self.mode == "hotseat":
279            text = "Player 1 wins" if self.winner is Owner.WARM else "Player 2 wins"
280        else:
281            text = "You win!" if self.winner is Owner.WARM else "Opponent wins"
282        colour = render.WARM if self.winner is Owner.WARM else render.COOL
283        renderer.text(text, (w / 2, h / 2 - 70), fill=colour, scale=2.6, align="centre")
284        renderer.text(
285            f"Amber {self.warm}   -   {self.cool} Azure", (w / 2, h / 2 - 30), fill=TEXT, scale=1.1, align="centre"
286        )