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