fuzzyworld

basin / files / src / render / renderer.ts

renderer.ts

Blame: the commit that last changed each line · file · history

9e605e0f 2026-06-30 Pavel Safronov1import type { Heightmap } from '../model/heightmap';
a7bf123a 2026-07-01 Pavel Safronov2import { heightAt, isOceanEdge } from '../model/heightmap';
9e605e0f 2026-06-30 Pavel Safronov3import type { Cell, FlowResult } from '../model/flow';
4
a7bf123a 2026-07-01 Pavel Safronov5/** Puzzle-mode overlay: the goal edges plus the player's springs. */
6export 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 Safronov13 /** Dev-only: hidden spring positions to reveal as debug markers. */
14 solution?: Cell[];
a7bf123a 2026-07-01 Pavel Safronov15}
16
9e605e0f 2026-06-30 Pavel Safronov17/**
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 */
22export 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 Safronov41 render(hm: Heightmap, flow: FlowResult | null, view?: PuzzleView | null): void {
9e605e0f 2026-06-30 Pavel Safronov42 const dpr = window.devicePixelRatio || 1;
bfd86d6e 2026-07-01 Pavel Safronov43 // 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 Safronov49 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 Safronov69 // Wet cells (union of the current springs' flow).
9e605e0f 2026-06-30 Pavel Safronov70 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 Safronov79 }
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 Safronov100 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 Safronov103 for (const s of flow.springs) this.dot(s.x, s.y, this.cell * 0.28, 4);
9e605e0f 2026-06-30 Pavel Safronov104 }
105
644389f3 2026-07-02 Pavel Safronov106 // 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 Safronov123 // 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 Safronov142 /**
644389f3 2026-07-02 Pavel Safronov143 * 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 Safronov150 */
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 Safronov157 // 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 Safronov164 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 Safronov170 if (!goal && !wet) continue;
a7bf123a 2026-07-01 Pavel Safronov171
644389f3 2026-07-02 Pavel Safronov172 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 Safronov181 }
182 }
183 }
644389f3 2026-07-02 Pavel Safronov184
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 Safronov203 }
204
9e605e0f 2026-06-30 Pavel Safronov205 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. */
214function 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}