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 )