fertig-classic-games/src/games/superkart/SuperKartRaster.js

550 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
}