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))