// Super Kart Canvas2D painters (browser-only — the headless modules never // import this). Three jobs: // 1. rasterizeTrack(): the top-down world texture the Mode 7 shader samples, // including the procedural white/red/black striped curbs that sell the // sense of speed. // 2. drawMinimapCanvas(): the HUD minimap outline. // 3. Procedural fallback art: kart rotation sheets, item sheet, and theme // backdrops, generated when the drop-in PNGs in // data/superkart-artwork.json are still path:null. import { sampleAt, STRIPE_LEN, CURB_WIDTH } from './SuperKartTrack.js'; export const TRACK_TEXTURE_SIZE = 2048; function makeCanvas(w, h) { const c = document.createElement('canvas'); c.width = w; c.height = h; return c; } // Deterministic tiny rng for speckle so re-rasterizing looks identical. function speckleRng(seed) { let a = seed >>> 0; return () => { a = (a * 1664525 + 1013904223) >>> 0; return a / 4294967296; }; } // ── Track texture ─────────────────────────────────────────────────────────── export function rasterizeTrack(model, theme, size = TRACK_TEXTURE_SIZE) { const canvas = makeCanvas(size, size); const ctx = canvas.getContext('2d'); const k = size / model.world; // world → texture scale const S = model.samples; const n = S.length; // Edge points once: left/right road edge and outer curb edge per sample. const left = []; const right = []; const leftOut = []; const rightOut = []; for (const p of S) { const nx = -p.ty; const ny = p.tx; left.push([p.x + nx * p.w, p.y + ny * p.w]); right.push([p.x - nx * p.w, p.y - ny * p.w]); leftOut.push([p.x + nx * (p.w + CURB_WIDTH), p.y + ny * (p.w + CURB_WIDTH)]); rightOut.push([p.x - nx * (p.w + CURB_WIDTH), p.y - ny * (p.w + CURB_WIDTH)]); } // 1. Terrain fill + speckle noise (theme sheet tile fill could replace this // later — the texture is regenerated per race so no code change needed). ctx.fillStyle = theme.terrain; ctx.fillRect(0, 0, size, size); const rng = speckleRng(0xBADD00D); ctx.fillStyle = theme.terrainSpeckle; for (let i = 0; i < 9000; i += 1) { const x = rng() * size; const y = rng() * size; const r = 1 + rng() * 2.5; ctx.globalAlpha = 0.25 + rng() * 0.5; ctx.fillRect(x, y, r, r); } ctx.globalAlpha = 1; // 2. Painted surface patches (water / deep). for (const surf of model.json.surfaces ?? []) { ctx.fillStyle = surf.type === 'water' ? theme.water : theme.deep; if (surf.poly) { ctx.beginPath(); surf.poly.forEach(([x, y], i) => (i ? ctx.lineTo(x * k, y * k) : ctx.moveTo(x * k, y * k))); ctx.closePath(); ctx.fill(); ctx.strokeStyle = 'rgba(0,0,0,0.35)'; ctx.lineWidth = 3; ctx.stroke(); } else if (surf.circle) { const [cx, cy, r] = surf.circle; ctx.beginPath(); ctx.arc(cx * k, cy * k, r * k, 0, Math.PI * 2); ctx.fill(); } } // 3. Black outer hairline FIRST (a slightly wider dark ribbon the curbs and // road paint over — what survives is the 2-unit contrast edge). fillRibbon(ctx, leftOut, rightOut, k, '#0a0a0a', 2.5 * k); // 4. Striped curbs: the band between road edge and curb edge, alternating // white/red per STRIPE_LEN of arc — the classic kart-racer rumble strip. for (let i = 0; i < n; i += 1) { const j = (i + 1) % n; const stripe = Math.floor(S[i].s / STRIPE_LEN) % 2; ctx.fillStyle = stripe === 0 ? '#f2f2f2' : '#d42a20'; quad(ctx, left[i], leftOut[i], leftOut[j], left[j], k); const stripeR = (Math.floor(S[i].s / STRIPE_LEN) + 1) % 2; // offset phase on the right edge ctx.fillStyle = stripeR === 0 ? '#f2f2f2' : '#d42a20'; quad(ctx, right[i], rightOut[i], rightOut[j], right[j], k); } // 5. Road ribbon (over the curb band's inner overlap so edges stay crisp). fillRibbon(ctx, left, right, k, theme.road, 0); // Subtle asphalt texture + a faint center dash. const rng2 = speckleRng(0xF00DFACE); ctx.fillStyle = theme.roadEdge; for (let i = 0; i < 2600; i += 1) { const p = S[Math.floor(rng2() * n)]; const off = (rng2() * 2 - 1) * p.w * 0.92; ctx.globalAlpha = 0.12 + rng2() * 0.2; ctx.fillRect((p.x + -p.ty * off) * k, (p.y + p.tx * off) * k, 2, 2); } ctx.globalAlpha = 1; ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = Math.max(1, 8 * k); ctx.setLineDash([56 * k, 88 * k]); ctx.beginPath(); S.forEach((p, i) => (i ? ctx.lineTo(p.x * k, p.y * k) : ctx.moveTo(p.x * k, p.y * k))); ctx.closePath(); ctx.stroke(); ctx.setLineDash([]); // 6. Boost chevrons. for (const b of model.boosts) { ctx.save(); ctx.translate(b.x * k, b.y * k); ctx.rotate(b.angle); ctx.fillStyle = '#ffd028'; for (let c = -1; c <= 1; c += 1) { // three chevrons, ~26 world units apart ctx.beginPath(); ctx.moveTo((c * 26 - 14) * k, -20 * k); ctx.lineTo((c * 26 + 8) * k, 0); ctx.lineTo((c * 26 - 14) * k, 20 * k); ctx.lineTo((c * 26 - 3) * k, 0); ctx.closePath(); ctx.fill(); } ctx.restore(); } // 7. Item pad diamonds (the spinning box itself is a projected sprite). for (const box of model.itemBoxes) { ctx.save(); ctx.translate(box.x * k, box.y * k); ctx.rotate(Math.PI / 4); ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 2; const r = 40 * k; ctx.strokeRect(-r / 2, -r / 2, r, r); ctx.restore(); } // 8. Start/finish checkerboard + grid slot ticks. drawStartLine(ctx, model, k); ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = 2; for (const g of model.gridSlots) { ctx.save(); ctx.translate(g.x * k, g.y * k); ctx.rotate(g.heading); const w = 34 * k; ctx.strokeRect(-w * 0.6, -w / 2, w * 1.2, w); ctx.restore(); } return canvas; } function quad(ctx, a, b, c, d, k) { ctx.beginPath(); ctx.moveTo(a[0] * k, a[1] * k); ctx.lineTo(b[0] * k, b[1] * k); ctx.lineTo(c[0] * k, c[1] * k); ctx.lineTo(d[0] * k, d[1] * k); ctx.closePath(); ctx.fill(); // Hairline stroke of the same fill kills antialias seams between quads. ctx.strokeStyle = ctx.fillStyle; ctx.lineWidth = 0.75; ctx.stroke(); } // Fills the closed band between two edge polylines as one even-odd path, // optionally stroked outward (used for the black contrast edge). function fillRibbon(ctx, leftPts, rightPts, k, color, strokeW) { ctx.beginPath(); leftPts.forEach(([x, y], i) => (i ? ctx.lineTo(x * k, y * k) : ctx.moveTo(x * k, y * k))); ctx.closePath(); rightPts.forEach(([x, y], i) => (i ? ctx.lineTo(x * k, y * k) : ctx.moveTo(x * k, y * k))); ctx.closePath(); ctx.fillStyle = color; ctx.fill('evenodd'); if (strokeW > 0) { ctx.strokeStyle = color; ctx.lineWidth = strokeW; ctx.stroke(); } } function drawStartLine(ctx, model, k) { const p = sampleAt(model, model.startS); const nx = -p.ty; const ny = p.tx; const cols = 10; const rows = 2; const bandDepth = 14; // world units along travel direction for (let r = 0; r < rows; r += 1) { for (let c = 0; c < cols; c += 1) { const lane = -1 + (2 * c) / cols; const laneNext = -1 + (2 * (c + 1)) / cols; const d0 = (r - rows / 2) * bandDepth; const d1 = d0 + bandDepth; ctx.fillStyle = (r + c) % 2 === 0 ? '#f2f2f2' : '#151515'; ctx.beginPath(); const pt = (laneT, depth) => [ (p.x + nx * laneT * p.w + p.tx * depth) * k, (p.y + ny * laneT * p.w + p.ty * depth) * k, ]; const q = [pt(lane, d0), pt(laneNext, d0), pt(laneNext, d1), pt(lane, d1)]; ctx.moveTo(q[0][0], q[0][1]); q.slice(1).forEach(([x, y]) => ctx.lineTo(x, y)); ctx.closePath(); ctx.fill(); } } } // ── Minimap ───────────────────────────────────────────────────────────────── // Returns { canvas, toMap } — toMap(x, y) maps world → minimap pixels so the // scene can overlay live kart dots with the same transform. export function drawMinimapCanvas(model, size = 220) { const canvas = makeCanvas(size, size); const ctx = canvas.getContext('2d'); const pts = model.samples; let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (const p of pts) { minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x); minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y); } const pad = 14; const scale = (size - pad * 2) / Math.max(maxX - minX, maxY - minY); const ox = (size - (maxX - minX) * scale) / 2; const oy = (size - (maxY - minY) * scale) / 2; const toMap = (x, y) => [ox + (x - minX) * scale, oy + (y - minY) * scale]; const stroke = (width, style) => { ctx.strokeStyle = style; ctx.lineWidth = width; ctx.lineJoin = 'round'; ctx.beginPath(); pts.forEach((p, i) => { const [x, y] = toMap(p.x, p.y); if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y); }); ctx.closePath(); ctx.stroke(); }; stroke(9, 'rgba(0,0,0,0.75)'); stroke(5, 'rgba(240,240,240,0.9)'); // Start-line tick. const sp = sampleAt(model, model.startS); const [sx, sy] = toMap(sp.x, sp.y); ctx.strokeStyle = '#ffd028'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(sx - -sp.ty * 7, sy - sp.tx * 7); ctx.lineTo(sx + -sp.ty * 7, sy + sp.tx * 7); ctx.stroke(); return { canvas, toMap }; } // ── Procedural fallback sprites ───────────────────────────────────────────── // 18-frame kart rotation strip (16 view angles + 2 lean frames) drawn as a // simple tinted kart. Frame 0 = seen from behind, rotating clockwise — the // same contract as drop-in racer sheets in data/superkart-artwork.json. export function buildKartSheetCanvas(color, frame = 64) { const angles = 16; const canvas = makeCanvas(frame * (angles + 2), frame); const ctx = canvas.getContext('2d'); for (let i = 0; i < angles; i += 1) { drawKart(ctx, i * frame + frame / 2, frame / 2, (i * Math.PI * 2) / angles, color, frame, 0); } drawKart(ctx, 16 * frame + frame / 2, frame / 2, 0, color, frame, -0.35); drawKart(ctx, 17 * frame + frame / 2, frame / 2, 0, color, frame, 0.35); return canvas; } // A top-down kart drawn pointing "away from the viewer" at angle 0, then // rotated. lean tilts the body for the player's steering frames. function drawKart(ctx, cx, cy, angle, color, frame, lean) { const u = frame / 64; // proportions tuned at 64px ctx.save(); ctx.translate(cx, cy); ctx.rotate(angle + lean * 0.5); ctx.scale(u, u); // Shadow. ctx.fillStyle = 'rgba(0,0,0,0.3)'; ctx.beginPath(); ctx.ellipse(0, 4, 20, 16, 0, 0, Math.PI * 2); ctx.fill(); // Wheels (dark, slightly outside the body). ctx.fillStyle = '#181818'; for (const [wx, wy] of [[-15, -12], [15, -12], [-16, 12], [16, 12]]) { ctx.beginPath(); ctx.roundRect(wx - 5, wy - 7, 10, 14, 3); ctx.fill(); } // Body: tinted rounded shell, brighter nose (nose points up = away). const grad = ctx.createLinearGradient(0, 16, 0, -18); grad.addColorStop(0, color); grad.addColorStop(1, lighten(color, 0.35)); ctx.fillStyle = grad; ctx.beginPath(); ctx.moveTo(0, -20); ctx.quadraticCurveTo(14, -16, 13, 2); ctx.quadraticCurveTo(13, 16, 0, 17); ctx.quadraticCurveTo(-13, 16, -13, 2); ctx.quadraticCurveTo(-14, -16, 0, -20); ctx.closePath(); ctx.fill(); ctx.strokeStyle = 'rgba(0,0,0,0.45)'; ctx.lineWidth = 1.5; ctx.stroke(); // Driver helmet + rear bumper stripe. ctx.fillStyle = darken(color, 0.35); ctx.beginPath(); ctx.arc(0, 2, 6.5, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(255,255,255,0.75)'; ctx.beginPath(); ctx.arc(0, 0.5, 3, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#202020'; ctx.fillRect(-9, 13, 18, 3.5); ctx.restore(); } function lighten(hex, amt) { return shade(hex, amt); } function darken(hex, amt) { return shade(hex, -amt); } function shade(hex, amt) { const v = parseInt(hex.slice(1), 16); const ch = (sh) => { let c = (v >> sh) & 0xff; c = Math.round(amt >= 0 ? c + (255 - c) * amt : c * (1 + amt)); return Math.max(0, Math.min(255, c)); }; return `rgb(${ch(16)},${ch(8)},${ch(0)})`; } // Item sheet fallback: frames match data/superkart-artwork.json itemSheet // order — bolt, seeker, oil, turbo, overdrive, emp, coins, item box, coin. export function buildItemSheetCanvas(frame = 48) { const canvas = makeCanvas(frame * 9, frame); const ctx = canvas.getContext('2d'); const at = (i, fn) => { ctx.save(); ctx.translate(i * frame + frame / 2, frame / 2); fn(); ctx.restore(); }; const orb = (color) => { const g = ctx.createRadialGradient(-4, -4, 2, 0, 0, 16); g.addColorStop(0, '#ffffff'); g.addColorStop(0.35, color); g.addColorStop(1, darken(color, 0.5)); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(0, 0, 15, 0, Math.PI * 2); ctx.fill(); }; const boltGlyph = () => { ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.moveTo(2, -9); ctx.lineTo(-5, 2); ctx.lineTo(-1, 2); ctx.lineTo(-2, 9); ctx.lineTo(5, -2); ctx.lineTo(1, -2); ctx.closePath(); ctx.fill(); }; at(0, () => { orb('#38b048'); boltGlyph(); }); at(1, () => { orb('#d42a20'); boltGlyph(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(0, 0, 10, 0.6, 2.6); ctx.stroke(); }); at(2, () => { // oil slick ctx.fillStyle = '#141418'; ctx.beginPath(); ctx.ellipse(0, 2, 17, 12, 0.3, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(120,120,255,0.35)'; ctx.beginPath(); ctx.ellipse(-4, -1, 7, 4, -0.4, 0, Math.PI * 2); ctx.fill(); }); at(3, () => { // turbo cell ctx.fillStyle = '#ffd028'; ctx.beginPath(); ctx.roundRect(-8, -14, 16, 28, 6); ctx.fill(); ctx.strokeStyle = darken('#ffd028', 0.4); ctx.lineWidth = 2; ctx.stroke(); ctx.fillStyle = '#e04838'; boltGlyph(); }); at(4, () => { // overdrive star ctx.fillStyle = '#ffd028'; ctx.strokeStyle = '#a06a00'; ctx.lineWidth = 2; ctx.beginPath(); for (let i = 0; i < 10; i += 1) { const r = i % 2 === 0 ? 16 : 7; const a = -Math.PI / 2 + (i * Math.PI) / 5; const x = Math.cos(a) * r; const y = Math.sin(a) * r; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.closePath(); ctx.fill(); ctx.stroke(); }); at(5, () => { // emp burst orb('#2888e0'); ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; for (let i = 0; i < 6; i += 1) { const a = (i * Math.PI) / 3; ctx.beginPath(); ctx.moveTo(Math.cos(a) * 8, Math.sin(a) * 8); ctx.lineTo(Math.cos(a) * 17, Math.sin(a) * 17); ctx.stroke(); } }); const coin = (x, y, r) => { ctx.fillStyle = '#ffd028'; ctx.strokeStyle = '#a06a00'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.strokeStyle = 'rgba(160,106,0,0.8)'; ctx.beginPath(); ctx.arc(x, y, r * 0.55, 0, Math.PI * 2); ctx.stroke(); }; at(6, () => { coin(-6, 3, 10); coin(7, -4, 10); }); at(7, () => { // item box ctx.rotate(Math.PI / 8); const g = ctx.createLinearGradient(-14, -14, 14, 14); g.addColorStop(0, '#68d0ff'); g.addColorStop(0.5, '#b078ff'); g.addColorStop(1, '#ff78c8'); ctx.fillStyle = g; ctx.fillRect(-13, -13, 26, 26); ctx.strokeStyle = '#fff'; ctx.lineWidth = 2.5; ctx.strokeRect(-13, -13, 26, 26); ctx.rotate(-Math.PI / 8); ctx.fillStyle = '#fff'; ctx.font = 'bold 20px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('?', 0, 1); }); at(8, () => coin(0, 0, 12)); return canvas; } // Horizon backdrop fallback: sky gradient + two silhouette hill layers, // horizontally tileable (hills are built from a wrapped sine mix). export function buildBackdropCanvas(theme, w = 1024, h = 300) { const canvas = makeCanvas(w, h); const ctx = canvas.getContext('2d'); const sky = ctx.createLinearGradient(0, 0, 0, h); sky.addColorStop(0, theme.skyTop); sky.addColorStop(1, theme.skyBottom); ctx.fillStyle = sky; ctx.fillRect(0, 0, w, h); const hills = (baseY, amp, freqs, color) => { ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(0, h); for (let x = 0; x <= w; x += 4) { const t = (x / w) * Math.PI * 2; let y = baseY; freqs.forEach(([f, a], i) => { y -= Math.abs(Math.sin(t * f + i * 1.7)) * amp * a; }); ctx.lineTo(x, y); } ctx.lineTo(w, h); ctx.closePath(); ctx.fill(); }; hills(h * 0.85, h * 0.5, [[3, 0.6], [5, 0.4]], withAlpha(theme.skyTop, 0.55)); hills(h * 0.95, h * 0.35, [[4, 0.5], [7, 0.5]], withAlpha(darkenHexStr(theme.skyTop), 0.75)); return canvas; } function withAlpha(hex, a) { const v = parseInt(hex.slice(1), 16); return `rgba(${(v >> 16) & 255},${(v >> 8) & 255},${v & 255},${a})`; } function darkenHexStr(hex) { const v = parseInt(hex.slice(1), 16); const f = (sh) => Math.round(((v >> sh) & 255) * 0.55); return `#${((f(16) << 16) | (f(8) << 8) | f(0)).toString(16).padStart(6, '0')}`; } // Theme decor fallback: one 8-frame 64×64 strip of simple silhouettes so // tracks have roadside objects before real theme sheets are painted. export function buildDecorSheetCanvas(theme, frame = 64) { const canvas = makeCanvas(frame * 8, frame); const ctx = canvas.getContext('2d'); const dark = darkenHexStr(theme.skyTop); for (let i = 0; i < 8; i += 1) { ctx.save(); ctx.translate(i * frame + frame / 2, frame); ctx.fillStyle = i % 2 === 0 ? dark : withAlpha(theme.terrainSpeckle ?? theme.terrain, 1); ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 2; ctx.beginPath(); if (i % 3 === 0) { // tree-ish ctx.moveTo(-4, 0); ctx.lineTo(-4, -18); ctx.lineTo(4, -18); ctx.lineTo(4, 0); ctx.closePath(); ctx.fill(); ctx.beginPath(); ctx.arc(0, -34, 18, 0, Math.PI * 2); ctx.fill(); } else if (i % 3 === 1) { // boulder / mound ctx.moveTo(-24, 0); ctx.quadraticCurveTo(-18, -34, 2, -30); ctx.quadraticCurveTo(24, -26, 22, 0); ctx.closePath(); ctx.fill(); ctx.stroke(); } else { // post / tower ctx.fillRect(-7, -52, 14, 52); ctx.fillRect(-12, -58, 24, 10); } ctx.restore(); } return canvas; }