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 )