render.pyΒΆ

Part of Bloom.

  1"""Bloom rendering: a plain helper class (not a Node) that lays out and draws
  2the hex board via immediate-mode Draw2D. Reads board + per-cell effect state;
  3owns no game logic and no input.
  4
  5The "Petalfall" look: each cell is a small domed petal (concentric hexes shade
  6rim -> mid -> a glowing core), grounded by a soft drop shadow. Owned cores sit
  7just above 1.0 in their dominant channel so the WorldEnvironment bloom catches
  8only them, giving the resting board a calm permanent glow. Captures briefly
  9flash the cell to its HI colour (also >1.0) and leave an additive afterglow.
 10"""
 11
 12import math
 13
 14from board import Board, Owner
 15from hex_grid import SQRT3, Hex, hex_corners, hex_to_pixel, pixel_to_hex
 16
 17from simvx.core import Vec2
 18
 19# --- palette (RGBA floats; Canadian spelling) --------------------------------
 20# Only owned CORES and the flip HI peaks cross 1.0, so only they bloom. Every
 21# matte fill, grid line, outline and HUD element stays <= 1.0 and never glows.
 22EMPTY = (0.205, 0.215, 0.255, 1.0)  # soil
 23GRID_LINE = (0.34, 0.37, 0.46, 1.0)
 24WARM = (0.85, 0.52, 0.20, 1.0)  # marigold, matte base/mid
 25WARM_CORE = (1.12, 0.80, 0.45, 1.0)  # resting glow core (>1.0 R)
 26WARM_HI = (1.35, 0.92, 0.55, 1.0)  # flip flash peak / afterglow tint
 27COOL = (0.28, 0.55, 0.85, 1.0)  # cornflower, matte base/mid
 28COOL_CORE = (0.55, 0.80, 1.12, 1.0)  # resting glow core (>1.0 B)
 29COOL_HI = (0.62, 0.92, 1.35, 1.0)
 30LAST_OUTLINE = (1.0, 1.0, 1.0, 0.85)
 31SHADOW = (0.0, 0.0, 0.0, 0.22)
 32
 33_HEX_GAP = 0.90  # fraction of cell size actually drawn (leaves grid gutters)
 34_FIT_MARGIN = 0.94
 35_MID_FRAC = 0.80  # mid dome layer, fraction of the base
 36_CORE_FRAC = 0.50  # bright core layer
 37
 38
 39def screen_wh(tree) -> tuple[float, float]:
 40    """Viewport size as (w, h). ``SceneTree.screen_size`` is already normalized."""
 41    return tree.screen_size
 42
 43
 44def owner_colour(o: Owner) -> tuple[float, float, float, float]:
 45    if o is Owner.WARM:
 46        return WARM
 47    if o is Owner.COOL:
 48        return COOL
 49    return EMPTY
 50
 51
 52def core_colour(o: Owner) -> tuple[float, float, float, float]:
 53    return WARM_CORE if o is Owner.WARM else COOL_CORE
 54
 55
 56def highlight_colour(o: Owner) -> tuple[float, float, float, float]:
 57    return WARM_HI if o is Owner.WARM else COOL_HI
 58
 59
 60def lerp_colour(a, b, t: float):
 61    t = 0.0 if t < 0.0 else 1.0 if t > 1.0 else t
 62    return tuple(a[i] + (b[i] - a[i]) * t for i in range(4))
 63
 64
 65def scale_rgb(c, f: float, alpha: float | None = None):
 66    a = c[3] if alpha is None else alpha
 67    return (min(c[0] * f, 2.0), min(c[1] * f, 2.0), min(c[2] * f, 2.0), a)
 68
 69
 70def draw_ring(renderer, cx: float, cy: float, r_out: float, r_in: float, col) -> None:
 71    """Additive expanding hex annulus (pollen ripple). Built from 6 filled quads
 72    because draw_polygon can't fill a polygon with a hole, and only FILLED
 73    primitives honour blend on both backends."""
 74    out = hex_corners(Vec2(cx, cy), r_out)
 75    inn = hex_corners(Vec2(cx, cy), max(0.0, r_in))
 76    for i in range(6):
 77        j = (i + 1) % 6
 78        quad = [(out[i].x, out[i].y), (out[j].x, out[j].y), (inn[j].x, inn[j].y), (inn[i].x, inn[i].y)]
 79        renderer.draw_polygon(quad, colour=col, filled=True, blend="add")
 80
 81
 82def draw_disc(renderer, cx: float, cy: float, r: float, col, segments: int = 14) -> None:
 83    """Soft additive disc (background pollen mote). draw_circle has no blend param,
 84    so approximate with an additive filled polygon."""
 85    pts = [
 86        (cx + r * math.cos(math.tau * i / segments), cy + r * math.sin(math.tau * i / segments))
 87        for i in range(segments)
 88    ]
 89    renderer.draw_polygon(pts, colour=col, filled=True, blend="add")
 90
 91
 92def draw_petal(renderer, x: float, y: float, s: float, ang: float, col) -> None:
 93    """Additive 4-point petal/diamond, rotated by ``ang``."""
 94    c, sn = math.cos(ang), math.sin(ang)
 95    pts = [(0.0, -s * 1.4), (s * 0.7, 0.0), (0.0, s * 1.4), (-s * 0.7, 0.0)]
 96    renderer.draw_polygon(
 97        [(x + px * c - py * sn, y + px * sn + py * c) for (px, py) in pts], colour=col, filled=True, blend="add"
 98    )
 99
100
101def draw_flower(renderer, cx: float, cy: float, petal_len: float, col) -> None:
102    """Soft additive 6-petal flower with a glowing centre (decorative backdrop)."""
103    for k in range(6):
104        a = math.tau * k / 6.0
105        px = cx + math.cos(a) * petal_len * 0.6
106        py = cy + math.sin(a) * petal_len * 0.6
107        draw_petal(renderer, px, py, petal_len * 0.5, a - math.pi / 2, col)
108
109
110class CellFx:
111    """Mutable per-cell animation state the engine ``tween`` writes into."""
112
113    __slots__ = ("scale", "flash", "afterglow", "from_col", "to_col")
114
115    def __init__(self, colour):
116        self.scale = 1.0  # 1.0 resting; pops above 1 then settles
117        self.flash = 1.0  # 0..1 blend from_col -> to_col
118        self.afterglow = 0.0  # 0..1 additive capture glow that lingers then fades
119        self.from_col = colour
120        self.to_col = colour
121
122    @property
123    def colour(self):
124        return lerp_colour(self.from_col, self.to_col, self.flash)
125
126
127class HexBoardView:
128    """Fits, hit-tests and draws a Bloom board inside a rectangular play area."""
129
130    def __init__(self):
131        self.origin = Vec2(0, 0)
132        self.size = 28.0
133        self.fx: dict[Hex, CellFx] = {}
134
135    # --- layout -------------------------------------------------------------
136    def layout(self, board: Board, area_x: float, area_y: float, area_w: float, area_h: float) -> None:
137        """Compute origin/size so the whole flower fits the play area, centred."""
138        xs, ys = [], []
139        for h in board.cells:
140            xs.append(SQRT3 * h.q + SQRT3 / 2 * h.r)
141            ys.append(1.5 * h.r)
142        span_x = (max(xs) - min(xs)) + SQRT3  # + one cell width (pointy-top)
143        span_y = (max(ys) - min(ys)) + 2.0  # + one cell height
144        self.size = _FIT_MARGIN * min(area_w / span_x, area_h / span_y)
145        mid_x = (max(xs) + min(xs)) / 2 * self.size
146        mid_y = (max(ys) + min(ys)) / 2 * self.size
147        self.origin = Vec2(area_x + area_w / 2 - mid_x, area_y + area_h / 2 - mid_y)
148
149    def ensure_fx(self, board: Board) -> None:
150        """Create any missing effect holders, matched to current ownership."""
151        for h, o in board.cells.items():
152            if h not in self.fx:
153                self.fx[h] = CellFx(owner_colour(o))
154
155    def sync_static(self, board: Board) -> None:
156        """Snap every cell's colour to the board with no animation."""
157        self.fx.clear()
158        for h, o in board.cells.items():
159            self.fx[h] = CellFx(owner_colour(o))
160
161    # --- hit-test -----------------------------------------------------------
162    def pixel_to_hex(self, px: float, py: float) -> Hex:
163        return pixel_to_hex(px, py, self.origin, self.size)
164
165    def centre_of(self, h: Hex) -> Vec2:
166        return hex_to_pixel(h, self.origin, self.size)
167
168    def _verts(self, centre: Vec2, frac: float) -> list[tuple[float, float]]:
169        return [(c.x, c.y) for c in hex_corners(centre, self.size * frac)]
170
171    # --- draw ---------------------------------------------------------------
172    def draw(self, renderer, board: Board) -> None:
173        self.ensure_fx(board)
174        sz = self.size
175        sx, sy = sz * 0.05, sz * 0.07
176        for h, owner in board.cells.items():
177            fx = self.fx[h]
178            centre = hex_to_pixel(h, self.origin, sz)
179            base_frac = _HEX_GAP * fx.scale
180            base = self._verts(centre, base_frac)
181            # (0) drop shadow grounds the cell on the soil
182            shadow = [(x + sx, y + sy) for (x, y) in base]
183            renderer.draw_polygon(shadow, colour=SHADOW, filled=True)
184            if owner is Owner.EMPTY:
185                renderer.draw_polygon(base, colour=fx.colour, filled=True)
186                renderer.draw_polygon(base, colour=GRID_LINE, filled=False)
187                continue
188            # owned: domed petal = rim -> mid -> glowing core
189            col = fx.colour  # animates to HI during a flip flash (blooms), then settles
190            renderer.draw_polygon(base, colour=scale_rgb(col, 0.80), filled=True)
191            renderer.draw_polygon(self._verts(centre, base_frac * _MID_FRAC), colour=col, filled=True)
192            renderer.draw_polygon(self._verts(centre, base_frac * _CORE_FRAC), colour=core_colour(owner), filled=True)
193            # rim light all around (subtle, never blooms at this alpha)
194            renderer.draw_lines(base, closed=True, colour=scale_rgb(col, 1.35, alpha=0.45))
195            # additive afterglow lingering after a capture
196            if fx.afterglow > 0.003:
197                hi = highlight_colour(owner)
198                renderer.draw_polygon(
199                    base, colour=(hi[0], hi[1], hi[2], min(1.0, fx.afterglow)), filled=True, blend="add"
200                )
201        last = board.last_placed
202        if last is not None:
203            centre = hex_to_pixel(last, self.origin, sz)
204            ring = self._verts(centre, _HEX_GAP * self.fx[last].scale)
205            renderer.draw_lines(ring, closed=True, colour=LAST_OUTLINE)