game.py¶
Part of Bloom.
1"""Bloom game node: drives turns, animation, AI and the in-game HUD.
2
3Holds the pure :class:`Board` and the :class:`HexBoardView`, polls input on the
4human's turn, runs the cascade and AI as coroutines, and draws the board plus a
5top HUD strip. Menus, the bottom control bar and the game-over card live in the
6root (``app.py``); this node emits signals the root reacts to.
7"""
8
9import logging
10import math
11import random
12
13import render
14from ai import DEPTHS, choose_move
15from board import CENTRE, Board, Owner, build_board, opponent, resolve_placement
16from hex_grid import Hex, hex_corners, hex_distance
17from render import HexBoardView, owner_colour
18
19from simvx.core import (
20 AudioClip,
21 AudioPlayer,
22 Input,
23 Node2D,
24 Signal,
25 WorldEnvironment,
26 tween,
27 wait,
28)
29from simvx.core.animation.tween import (
30 ease_in_out_sine,
31 ease_out_back,
32 ease_out_cubic,
33 ease_out_quad,
34)
35
36log = logging.getLogger(__name__)
37
38AI_MIN_THINK = 0.28
39RING_STAGGER = 0.07
40SCORE_SLIDE = 0.30
41
42TOP_H_FULL = 56.0
43TOP_H_MIN = 44.0
44BOT_RESERVE = 40.0 # space reserved for the root's bottom control bar
45
46TEXT = (0.93, 0.94, 0.97, 1.0)
47TEXT_DIM = (0.62, 0.65, 0.74, 1.0)
48HUD_BG = (0.06, 0.07, 0.10, 0.55)
49
50
51class BloomGame(Node2D):
52 """One match of Bloom."""
53
54 move_made = Signal(int) # flip count
55 turn_changed = Signal(int) # Owner value of the new active player
56 game_over = Signal(int) # winning Owner value
57
58 def __init__(self, mode: str = "ai", difficulty: str = "sharp", **kwargs):
59 super().__init__(name="BloomGame", **kwargs)
60 self.mode = mode # "ai" | "hotseat"
61 self.difficulty = difficulty
62 self.board = build_board()
63 self.view = HexBoardView()
64 self.current = Owner.WARM
65 self.busy = False
66 self.finished = False
67 self.rng = random.Random(0xB100)
68 self.undo_stack: list[tuple[Board, Owner]] = []
69 self.preview_cell: Hex | None = None
70 self.preview_flips: frozenset[Hex] = frozenset()
71 self.interactive = True # cleared by the root while a card covers the board
72 self._armed = False
73 self._input_ready = False # set True once the pointer is first seen released
74 self.env = None
75 self.petals: list[dict] = [] # transient additive pollen, hard-capped
76 self.ripples: list[dict] = [] # transient additive capture shockwaves
77 self._disp_warm = 1.0
78 self._disp_cool = 1.0
79 self._snd_place = self._snd_flip = self._snd_bad = None
80
81 # --- lifecycle ----------------------------------------------------------
82 def on_ready(self):
83 self.view.sync_static(self.board)
84 # "Petalfall" post recipe. bloom_threshold=1.0 so only the >1.0 owned
85 # cores and flip peaks glow; every matte fill/grid/HUD stays out of it.
86 # All four flags are in the web 2D post route, so this is full parity.
87 env = WorldEnvironment(name="GardenEnv")
88 env.bloom_enabled = True
89 env.bloom_threshold = 1.0
90 env.bloom_intensity = 0.5
91 env.bloom_soft_knee = 0.6
92 env.vignette_enabled = True
93 env.vignette_intensity = 0.32
94 env.vignette_smoothness = 0.6
95 env.film_grain_enabled = True
96 env.film_grain_intensity = 0.022
97 env.fxaa_enabled = True
98 self.env = env
99 self.add_child(env)
100 self._init_audio()
101 warm, cool = self.board.score()
102 self._disp_warm, self._disp_cool = float(warm), float(cool)
103 self.turn_changed(int(self.current))
104
105 def _init_audio(self):
106 # Audio is a nicety here: if the device or backend is unavailable the match
107 # still plays, silently. Report it once, then never guard a play call again.
108 try:
109 self._snd_place = self._mk_tone(294.0, 0.09, 0.22)
110 self._snd_flip = self._mk_tone(440.0, 0.07, 0.18)
111 self._snd_bad = self._mk_tone(120.0, 0.10, 0.20)
112 except Exception:
113 log.warning("Bloom: audio unavailable, playing silent", exc_info=True)
114 self._snd_place = self._snd_flip = self._snd_bad = None
115
116 def _mk_tone(self, freq, dur, vol):
117 player = AudioPlayer(stream=AudioClip.tone(freq, duration=dur, volume=vol), bus="SFX")
118 self.add_child(player)
119 return player
120
121 # --- screen / layout ----------------------------------------------------
122 def _screen_size(self) -> tuple[float, float]:
123 return render.screen_wh(self.tree)
124
125 def _play_rect(self) -> tuple[float, float, float, float, float]:
126 w, h = self._screen_size()
127 top = TOP_H_FULL if w >= 520 else TOP_H_MIN
128 area_y = top
129 area_h = h - top - BOT_RESERVE
130 return 0.0, area_y, w, area_h, top
131
132 # --- input --------------------------------------------------------------
133 def on_update(self, dt):
134 # on_draw reads non-Property state (board cells + tween-driven fx), so the
135 # retained 2D renderer must be told to re-collect this drawable each frame.
136 self.queue_redraw()
137 self._integrate_fx(dt)
138 ax, ay, aw, ah, _ = self._play_rect()
139 self.view.layout(self.board, ax, ay, aw, ah)
140 self.preview_cell = None
141 self.preview_flips = frozenset()
142 if not self.interactive or not self._human_turn():
143 self._armed = False
144 return
145 # Poll the action (not @on_input / on_unhandled_input): the Pyodide web
146 # runtime only updates Input state + routes UI, it never calls
147 # propagate_input, so polling is the one path that works on desktop AND
148 # web. Press-to-aim, release-to-place.
149 mx, my = self._mouse_xy()
150 # The play rect already excludes the HUD strip and the root's bottom
151 # control bar, so a press on a button can never aim or arm a placement.
152 on_board = ax <= mx <= ax + aw and ay <= my <= ay + ah
153 cell = self.view.pixel_to_hex(mx, my)
154 aiming = on_board and self.board.cells.get(cell) is Owner.EMPTY
155 if aiming:
156 self.preview_cell = cell
157 self.preview_flips = self._would_flip(cell)
158 # Ignore the click that spawned this scene: a new game won't accept input
159 # until it has first seen the pointer released, so the press/release that
160 # activated Play or Rematch can never drop a stray seed underneath it.
161 if not self._input_ready:
162 if not Input.is_action_pressed("place"):
163 self._input_ready = True
164 return
165 # _armed (set only on a board press) keeps a release from committing if
166 # the press wasn't ours. Occupied cells arm too, so releasing on one gets
167 # the "that square is taken" shake instead of silence.
168 if Input.is_action_just_pressed("place"):
169 self._armed = on_board and self.board.cells.get(cell) is not None
170 if Input.is_action_just_released("place"):
171 if self._armed and on_board:
172 self._commit(cell)
173 self._armed = False
174
175 def _human_turn(self) -> bool:
176 return not self.busy and not self.finished and not (self.mode == "ai" and self.current is Owner.COOL)
177
178 def _mouse_xy(self) -> tuple[float, float]:
179 mp = Input.mouse_position
180 return float(mp.x), float(mp.y)
181
182 def _would_flip(self, cell: Hex) -> frozenset[Hex]:
183 """Cells that would flip if the current player placed at ``cell`` (no mutation)."""
184 sim = self.board.copy()
185 flips = resolve_placement(sim, cell, self.current)
186 return frozenset(h for ring in flips for h in ring)
187
188 def _commit(self, h):
189 if self.busy or self.finished:
190 return
191 owner = self.board.cells.get(h)
192 if owner is Owner.EMPTY:
193 self.start_coroutine(self._turn_sequence(h, self.current))
194 elif owner is not None:
195 self._illegal(h) # occupied tap: shake + thud
196 # off-board (owner None): slide-away cancel, do nothing
197
198 # --- turn flow ----------------------------------------------------------
199 def _turn_sequence(self, where, player):
200 self.busy = True
201 yield from self._resolve_and_animate(where, player)
202 while not self.finished and self.mode == "ai" and self.current is Owner.COOL:
203 yield from wait(AI_MIN_THINK)
204 mv = choose_move(self.board, Owner.COOL, DEPTHS[self.difficulty], self.rng)
205 yield from self._resolve_and_animate(mv, Owner.COOL)
206 self.busy = False
207
208 def _resolve_and_animate(self, where, player):
209 self.undo_stack.append((self.board.copy(), player))
210 flips = resolve_placement(self.board, where, player)
211 yield from self._animate(where, player, flips)
212 warm, cool = self.board.score()
213 self.start_coroutine(tween(self, "_disp_warm", float(warm), SCORE_SLIDE, easing=ease_out_cubic))
214 self.start_coroutine(tween(self, "_disp_cool", float(cool), SCORE_SLIDE, easing=ease_out_cubic))
215 self.move_made(sum(len(r) for r in flips))
216 if self.board.is_full():
217 self.finished = True
218 winner = self.board.winner()
219 yield from self._win_sequence(winner) # garden in full bloom before the card
220 self.game_over(int(winner))
221 return
222 self.current = opponent(player)
223 self.turn_changed(int(self.current))
224
225 def _win_sequence(self, winner):
226 """A staggered 'garden in full bloom' sweep of the winner's cells, with a
227 one-shot bloom-intensity swell + faint chromatic-aberration pulse. The env
228 writes only happen here, so the common path never touches post-processing."""
229 self._play(self._snd_place)
230 if self.env is not None:
231 self.start_coroutine(self._bloom_swell())
232 self.start_coroutine(self._ca_pulse())
233 cells = sorted(
234 (h for h, o in self.board.cells.items() if o is winner),
235 key=lambda h: hex_distance(h, CENTRE),
236 )
237 for h in cells:
238 fx = self.view.fx[h]
239 fx.afterglow = 0.7
240 self.start_coroutine(tween(fx, "afterglow", 0.0, 0.5, easing=ease_in_out_sine))
241 self.start_coroutine(self._pop(h))
242 c = self.view.centre_of(h)
243 self._spawn_petals(c.x, c.y, owner_colour(winner), 2)
244 yield from wait(0.035)
245 yield from wait(0.5)
246
247 def _bloom_swell(self):
248 env = self.env
249 yield from tween(env, "bloom_intensity", 0.9, 0.5, easing=ease_out_cubic)
250 yield from tween(env, "bloom_intensity", 0.5, 0.6, easing=ease_in_out_sine)
251
252 def _ca_pulse(self):
253 env = self.env
254 env.chromatic_aberration_intensity = 0.0
255 env.chromatic_aberration_enabled = True
256 yield from tween(env, "chromatic_aberration_intensity", 0.004, 0.25, easing=ease_out_cubic)
257 yield from tween(env, "chromatic_aberration_intensity", 0.0, 0.35, easing=ease_in_out_sine)
258 env.chromatic_aberration_enabled = False
259
260 def _animate(self, where, player, flips):
261 self._play(self._snd_place)
262 fx = self.view.fx[where]
263 fx.from_col = fx.to_col = owner_colour(player)
264 self.start_coroutine(self._pop(where))
265 seed = self.view.centre_of(where)
266 self._spawn_petals(seed.x, seed.y, owner_colour(player), 8)
267 self._spawn_ripple(where, player, flips)
268 for i, ring in enumerate(flips):
269 yield from wait(RING_STAGGER)
270 self._play(self._snd_flip, pitch=min(2.0, 1.0 + 0.12 * i))
271 for c in ring:
272 self.start_coroutine(self._flip_cell(c, player))
273 pc = self.view.centre_of(c)
274 self._spawn_petals(pc.x, pc.y, owner_colour(player), 3)
275 yield from wait(0.28)
276
277 def _pop(self, where):
278 # Bloom open: snap from nothing to a small overshoot, then settle.
279 fx = self.view.fx[where]
280 fx.scale = 0.0
281 yield from tween(fx, "scale", 1.18, 0.30, easing=ease_out_back)
282 yield from tween(fx, "scale", 1.0, 0.12, easing=ease_out_quad)
283
284 def _flip_cell(self, c, player):
285 fx = self.view.fx[c]
286 fx.from_col = fx.colour
287 fx.to_col = render.highlight_colour(player)
288 fx.flash = 0.0
289 fx.afterglow = 0.55
290 self.start_coroutine(tween(fx, "scale", 1.2, 0.12, easing=ease_out_back))
291 self.start_coroutine(tween(fx, "afterglow", 0.0, 0.45, easing=ease_in_out_sine))
292 yield from tween(fx, "flash", 1.0, 0.20, easing=ease_out_cubic)
293 yield from wait(0.12) # hold the bright peak so the capture reads
294 fx.from_col = render.highlight_colour(player)
295 fx.to_col = owner_colour(player)
296 fx.flash = 0.0
297 self.start_coroutine(tween(fx, "scale", 1.0, 0.18, easing=ease_out_quad))
298 yield from tween(fx, "flash", 1.0, 0.24, easing=ease_in_out_sine)
299
300 # --- transient FX (additive pollen ripple + petals; capped, web-safe) ----
301 def _spawn_ripple(self, where, player, flips):
302 seed = self.view.centre_of(where)
303 extent = self.view.size * 1.2
304 for ring in flips:
305 for c in ring:
306 d = self.view.centre_of(c)
307 extent = max(extent, math.hypot(d.x - seed.x, d.y - seed.y) + self.view.size)
308 self.ripples.append(
309 {
310 "cx": seed.x,
311 "cy": seed.y,
312 "r0": self.view.size * 0.3,
313 "r1": extent,
314 "t": 0.0,
315 "dur": max(0.3, len(flips) * RING_STAGGER + 0.32),
316 "col": render.highlight_colour(player),
317 }
318 )
319
320 def _spawn_petals(self, cx, cy, base_col, n):
321 free = 40 - len(self.petals)
322 if free <= 0:
323 return
324 tint = render.lerp_colour(base_col, (0.98, 0.95, 0.88, 1.0), 0.3)
325 sz = self.view.size
326 for _ in range(min(n, free)):
327 ang = self.rng.uniform(0.0, math.tau)
328 spd = self.rng.uniform(45.0, 115.0)
329 self.petals.append(
330 {
331 "x": cx,
332 "y": cy,
333 "vx": math.cos(ang) * spd,
334 "vy": math.sin(ang) * spd - 55.0,
335 "life": self.rng.uniform(0.6, 1.05),
336 "max": 1.05,
337 "col": tint,
338 "sz": sz * self.rng.uniform(0.10, 0.18),
339 "spin": self.rng.uniform(0.0, math.tau),
340 "spinv": self.rng.uniform(-4.0, 4.0),
341 }
342 )
343
344 def _integrate_fx(self, dt):
345 if dt <= 0:
346 return
347 alive = []
348 for p in self.petals:
349 p["life"] -= dt
350 if p["life"] <= 0:
351 continue
352 p["x"] += p["vx"] * dt
353 p["y"] += p["vy"] * dt
354 p["vy"] += 85.0 * dt # gentle gravity after the upward burst
355 p["vx"] *= max(0.0, 1.0 - 1.2 * dt) # drag
356 p["spin"] += p["spinv"] * dt
357 alive.append(p)
358 self.petals = alive
359 for r in self.ripples:
360 r["t"] += dt
361 self.ripples = [r for r in self.ripples if r["t"] < r["dur"]]
362
363 def _draw_transients(self, renderer):
364 for r in self.ripples:
365 frac = r["t"] / r["dur"]
366 ease = 1.0 - (1.0 - frac) * (1.0 - frac) # ease-out
367 r_out = r["r0"] + (r["r1"] - r["r0"]) * ease
368 a = (1.0 - frac) * 0.5
369 col = (r["col"][0], r["col"][1], r["col"][2], a)
370 render.draw_ring(renderer, r["cx"], r["cy"], r_out, r_out - self.view.size * 0.45, col)
371 for p in self.petals:
372 a = max(0.0, min(1.0, p["life"] / p["max"])) * 0.7
373 col = (p["col"][0], p["col"][1], p["col"][2], a)
374 render.draw_petal(renderer, p["x"], p["y"], p["sz"], p["spin"], col)
375
376 def _illegal(self, h):
377 self._play(self._snd_bad)
378 self.start_coroutine(self._wobble(h))
379
380 def _wobble(self, h):
381 fx = self.view.fx[h]
382 yield from tween(fx, "scale", 0.86, 0.06, easing=ease_out_quad)
383 yield from tween(fx, "scale", 1.0, 0.10, easing=ease_out_back)
384
385 def _play(self, snd, pitch=1.0):
386 if snd is None: # audio unavailable; see _init_audio
387 return
388 snd.pitch_scale = pitch
389 snd.play()
390
391 # --- undo ---------------------------------------------------------------
392 def can_undo(self) -> bool:
393 return bool(self.undo_stack) and not self.busy and not self.finished
394
395 def undo(self):
396 if not self.can_undo():
397 return
398 snapshot = None
399 if self.mode == "ai":
400 while self.undo_stack and self.undo_stack[-1][1] is Owner.COOL:
401 snapshot = self.undo_stack.pop()
402 if self.undo_stack:
403 snapshot = self.undo_stack.pop()
404 restore_to = Owner.WARM
405 else:
406 snapshot = self.undo_stack.pop()
407 restore_to = snapshot[1]
408 if snapshot is None:
409 return
410 self.board = snapshot[0]
411 self.current = restore_to
412 self.view.sync_static(self.board)
413 warm, cool = self.board.score()
414 self._disp_warm, self._disp_cool = float(warm), float(cool)
415 self.turn_changed(int(self.current))
416
417 # --- drawing ------------------------------------------------------------
418 def on_draw(self, renderer):
419 ax, ay, aw, ah, top = self._play_rect()
420 self.view.layout(self.board, ax, ay, aw, ah)
421 self.view.draw(renderer, self.board)
422 self._draw_transients(renderer) # additive ripple + pollen, above the board
423 self._draw_preview(renderer) # on top of the board so flip outlines show
424 self._draw_hud(renderer, aw, top)
425
426 def _draw_preview(self, renderer):
427 if self.preview_cell is None:
428 return
429 col = render.owner_colour(self.current)
430 hi = render.highlight_colour(self.current)
431 # Ring every enemy cell that this placement would flip, in your colour.
432 for h in self.preview_flips:
433 corners = [(p.x, p.y) for p in hex_corners(self.view.centre_of(h), self.view.size * 0.92)]
434 renderer.draw_lines(corners, closed=True, colour=(hi[0], hi[1], hi[2], 0.95))
435 # The aimed empty cell: a translucent seed of your colour + bright outline.
436 corners = [(p.x, p.y) for p in hex_corners(self.view.centre_of(self.preview_cell), self.view.size * 0.90)]
437 renderer.draw_polygon(corners, colour=(col[0], col[1], col[2], 0.5), filled=True)
438 renderer.draw_lines(corners, closed=True, colour=(1.0, 1.0, 1.0, 0.85))
439
440 def _draw_hud(self, renderer, w, top):
441 renderer.draw_rect((0, 0), (w, top), colour=HUD_BG, filled=True, screen_space=True)
442 narrow = w < 560
443 cy = top * 0.5
444 # turn pip + label (left)
445 pip = render.owner_colour(self.current)
446 renderer.draw_circle((20, cy), 8, colour=pip, filled=True, screen_space=True)
447 label = self._turn_label(short=narrow)
448 lscale = 0.9
449 renderer.draw_text(
450 label, (34, cy), colour=TEXT, scale=lscale, alignment="left", vertical_alignment="centre", screen_space=True
451 )
452 label_end = 34 + renderer.text_width(label, lscale)
453 # score bar fills the space between the label and the right edge
454 warm = int(round(self._disp_warm))
455 cool = int(round(self._disp_cool))
456 cnt_w = 26.0
457 lx = label_end + cnt_w + 6
458 rx = w - cnt_w - 10
459 bar_w = min(rx - lx, 460.0)
460 bx = rx - bar_w
461 by = cy - 7
462 bh = 14.0
463 total = float(len(self.board.cells))
464 warm_f = self._disp_warm / total
465 cool_f = self._disp_cool / total
466 renderer.draw_rect((bx, by), (bar_w, bh), colour=(0.13, 0.14, 0.18, 1.0), filled=True, screen_space=True)
467 renderer.draw_rect((bx, by), (bar_w * warm_f, bh), colour=render.WARM, filled=True, screen_space=True)
468 renderer.draw_rect(
469 (bx + bar_w * (1 - cool_f), by), (bar_w * cool_f, bh), colour=render.COOL, filled=True, screen_space=True
470 )
471 renderer.draw_text(
472 str(warm),
473 (bx - 6, cy),
474 colour=render.WARM,
475 scale=0.9,
476 alignment="right",
477 vertical_alignment="centre",
478 screen_space=True,
479 )
480 renderer.draw_text(
481 str(cool),
482 (bx + bar_w + 6, cy),
483 colour=render.COOL,
484 scale=0.9,
485 alignment="left",
486 vertical_alignment="centre",
487 screen_space=True,
488 )
489
490 def _turn_label(self, short: bool = False) -> str:
491 if self.mode == "ai":
492 if self.current is Owner.WARM:
493 return "You" if short else "Your turn"
494 if short:
495 return "AI..." if self.busy else "AI"
496 return "Opponent thinking..." if self.busy else "Opponent"
497 if short:
498 return "P1" if self.current is Owner.WARM else "P2"
499 return "Player 1" if self.current is Owner.WARM else "Player 2"