glenans-sailing-sim / src /render.ts
tfrere's picture
tfrere HF Staff
feat: painterly sea, refined tiller, tabbed finish screen + top-5 leaderboard
acf9613
Raw
History Blame Contribute Delete
7.99 kB
// Self-contained canvas render helpers for the scrolling scene. Each takes the
// context plus an explicit snapshot of what it needs, so they stay pure and
// testable instead of closing over the game loop's mutable state.
interface Vec {
x: number;
y: number;
}
// "#rrggbb" -> "r,g,b" (memoised): lets us build rgba() gradient stops that
// fade a colour to transparent WITHOUT drifting through grey.
const rgbCache = new Map<string, string>();
function rgbOf(hex: string): string {
let v = rgbCache.get(hex);
if (v) return v;
const h = hex.charAt(0) === "#" ? hex.slice(1) : hex;
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
v = `${r},${g},${b}`;
rgbCache.set(hex, v);
return v;
}
// Linear blend of two hex colours -> "rgb(r,g,b)" (memoised). t=0 -> a, t=1 -> b.
const mixCache = new Map<string, string>();
function mix(a: string, b: string, t: number): string {
const key = a + b + t.toFixed(3);
let v = mixCache.get(key);
if (v) return v;
const [ar, ag, ab] = rgbOf(a).split(",").map(Number);
const [br, bg, bb] = rgbOf(b).split(",").map(Number);
const r = Math.round(ar + (br - ar) * t);
const g = Math.round(ag + (bg - ag) * t);
const bl = Math.round(ab + (bb - ab) * t);
v = `rgb(${r},${g},${bl})`;
mixCache.set(key, v);
return v;
}
// Sea surface: rolling SWELLS painted as overlapping flat layers (paper-cut /
// gouache look). Each swell is a band filled with a slightly different blue,
// stacked back-to-front with an organic wavy crest, so the eye reads real waves
// with subtle colour variation - not lines or dabs. A soft lit rim on each crest
// gives relief, and a sparse foam cap catches the light. World-anchored +
// parallaxed and drifting downwind; every swell keeps its tone as it scrolls, so
// nothing flickers.
export function drawSea(
ctx: CanvasRenderingContext2D,
opts: {
W: number;
H: number;
cx: number;
cy: number;
boat: Vec;
now: number;
colors: { water: string; waterDeep: string; crest: string };
},
) {
const { W, H, cx, cy, boat, now, colors } = opts;
// Base fill, larger than the viewport so screen-shake never bares an edge.
ctx.fillStyle = colors.water;
ctx.fillRect(-20, -20, W + 40, H + 40);
const hash = (a: number, b: number) => {
const s = Math.sin(a * 127.1 + b * 311.7) * 43758.5453;
return s - Math.floor(s);
};
// Smooth 1-D value noise, for tone variation + crest foam clustering.
const vnoise = (x: number, seed: number) => {
const xi = Math.floor(x);
const f = x - xi;
const u = f * f * (3 - 2 * f);
return hash(xi, seed) * (1 - u) + hash(xi + 1, seed) * u;
};
const spacing = 30; // visible band height (world px)
const drift = now * 0.02; // downwind scroll (px)
const stepX = 12; // crest sampling along E-W (px)
// Tone: t<0 lifts toward foam (lit water), t>0 sinks toward deep. Tight range
// keeps the colours "slightly different", never garish.
const tone = (t: number) =>
t < 0 ? mix(colors.water, colors.crest, Math.min(0.5, -t)) : mix(colors.water, colors.waterDeep, Math.min(1, t));
const nLayers = Math.ceil(H / spacing) + 4;
const k0 = Math.floor((boat.y - cy - drift) / spacing) - 2;
const cols = Math.ceil(W / stepX) + 2;
const wxBase = boat.x - cx; // world-x at screen x=0
// Draw back (top) to front (bottom); each lower band overpaints the previous,
// leaving a wavy strip whose lower edge is the next swell's crest.
for (let i = 0; i < nLayers; i++) {
const k = k0 + i;
const worldY = k * spacing + drift;
const baseY = cy + (worldY - boat.y);
const ph = hash(k, 3) * 6.283;
const ph2 = hash(k, 8) * 6.283;
const amp = 7 + hash(k, 7) * 7;
const wl1 = 130 + hash(k, 11) * 90;
const wl2 = 46 + hash(k, 5) * 26;
// Per-swell tone: two octaves of noise over the layer index give broad
// lighter/darker zones plus wave-to-wave variation.
const tv =
-0.28 +
1.15 * (0.6 * vnoise(k * 0.16, 2) + 0.4 * vnoise(k * 0.5 + 3, 9));
const crestY = (x: number) => {
const wx = wxBase + x;
return baseY + Math.sin(wx / wl1 + ph) * amp + Math.sin(wx / wl2 + ph2) * amp * 0.35;
};
// Fill this swell's band from its crest down past the bottom edge, with a
// gentle vertical gradient (lit near the crest, a touch deeper below) so each
// layer has soft dimension instead of a flat slab.
ctx.beginPath();
ctx.moveTo(-20, crestY(-20));
for (let c = 0; c <= cols; c++) {
const x = c * stepX;
ctx.lineTo(x, crestY(x));
}
ctx.lineTo(W + 20, crestY(W + 20));
ctx.lineTo(W + 20, H + 40);
ctx.lineTo(-20, H + 40);
ctx.closePath();
const g = ctx.createLinearGradient(0, baseY - amp, 0, baseY + spacing + amp);
g.addColorStop(0, tone(tv - 0.16));
g.addColorStop(1, tone(tv + 0.22));
ctx.fillStyle = g;
ctx.fill();
// Light wispy foam trailing off the top of a few crests. Drawn segment by
// segment with a sine-faded alpha so each streak melts in/out at its ends -
// reads as spray catching the light, never a hard outline.
if (hash(k, 17) > 0.62) {
const foamAt = (c: number) => vnoise((wxBase + c * stepX) / 46 + k * 0.6, k * 5 + 1);
ctx.strokeStyle = colors.crest;
ctx.lineWidth = 1.4;
ctx.lineCap = "round";
let c = 0;
while (c <= cols) {
if (foamAt(c) <= 0.7) {
c++;
continue;
}
const s0 = c;
while (c <= cols && foamAt(c) > 0.6) c++;
const e = c - 1;
const cnt = e - s0;
if (cnt >= 1) {
for (let j = s0; j < e; j++) {
const t = (j - s0 + 0.5) / cnt;
ctx.globalAlpha = 0.5 * Math.sin(Math.PI * t);
const x0 = j * stepX;
const x1 = (j + 1) * stepX;
ctx.beginPath();
ctx.moveTo(x0, crestY(x0) - 1.5);
ctx.lineTo(x1, crestY(x1) - 1.5);
ctx.stroke();
}
}
}
ctx.globalAlpha = 1;
ctx.lineCap = "butt";
}
}
}
// Wake: the boat's real past path (world-anchored), so it curves through every
// tack and turn. The live position is appended as the head so the wake stays
// attached even at low speed. Width and alpha taper from a crisp head down to
// nothing at the tail, with a touch of foam up front.
export function drawWake(
ctx: CanvasRenderingContext2D,
opts: {
cx: number;
cy: number;
boat: Vec;
now: number;
trail: { x: number; y: number; t: number }[];
wakeLifeMs: number;
},
) {
const { cx, cy, boat, now, trail, wakeLifeMs } = opts;
const m = trail.length;
if (m <= 1) return;
const n = m + 1; // + the live head, always on the boat
// Screen coords + age (1 fresh -> 0 old) read straight from the trail, so no
// per-frame array is allocated. Index n-1 is the virtual live head.
const px = (k: number) => (k < m ? cx + (trail[k].x - boat.x) : cx);
const py = (k: number) => (k < m ? cy + (trail[k].y - boat.y) : cy);
const ageOf = (k: number) =>
k < m ? Math.max(0, 1 - (now - trail[k].t) / wakeLifeMs) : 1;
ctx.lineCap = "round";
ctx.lineJoin = "round";
// disturbed-water body
for (let k = 1; k < n; k++) {
const t = k / (n - 1); // 0 tail .. 1 head
ctx.beginPath();
ctx.moveTo(px(k - 1), py(k - 1));
ctx.lineTo(px(k), py(k));
ctx.lineWidth = 1 + t * t * 5;
ctx.strokeStyle = `rgba(22,54,66,${(0.03 + t * 0.15) * ageOf(k)})`;
ctx.stroke();
}
// bright foam, only the last stretch behind the transom
const foam = Math.min(n - 1, 14);
for (let k = n - foam; k < n; k++) {
const t = (k - (n - foam)) / foam; // 0 .. 1 toward the boat
ctx.beginPath();
ctx.moveTo(px(k - 1), py(k - 1));
ctx.lineTo(px(k), py(k));
ctx.lineWidth = 0.8 + t * 2.2;
ctx.strokeStyle = `rgba(255,255,255,${t * 0.5 * ageOf(k)})`;
ctx.stroke();
}
ctx.lineCap = "butt";
}