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