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