// 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(); 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(); 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"; }