550 lines
18 KiB
JavaScript
550 lines
18 KiB
JavaScript
// 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;
|
||
}
|