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"