Spaces:
Sleeping
Sleeping
| // 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"; | |
| } | |