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)