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 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.polygon(quad, fill=col, 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). 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.polygon(pts, fill=col, 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.polygon([(x + px * c - py * sn, y + px * sn + py * c) for (px, py) in pts], fill=col, blend="add")
 97
 98
 99def draw_flower(renderer, cx: float, cy: float, petal_len: float, col) -> None:
100    """Soft additive 6-petal flower with a glowing centre (decorative backdrop)."""
101    for k in range(6):
102        a = math.tau * k / 6.0
103        px = cx + math.cos(a) * petal_len * 0.6
104        py = cy + math.sin(a) * petal_len * 0.6
105        draw_petal(renderer, px, py, petal_len * 0.5, a - math.pi / 2, col)
106
107
108class CellFx:
109    """Mutable per-cell animation state the engine ``tween`` writes into."""
110
111    __slots__ = ("scale", "flash", "afterglow", "from_col", "to_col")
112
113    def __init__(self, colour):
114        self.scale = 1.0  # 1.0 resting; pops above 1 then settles
115        self.flash = 1.0  # 0..1 blend from_col -> to_col
116        self.afterglow = 0.0  # 0..1 additive capture glow that lingers then fades
117        self.from_col = colour
118        self.to_col = colour
119
120    @property
121    def colour(self):
122        return lerp_colour(self.from_col, self.to_col, self.flash)
123
124
125class HexBoardView:
126    """Fits, hit-tests and draws a Bloom board inside a rectangular play area."""
127
128    def __init__(self):
129        self.origin = Vec2(0, 0)
130        self.size = 28.0
131        self.fx: dict[Hex, CellFx] = {}
132
133    # --- layout -------------------------------------------------------------
134    def layout(self, board: Board, area_x: float, area_y: float, area_w: float, area_h: float) -> None:
135        """Compute origin/size so the whole flower fits the play area, centred."""
136        xs, ys = [], []
137        for h in board.cells:
138            xs.append(SQRT3 * h.q + SQRT3 / 2 * h.r)
139            ys.append(1.5 * h.r)
140        span_x = (max(xs) - min(xs)) + SQRT3  # + one cell width (pointy-top)
141        span_y = (max(ys) - min(ys)) + 2.0  # + one cell height
142        self.size = _FIT_MARGIN * min(area_w / span_x, area_h / span_y)
143        mid_x = (max(xs) + min(xs)) / 2 * self.size
144        mid_y = (max(ys) + min(ys)) / 2 * self.size
145        self.origin = Vec2(area_x + area_w / 2 - mid_x, area_y + area_h / 2 - mid_y)
146
147    def ensure_fx(self, board: Board) -> None:
148        """Create any missing effect holders, matched to current ownership."""
149        for h, o in board.cells.items():
150            if h not in self.fx:
151                self.fx[h] = CellFx(owner_colour(o))
152
153    def sync_static(self, board: Board) -> None:
154        """Snap every cell's colour to the board with no animation."""
155        self.fx.clear()
156        for h, o in board.cells.items():
157            self.fx[h] = CellFx(owner_colour(o))
158
159    # --- hit-test -----------------------------------------------------------
160    def pixel_to_hex(self, px: float, py: float) -> Hex:
161        return pixel_to_hex(px, py, self.origin, self.size)
162
163    def centre_of(self, h: Hex) -> Vec2:
164        return hex_to_pixel(h, self.origin, self.size)
165
166    def _verts(self, centre: Vec2, frac: float) -> list[tuple[float, float]]:
167        return [(c.x, c.y) for c in hex_corners(centre, self.size * frac)]
168
169    # --- draw ---------------------------------------------------------------
170    def draw(self, renderer, board: Board) -> None:
171        self.ensure_fx(board)
172        sz = self.size
173        sx, sy = sz * 0.05, sz * 0.07
174        for h, owner in board.cells.items():
175            fx = self.fx[h]
176            centre = hex_to_pixel(h, self.origin, sz)
177            base_frac = _HEX_GAP * fx.scale
178            base = self._verts(centre, base_frac)
179            # (0) drop shadow grounds the cell on the soil
180            shadow = [(x + sx, y + sy) for (x, y) in base]
181            renderer.polygon(shadow, fill=SHADOW)
182            if owner is Owner.EMPTY:
183                renderer.polygon(base, fill=fx.colour)
184                renderer.polygon(base, stroke=GRID_LINE)
185                continue
186            # owned: domed petal = rim -> mid -> glowing core
187            col = fx.colour  # animates to HI during a flip flash (blooms), then settles
188            renderer.polygon(base, fill=scale_rgb(col, 0.80))
189            renderer.polygon(self._verts(centre, base_frac * _MID_FRAC), fill=col)
190            renderer.polygon(self._verts(centre, base_frac * _CORE_FRAC), fill=core_colour(owner))
191            # rim light all around (subtle, never blooms at this alpha)
192            renderer.polygon(base, stroke=scale_rgb(col, 1.35, alpha=0.45))
193            # additive afterglow lingering after a capture
194            if fx.afterglow > 0.003:
195                hi = highlight_colour(owner)
196                renderer.polygon(base, fill=(hi[0], hi[1], hi[2], min(1.0, fx.afterglow)), blend="add")
197        last = board.last_placed
198        if last is not None:
199            centre = hex_to_pixel(last, self.origin, sz)
200            ring = self._verts(centre, _HEX_GAP * self.fx[last].scale)
201            renderer.polygon(ring, stroke=LAST_OUTLINE)