menu.py¶
Part of Bloom.
1"""Bloom menus: main menu (mode + difficulty) and the how-to-play card.
2
3Both are anchored, resize-aware UI built from SimVX widgets. The how-to card
4runs a looping live demo of the one rule by reusing the real board, resolver
5and :class:`HexBoardView`, so the tutorial can never drift from the game. The
6root parents the card under a CanvasLayer, which pins it to the window.
7"""
8
9import math
10
11import render
12from ai import DIFFICULTY_BLURB
13from board import CENTRE, Board, Owner, resolve_placement
14from hex_grid import Hex, hex_corners, hex_in_range
15from render import HexBoardView
16
17from simvx.core import (
18 Anchor,
19 Button,
20 HBoxContainer,
21 Label,
22 Node2D,
23 Signal,
24 SizingMode,
25 VBoxContainer,
26 Vec2,
27 tween,
28 wait,
29)
30from simvx.core.animation.tween import ease_out_back
31
32TITLE = (1.0, 0.72, 0.42, 1.0)
33TEXT = (0.93, 0.94, 0.97, 1.0)
34TEXT_DIM = (0.62, 0.65, 0.74, 1.0)
35BTN_BG = (0.16, 0.18, 0.24, 1.0)
36BTN_HOVER = (0.22, 0.26, 0.34, 1.0)
37BTN_DOWN = (0.12, 0.14, 0.19, 1.0)
38SEL_BG = (0.30, 0.34, 0.46, 1.0)
39PLAY_BG = (0.62, 0.34, 0.16, 1.0)
40PLAY_HOVER = (0.74, 0.42, 0.20, 1.0)
41
42
43def make_button(
44 text, width=0.0, height=0.0, *, font=18.0, bg=BTN_BG, hover=BTN_HOVER, down=BTN_DOWN, fg=TEXT
45) -> Button:
46 """Themed :class:`Button` factory shared by every Bloom screen.
47
48 ``width`` / ``height`` may be left at 0 when a container sizes the button.
49 """
50 b = Button(text)
51 b.size = Vec2(width, height)
52 b.font_size = font
53 b.bg_colour = bg
54 b.hover_colour = hover
55 b.pressed_colour = down
56 b.text_colour = fg
57 b.border_colour = (0.30, 0.33, 0.42, 1.0)
58 return b
59
60
61class _Segmented:
62 """A row of mutually-exclusive option buttons."""
63
64 def __init__(self, options: list[tuple[str, str]], default: str, on_change):
65 self.options = options
66 self.value = default
67 self._on_change = on_change
68 self.buttons: dict[str, Button] = {}
69 self.row = HBoxContainer()
70 self.row.separation = 6
71 self.row.sizing = SizingMode.FILL # the row shares its width evenly, at any size
72
73 def build(self, total_w: float, h: float) -> HBoxContainer:
74 for key, label in self.options:
75 b = make_button(label, height=h, font=15.0)
76 b.pressed.connect(lambda k=key: self.select(k))
77 self.buttons[key] = b
78 self.row.add_child(b)
79 self.row.size = Vec2(total_w, h)
80 self._restyle()
81 return self.row
82
83 def select(self, key: str):
84 if key == self.value:
85 return
86 self.value = key
87 self._restyle()
88 self._on_change(key)
89
90 def set_enabled(self, enabled: bool):
91 for b in self.buttons.values():
92 b.bg_colour = BTN_BG if enabled else (0.12, 0.12, 0.15, 1.0)
93 b.text_colour = TEXT if enabled else TEXT_DIM
94 if enabled:
95 self._restyle()
96
97 def _restyle(self):
98 for key, b in self.buttons.items():
99 sel = key == self.value
100 b.bg_colour = SEL_BG if sel else BTN_BG
101 b.text_colour = (1.0, 1.0, 1.0, 1.0) if sel else TEXT
102
103
104class MainMenu(Node2D):
105 """Title, mode/difficulty selectors and Play. Emits configuration on start."""
106
107 start_requested = Signal(object) # dict: {"mode", "difficulty"}
108 help_requested = Signal()
109 quit_requested = Signal()
110
111 def __init__(self, show_quit: bool = True, **kw):
112 super().__init__(name="MainMenu", **kw)
113 self._show_quit = show_quit
114 self.mode = "ai"
115 self.difficulty = "sharp"
116 self._mode_seg: _Segmented | None = None
117 self._diff_seg: _Segmented | None = None
118 self._diff_label: Label | None = None
119 self._root: VBoxContainer | None = None
120 self._enter = 0.0 # title entrance 0 -> 1 (eased), persists across resizes
121
122 def on_ready(self):
123 self._rebuild()
124 self.tree.screen_resized.connect(self._on_resize)
125 self.start_coroutine(tween(self, "_enter", 1.0, 0.55, easing=ease_out_back))
126
127 def on_update(self, dt):
128 self.queue_redraw() # drawn title + living backdrop animate every frame
129
130 def _on_resize(self, *_):
131 if self._root is not None:
132 self._root.destroy()
133 self._root = None
134 self._rebuild()
135
136 def _screen_w(self) -> float:
137 return render.screen_wh(self.tree)[0]
138
139 def _rebuild(self):
140 w = self._screen_w()
141 col = max(280.0, min(w * 0.7, 440.0))
142 root = VBoxContainer(name="MenuCol", anchor=Anchor.CENTER, size=(col, 500))
143 root.separation = 12
144
145 # The "BLOOM" title is drawn in on_draw (animated glow + entrance); reserve
146 # its slot here so the column layout is unchanged.
147 root.add_child(self._spacer(col, 90))
148
149 tag = Label("Grow your colour. Starve theirs.")
150 tag.font_size = 15.0
151 tag.text_colour = TEXT_DIM
152 tag.alignment = "centre"
153 tag.size = Vec2(col, 22)
154 root.add_child(tag)
155
156 root.add_child(self._spacer(col, 10))
157
158 self._mode_seg = _Segmented([("ai", "vs Computer"), ("hotseat", "2 Players")], self.mode, self._set_mode)
159 root.add_child(self._mode_seg.build(col, 46))
160
161 self._diff_seg = _Segmented(
162 [("calm", "Calm"), ("sharp", "Sharp"), ("ruthless", "Ruthless")], self.difficulty, self._set_difficulty
163 )
164 root.add_child(self._diff_seg.build(col, 46))
165
166 self._diff_label = Label(DIFFICULTY_BLURB[self.difficulty])
167 self._diff_label.font_size = 13.0
168 self._diff_label.text_colour = TEXT_DIM
169 self._diff_label.alignment = "centre"
170 self._diff_label.size = Vec2(col, 20)
171 root.add_child(self._diff_label)
172
173 root.add_child(self._spacer(col, 8))
174
175 play = make_button(
176 "PLAY", col, 58, font=24.0, bg=PLAY_BG, hover=PLAY_HOVER, down=(0.50, 0.27, 0.12, 1.0), fg=(1, 1, 1, 1)
177 )
178 play.pressed.connect(self._on_play)
179 root.add_child(play)
180
181 how = make_button("How to play", col, 40, font=15.0)
182 how.pressed.connect(self.help_requested.emit)
183 root.add_child(how)
184
185 if self._show_quit:
186 quit_btn = make_button("Quit", col, 36, font=14.0, fg=TEXT_DIM)
187 quit_btn.pressed.connect(self.quit_requested.emit)
188 root.add_child(quit_btn)
189
190 self._apply_mode_state()
191 self.add_child(root)
192 self._root = root
193
194 def _spacer(self, w, h) -> Label:
195 s = Label("")
196 s.size = Vec2(w, h)
197 return s
198
199 def _set_mode(self, mode):
200 self.mode = mode
201 self._apply_mode_state()
202
203 def _set_difficulty(self, diff):
204 self.difficulty = diff
205 if self._diff_label:
206 self._diff_label.text = DIFFICULTY_BLURB[diff]
207
208 def _apply_mode_state(self):
209 if self._diff_seg:
210 self._diff_seg.set_enabled(self.mode == "ai")
211
212 def _on_play(self):
213 self.start_requested.emit({"mode": self.mode, "difficulty": self.difficulty})
214
215 def on_draw(self, renderer):
216 w, h = render.screen_wh(self.tree)
217 now = self.tree.now
218 # living backdrop: a few big, faint, slowly breathing blooms behind the menu
219 blooms = (
220 (w * 0.16, h * 0.32, render.WARM, 0.0),
221 (w * 0.85, h * 0.62, render.COOL, 2.1),
222 (w * 0.52, h * 0.9, render.WARM, 4.0),
223 )
224 plen = min(w, h) * 0.13
225 for bx, by, col, ph in blooms:
226 breath = 1.0 + 0.10 * math.sin(now * 0.5 + ph)
227 render.draw_flower(renderer, bx, by, plen * breath, (col[0], col[1], col[2], 0.04))
228 # the hero title: entrance scale/fade + gentle breathing + a soft warm glow
229 e = max(0.0, min(1.0, self._enter))
230 cx, cy = w / 2.0, h / 2.0 - 205.0
231 px = max(54.0, min(w * 0.10, 96.0))
232 breath = 1.0 + 0.02 * math.sin(now * 1.7)
233 sc = (px / 16.0) * (0.9 + 0.1 * self._enter) * breath
234 # soft 3-ring additive aura (falloff, not a hard orb)
235 glow = (1.0, 0.66, 0.34)
236 gb = 1.0 + 0.05 * math.sin(now * 1.7)
237 for gr, ga in ((0.55, 0.14), (0.95, 0.08), (1.4, 0.04)):
238 render.draw_disc(renderer, cx, cy, px * gr * gb, (glow[0], glow[1], glow[2], ga * e))
239 renderer.text(
240 "BLOOM",
241 fill=(TITLE[0], TITLE[1], TITLE[2], e),
242 scale=sc,
243 align="centre",
244 valign="centre",
245 rect=(0, cy - px, w, 2 * px),
246 )
247
248
249def demo_rect(tree) -> tuple[float, float, float]:
250 """The how-to card's mini-board rect as ``(x, y, side)``, from the live viewport."""
251 w, h = render.screen_wh(tree)
252 side = min(w * 0.6, h * 0.32)
253 return w / 2 - side / 2, h * 0.17, side
254
255
256class RuleDemo(Node2D):
257 """The looping live demo of the one rule, inside the how-to card.
258
259 Its own node because it is the one part of the card that belongs in the
260 post-processed lane: owned cores sit above 1.0 so the garden's bloom catches
261 them here exactly as it does on the real board, and ``post_processed=True``
262 asks for that lane. Placement still comes from the CanvasLayer above the card, so
263 this draws in window coordinates like everything else on it.
264 """
265
266 def __init__(self, **kw):
267 super().__init__(name="RuleDemo", post_processed=True, **kw)
268 self.board = Board(radius=1)
269 self.view = HexBoardView()
270
271 def on_ready(self):
272 self._reset()
273 self.start_coroutine(self._loop())
274
275 def on_update(self, dt):
276 self.queue_redraw() # the board cells are plain state, not Properties
277
278 def _reset(self):
279 self.board.cells = dict.fromkeys(hex_in_range(CENTRE, 1), Owner.EMPTY)
280 self.board.cells[CENTRE] = Owner.COOL
281 self.board.cells[Hex(1, 0)] = Owner.WARM
282 self.board.last_placed = None
283 self.view.sync_static(self.board)
284
285 def _loop(self):
286 while True:
287 yield from wait(1.1)
288 seed = Hex(-1, 1)
289 if self.board.cells.get(seed) is Owner.EMPTY:
290 resolve_placement(self.board, seed, Owner.WARM)
291 self.view.sync_static(self.board)
292 yield from wait(1.6)
293 self._reset()
294
295 def on_draw(self, renderer):
296 x, y, side = demo_rect(self.tree)
297 self.view.layout(self.board, x, y, side, side)
298 self.view.draw(renderer, self.board)
299
300
301class HowToCard(Node2D):
302 """The how-to screen: dimming scrim, rule text, legend and a live demo.
303
304 The root parents this under a CanvasLayer, so everything it draws is pinned
305 to the window and composited after tonemap, clear of the board's bloom and
306 grain. The live demo is the one child that opts back into that lane.
307 """
308
309 back_requested = Signal()
310
311 def on_ready(self):
312 self.add_child(RuleDemo())
313 back = make_button("Back", font=18.0)
314 back.size = Vec2(300, 48)
315 back.inset = (0, 16)
316 back.anchor = Anchor.CENTER_BOTTOM
317 back.pressed.connect(self.back_requested.emit)
318 self.add_child(back)
319
320 def on_update(self, dt):
321 self.queue_redraw() # the card re-fits the viewport every frame
322
323 def on_draw(self, renderer):
324 w, h = render.screen_wh(self.tree)
325 renderer.rect((0, 0), (w, h), fill=(0.04, 0.05, 0.07, 0.82))
326 renderer.text("How to play", (w / 2, h * 0.10), fill=TITLE, scale=1.8, align="centre")
327 _, demo_top, side = demo_rect(self.tree)
328 # legend row, centred under the demo
329 ly = demo_top + side + 26
330 self._legend(renderer, w / 2 - 150, ly, render.WARM, "You")
331 self._legend(renderer, w / 2 - 24, ly, render.COOL, "Opponent")
332 self._legend(renderer, w / 2 + 118, ly, render.EMPTY, "Empty")
333 # rule text, width-fitted so it never overflows on phones
334 rules = [
335 ("Press an empty hex to aim; release to place a seed.", TEXT, 0.95),
336 ("Capture rule: count only the cells TOUCHING a hex. An enemy hex flips", TEXT_DIM, 0.9),
337 ("when your seeds touching it outnumber its own. Then captures ripple out.", TEXT_DIM, 0.9),
338 ("Your hexes flip the same way on their turn, so keep yours connected.", TEXT_DIM, 0.9),
339 ]
340 for i, (line, colour, scale) in enumerate(rules):
341 renderer.text(
342 line,
343 fill=colour,
344 scale=scale,
345 align="centre",
346 rect=(20, ly + 26 + i * 22, w - 40, 22),
347 fit_to_width=True,
348 )
349
350 def _legend(self, renderer, x, y, colour, text):
351 renderer.polygon([(c.x, c.y) for c in hex_corners(Vec2(x, y), 10)], fill=colour)
352 renderer.text(text, (x + 16, y), fill=TEXT, scale=0.8, align="left", valign="centre")