basin / files / src / render / renderer.ts
renderer.ts
9e605e0f 2026-06-30 Pavel Safronov | 1 | import type { Heightmap } from '../model/heightmap'; |
a7bf123a 2026-07-01 Pavel Safronov | 2 | import { heightAt, isOceanEdge } from '../model/heightmap'; |
9e605e0f 2026-06-30 Pavel Safronov | 3 | import type { Cell, FlowResult } from '../model/flow'; |
| 4 | ||
a7bf123a 2026-07-01 Pavel Safronov | 5 | /** Puzzle-mode overlay: the goal edges plus the player's springs. */ |
| 6 | export interface PuzzleView { | |
| 7 | /** 1 for every border cell the solution wets, row-major. */ | |
| 8 | target: Uint8Array; | |
| 9 | /** Locked, correctly-found springs, in find order (labelled 1..k). */ | |
| 10 | found: Cell[]; | |
| 11 | /** The current transient exploration tap, if any. */ | |
| 12 | probe: Cell | null; | |
644389f3 2026-07-02 Pavel Safronov | 13 | /** Dev-only: hidden spring positions to reveal as debug markers. */ |
| 14 | solution?: Cell[]; | |
a7bf123a 2026-07-01 Pavel Safronov | 15 | } |
| 16 | ||
9e605e0f 2026-06-30 Pavel Safronov | 17 | /** |
| 18 | * Canvas2D renderer for small boards (one filled rect per cell). Deliberately | |
| 19 | * decoupled from the model so a tiled / WebGL renderer can replace it for | |
| 20 | * contour-scale boards without touching game logic. | |
| 21 | */ | |
| 22 | export class Renderer { | |
| 23 | private ctx: CanvasRenderingContext2D; | |
| 24 | private cell = 0; | |
| 25 | ||
| 26 | constructor(private canvas: HTMLCanvasElement) { | |
| 27 | const ctx = canvas.getContext('2d'); | |
| 28 | if (!ctx) throw new Error('2D canvas not supported'); | |
| 29 | this.ctx = ctx; | |
| 30 | } | |
| 31 | ||
| 32 | /** Translate a click (canvas-local px) into board coords, or null if outside. */ | |
| 33 | pickCell(hm: Heightmap, px: number, py: number): Cell | null { | |
| 34 | if (this.cell === 0) return null; | |
| 35 | const x = Math.floor(px / this.cell); | |
| 36 | const y = Math.floor(py / this.cell); | |
| 37 | if (x < 0 || y < 0 || x >= hm.width || y >= hm.height) return null; | |
| 38 | return { x, y }; | |
| 39 | } | |
| 40 | ||
a7bf123a 2026-07-01 Pavel Safronov | 41 | render(hm: Heightmap, flow: FlowResult | null, view?: PuzzleView | null): void { |
9e605e0f 2026-06-30 Pavel Safronov | 42 | const dpr = window.devicePixelRatio || 1; |
bfd86d6e 2026-07-01 Pavel Safronov | 43 | // Fit the board to its container (the board area beside the sidebar), not the |
| 44 | // whole window, so it never underlaps the controls. Fall back to the window | |
| 45 | // width if the canvas isn't in the layout yet. | |
| 46 | const parent = this.canvas.parentElement; | |
| 47 | const avail = parent && parent.clientWidth > 0 ? parent.clientWidth : window.innerWidth - 32; | |
| 48 | const maxPx = Math.min(Math.max(avail, 120), 640); | |
9e605e0f 2026-06-30 Pavel Safronov | 49 | this.cell = Math.max(1, Math.floor(maxPx / hm.width)); |
| 50 | const w = this.cell * hm.width; | |
| 51 | const h = this.cell * hm.height; | |
| 52 | ||
| 53 | this.canvas.width = w * dpr; | |
| 54 | this.canvas.height = h * dpr; | |
| 55 | this.canvas.style.width = `${w}px`; | |
| 56 | this.canvas.style.height = `${h}px`; | |
| 57 | ||
| 58 | const ctx = this.ctx; | |
| 59 | ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| 60 | ||
| 61 | // Terrain. | |
| 62 | for (let y = 0; y < hm.height; y++) { | |
| 63 | for (let x = 0; x < hm.width; x++) { | |
| 64 | ctx.fillStyle = terrainColor(heightAt(hm, x, y)); | |
| 65 | ctx.fillRect(x * this.cell, y * this.cell, this.cell, this.cell); | |
| 66 | } | |
| 67 | } | |
| 68 | ||
a7bf123a 2026-07-01 Pavel Safronov | 69 | // Wet cells (union of the current springs' flow). |
9e605e0f 2026-06-30 Pavel Safronov | 70 | if (flow) { |
| 71 | ctx.fillStyle = 'rgba(40,120,255,0.55)'; | |
| 72 | for (let y = 0; y < hm.height; y++) { | |
| 73 | for (let x = 0; x < hm.width; x++) { | |
| 74 | if (flow.wet[y * hm.width + x]) { | |
| 75 | ctx.fillRect(x * this.cell, y * this.cell, this.cell, this.cell); | |
| 76 | } | |
| 77 | } | |
| 78 | } | |
a7bf123a 2026-07-01 Pavel Safronov | 79 | } |
| 80 | ||
| 81 | if (view) { | |
| 82 | this.drawTarget(hm, view.target, flow); | |
| 83 | // Locked, found springs: numbered so order-of-discovery reads clearly. | |
| 84 | view.found.forEach((s, i) => { | |
| 85 | ctx.fillStyle = '#34c759'; | |
| 86 | this.dot(s.x, s.y, this.cell * 0.3, 5); | |
| 87 | ctx.fillStyle = '#06210d'; | |
| 88 | ctx.font = `${Math.max(9, Math.floor(this.cell * 0.4))}px system-ui, sans-serif`; | |
| 89 | ctx.textAlign = 'center'; | |
| 90 | ctx.textBaseline = 'middle'; | |
| 91 | ctx.fillText(String(i + 1), (s.x + 0.5) * this.cell, (s.y + 0.55) * this.cell); | |
| 92 | }); | |
| 93 | // Transient exploration tap. | |
| 94 | if (view.probe) { | |
| 95 | ctx.fillStyle = '#ff3b30'; | |
| 96 | this.dot(view.probe.x, view.probe.y, this.cell * 0.28, 4); | |
| 97 | } | |
| 98 | } else if (flow) { | |
| 99 | // Sandbox fallback: plain outflow + spring markers. | |
9e605e0f 2026-06-30 Pavel Safronov | 100 | ctx.fillStyle = '#ffe14d'; |
| 101 | for (const o of flow.outflow) this.dot(o.x, o.y, this.cell * 0.18, 3); | |
| 102 | ctx.fillStyle = '#ff3b30'; | |
a7bf123a 2026-07-01 Pavel Safronov | 103 | for (const s of flow.springs) this.dot(s.x, s.y, this.cell * 0.28, 4); |
9e605e0f 2026-06-30 Pavel Safronov | 104 | } |
| 105 | ||
644389f3 2026-07-02 Pavel Safronov | 106 | // Dev-only: reveal hidden spring positions with a black ×. |
| 107 | if (import.meta.env.DEV && view?.solution?.length) { | |
| 108 | ctx.strokeStyle = 'rgba(0,0,0,0.85)'; | |
| 109 | ctx.lineWidth = Math.max(1.5, this.cell * 0.1); | |
| 110 | const r = this.cell * 0.28; | |
| 111 | for (const s of view.solution) { | |
| 112 | const mx = (s.x + 0.5) * this.cell; | |
| 113 | const my = (s.y + 0.5) * this.cell; | |
| 114 | ctx.beginPath(); | |
| 115 | ctx.moveTo(mx - r, my - r); | |
| 116 | ctx.lineTo(mx + r, my + r); | |
| 117 | ctx.moveTo(mx + r, my - r); | |
| 118 | ctx.lineTo(mx - r, my + r); | |
| 119 | ctx.stroke(); | |
| 120 | } | |
| 121 | } | |
| 122 | ||
9e605e0f 2026-06-30 Pavel Safronov | 123 | // Grid lines (small boards only). |
| 124 | if (this.cell >= 16) { | |
| 125 | ctx.strokeStyle = 'rgba(0,0,0,0.15)'; | |
| 126 | ctx.lineWidth = 1; | |
| 127 | for (let x = 0; x <= hm.width; x++) { | |
| 128 | ctx.beginPath(); | |
| 129 | ctx.moveTo(x * this.cell, 0); | |
| 130 | ctx.lineTo(x * this.cell, h); | |
| 131 | ctx.stroke(); | |
| 132 | } | |
| 133 | for (let y = 0; y <= hm.height; y++) { | |
| 134 | ctx.beginPath(); | |
| 135 | ctx.moveTo(0, y * this.cell); | |
| 136 | ctx.lineTo(w, y * this.cell); | |
| 137 | ctx.stroke(); | |
| 138 | } | |
| 139 | } | |
| 140 | } | |
| 141 | ||
a7bf123a 2026-07-01 Pavel Safronov | 142 | /** |
644389f3 2026-07-02 Pavel Safronov | 143 | * Shore feedback. Targets are shown on "shore" cells — the land cells |
| 144 | * (height > 0) immediately adjacent to ocean edges — rather than on the | |
| 145 | * blue ocean-edge tiles themselves. | |
| 146 | * | |
| 147 | * Gold outline = a goal shore (the adjacent ocean edge is in the target). | |
| 148 | * Green fill = goal shore is satisfied (the player's flow wets it). | |
| 149 | * Red fill = shore wets a non-goal ocean edge (wrong spring / overflow). | |
a7bf123a 2026-07-01 Pavel Safronov | 150 | */ |
| 151 | private drawTarget(hm: Heightmap, target: Uint8Array, flow: FlowResult | null): void { | |
| 152 | const ctx = this.ctx; | |
| 153 | const c = this.cell; | |
| 154 | const inset = Math.max(1.5, c * 0.12); | |
| 155 | ctx.lineWidth = Math.max(2, c * 0.14); | |
| 156 | ||
644389f3 2026-07-02 Pavel Safronov | 157 | // For each ocean edge cell, accumulate goal/overflow state onto its |
| 158 | // adjacent land ("shore") cells. A shore can adjoin multiple edges; | |
| 159 | // goal state takes priority over overflow. | |
| 160 | const shoreGoal = new Set<number>(); | |
| 161 | const shoreOverflow = new Set<number>(); | |
| 162 | const DIRS: ReadonlyArray<readonly [number, number]> = [[0, -1], [1, 0], [0, 1], [-1, 0]]; | |
| 163 | ||
a7bf123a 2026-07-01 Pavel Safronov | 164 | for (let y = 0; y < hm.height; y++) { |
| 165 | for (let x = 0; x < hm.width; x++) { | |
| 166 | if (!isOceanEdge(hm.edges, x, y, hm.width, hm.height)) continue; | |
| 167 | const i = y * hm.width + x; | |
| 168 | const goal = target[i] === 1; | |
| 169 | const wet = flow ? flow.wet[i] === 1 : false; | |
644389f3 2026-07-02 Pavel Safronov | 170 | if (!goal && !wet) continue; |
a7bf123a 2026-07-01 Pavel Safronov | 171 | |
644389f3 2026-07-02 Pavel Safronov | 172 | for (const [dx, dy] of DIRS) { |
| 173 | const nx = x + dx; | |
| 174 | const ny = y + dy; | |
| 175 | if (nx < 0 || ny < 0 || nx >= hm.width || ny >= hm.height) continue; | |
| 176 | if (isOceanEdge(hm.edges, nx, ny, hm.width, hm.height)) continue; | |
| 177 | if (heightAt(hm, nx, ny) <= 0) continue; // skip sunken cells | |
| 178 | const ni = ny * hm.width + nx; | |
| 179 | if (goal) shoreGoal.add(ni); | |
| 180 | else if (wet) shoreOverflow.add(ni); | |
a7bf123a 2026-07-01 Pavel Safronov | 181 | } |
| 182 | } | |
| 183 | } | |
644389f3 2026-07-02 Pavel Safronov | 184 | |
| 185 | for (const ni of shoreGoal) { | |
| 186 | const x = ni % hm.width; | |
| 187 | const y = (ni / hm.width) | 0; | |
| 188 | ctx.strokeStyle = '#ffd60a'; | |
| 189 | ctx.strokeRect(x * c + inset, y * c + inset, c - inset * 2, c - inset * 2); | |
| 190 | if (flow && flow.wet[ni]) { | |
| 191 | ctx.fillStyle = 'rgba(52,199,89,0.55)'; | |
| 192 | ctx.fillRect(x * c + inset, y * c + inset, c - inset * 2, c - inset * 2); | |
| 193 | } | |
| 194 | } | |
| 195 | ||
| 196 | for (const ni of shoreOverflow) { | |
| 197 | if (shoreGoal.has(ni)) continue; // goal takes priority | |
| 198 | const x = ni % hm.width; | |
| 199 | const y = (ni / hm.width) | 0; | |
| 200 | ctx.fillStyle = 'rgba(255,59,48,0.5)'; | |
| 201 | ctx.fillRect(x * c + inset, y * c + inset, c - inset * 2, c - inset * 2); | |
| 202 | } | |
a7bf123a 2026-07-01 Pavel Safronov | 203 | } |
| 204 | ||
9e605e0f 2026-06-30 Pavel Safronov | 205 | private dot(cx: number, cy: number, r: number, min: number): void { |
| 206 | const ctx = this.ctx; | |
| 207 | ctx.beginPath(); | |
| 208 | ctx.arc((cx + 0.5) * this.cell, (cy + 0.5) * this.cell, Math.max(min, r), 0, Math.PI * 2); | |
| 209 | ctx.fill(); | |
| 210 | } | |
| 211 | } | |
| 212 | ||
| 213 | /** Hypsometric tint: water blue at 0, green low, tan mid, brown then white high. */ | |
| 214 | function terrainColor(value: number): string { | |
| 215 | const t = value < 0 ? 0 : value > 1 ? 1 : value; | |
| 216 | const stops: Array<[number, [number, number, number]]> = [ | |
| 217 | [0.0, [40, 80, 160]], | |
| 218 | [0.02, [70, 130, 180]], | |
| 219 | [0.15, [96, 160, 90]], | |
| 220 | [0.45, [200, 190, 120]], | |
| 221 | [0.75, [150, 110, 80]], | |
| 222 | [1.0, [245, 245, 245]], | |
| 223 | ]; | |
| 224 | let lo = stops[0]; | |
| 225 | let hi = stops[stops.length - 1]; | |
| 226 | for (let i = 0; i < stops.length - 1; i++) { | |
| 227 | if (t >= stops[i][0] && t <= stops[i + 1][0]) { | |
| 228 | lo = stops[i]; | |
| 229 | hi = stops[i + 1]; | |
| 230 | break; | |
| 231 | } | |
| 232 | } | |
| 233 | const span = hi[0] - lo[0] || 1; | |
| 234 | const f = (t - lo[0]) / span; | |
| 235 | const r = Math.round(lo[1][0] + (hi[1][0] - lo[1][0]) * f); | |
| 236 | const g = Math.round(lo[1][1] + (hi[1][1] - lo[1][1]) * f); | |
| 237 | const b = Math.round(lo[1][2] + (hi[1][2] - lo[1][2]) * f); | |
| 238 | return `rgb(${r},${g},${b})`; | |
| 239 | } |