barkour/js/render.js

549 lines
19 KiB
JavaScript
Raw Normal View History

// All drawing. Procedural everything: dogs are canvas paths, the background is
// seeded decor, effects are a small particle pool fed by sim events.
import { TILE, GW, GH, W, H, T } from './consts.js';
import { CHARS } from './characters.js';
import { PALETTES } from './mapgen.js';
import { mulberry32 } from './rng.js';
let ctx = null, cv = null;
let bgCanvas = null, bgSeed = null;
let particles = [];
let ghosts = []; // afterimages
let shake = 0;
let banners = []; // floating text
let frame = 0;
export function initRender(canvas) {
cv = canvas;
ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
}
export function resetRender(game) {
particles = []; ghosts = []; banners = []; shake = 0;
buildBackground(game.map);
}
function pal(game) { return PALETTES[game.map.pal % PALETTES.length]; }
// --- static background, cached to an offscreen canvas ---
function buildBackground(map) {
bgSeed = map.seed;
bgCanvas = document.createElement('canvas');
bgCanvas.width = W; bgCanvas.height = H;
const b = bgCanvas.getContext('2d');
const p = PALETTES[map.pal % PALETTES.length];
const r = mulberry32((map.seed ^ 0xBA9D) >>> 0);
const grad = b.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, p.sky[0]); grad.addColorStop(1, p.sky[1]);
b.fillStyle = grad;
b.fillRect(0, 0, W, H);
if (p.star) {
b.fillStyle = 'rgba(255,255,255,0.7)';
for (let i = 0; i < 80; i++) {
const x = r() * W, y = r() * H * 0.7, s = r() < 0.15 ? 2 : 1;
b.fillRect(x, y, s, s);
}
}
// moon
b.fillStyle = 'rgba(255,255,240,0.85)';
b.beginPath();
b.arc(W * (0.15 + r() * 0.7), 60 + r() * 50, 22, 0, 7);
b.fill();
// hills silhouette
b.fillStyle = p.hill;
b.beginPath();
b.moveTo(0, H);
let y = H * 0.65 + r() * 40;
for (let x = 0; x <= W; x += 40) {
y += (r() - 0.5) * 50;
y = Math.max(H * 0.45, Math.min(H * 0.85, y));
b.lineTo(x, y);
}
b.lineTo(W, H);
b.fill();
}
// --- particles & event reactions ----------------------------------------
function spawn(x, y, vx, vy, ttl, c, r = 2, grav = 0.15) {
if (particles.length > 400) particles.shift();
particles.push({ x, y, vx, vy, ttl, ttl0: ttl, c, r, grav });
}
function burst(x, y, n, c, spd = 3, ttl = 25, grav = 0.1) {
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2, s = spd * (0.3 + Math.random() * 0.7);
spawn(x, y, Math.cos(a) * s, Math.sin(a) * s, ttl * (0.6 + Math.random() * 0.6), c, 1 + Math.random() * 2, grav);
}
}
export function fxEvents(events, game) {
const p = pal(game);
for (const ev of events) {
switch (ev.t) {
case 'jump': burst(ev.x, ev.y + 7, 5, '#ffffff55', 1.5, 14, 0.02); break;
case 'land': burst(ev.x, ev.y, Math.min(10, ev.v | 0), '#ffffff44', 2, 16, 0.02); break;
case 'dash': burst(ev.x, ev.y, 8, '#9be7ff', 2.5, 18, 0); break;
case 'hit': burst(ev.x, ev.y, 12, '#ffd54f', 3.5, 22); shake = Math.max(shake, Math.min(8, 2 + ev.m * 0.5)); break;
case 'block': burst(ev.x, ev.y, 8, '#80d8ff', 2, 18, 0); break;
case 'spike': burst(ev.x, ev.y, 10, '#ef5350', 3, 20); shake = Math.max(shake, 3); break;
case 'saw': burst(ev.x, ev.y, 12, '#ef5350', 3.5, 22); shake = Math.max(shake, 4); break;
case 'pad': burst(ev.x, ev.y, 8, p.pad, 2.5, 18, 0.02); break;
case 'crack': burst(ev.x, ev.y, 5, p.crystal, 2, 16); break;
case 'tile': burst(ev.x, ev.y, 14, p.crystal, 3, 26); break;
case 'pickup': burst(ev.x, ev.y, 10, '#fff59d', 2, 20, -0.02); break;
case 'boom': burst(ev.x, ev.y, 30, '#ffab40', 5, 32); burst(ev.x, ev.y, 16, '#eceff1', 3, 40); shake = Math.max(shake, 9); break;
case 'grapple': burst(ev.x, ev.y, 5, '#aed581', 1.5, 12, 0); break;
case 'blink':
burst(ev.x, ev.y, 8, '#ce93d8', 2, 18, 0);
burst(ev.x2, ev.y2, 8, '#ce93d8', 2, 18, 0);
ghosts.push({ x: ev.x, y: ev.y, x2: ev.x2, y2: ev.y2, ttl: 12, kind: 'blinkline' });
break;
case 'jet': spawn(ev.x, ev.y, (Math.random() - 0.5), 2 + Math.random() * 1.5, 14, '#ffab40', 2.5, -0.02); break;
case 'boing': burst(ev.x, ev.y + 6, 8, '#fff176', 2.5, 18); break;
case 'charge': burst(ev.x, ev.y + 5, 6, '#ffccbc', 2, 14); break;
case 'thud': burst(ev.x, ev.y, 8, '#ffccbc', 2.5, 16); shake = Math.max(shake, 3); break;
case 'pound': burst(ev.x, ev.y + 6, 18, '#ffccbc', 4, 26); shake = Math.max(shake, 6); break;
case 'ko': {
burst(ev.x, ev.y, 26, '#ff5252', 5, 36);
const who = game.players[ev.idx];
banners.push({ text: `${who ? who.name : '???'} KO'd!`, ttl: 90, y: H * 0.3 });
shake = Math.max(shake, 8);
break;
}
case 'respawn': burst(ev.x, ev.y, 12, '#b2ff59', 2, 24, -0.02); break;
case 'heal': burst(ev.x, ev.y, 10, '#69f0ae', 1.5, 24, -0.05); break;
case 'shield': burst(ev.x, ev.y, 10, '#80d8ff', 2, 20, 0); break;
case 'boost': burst(ev.x, ev.y, 10, '#ffd740', 2, 20, 0); break;
case 'drop': banners.push({ text: 'Care package!', ttl: 70, y: 60 }); break;
case 'go': banners.push({ text: 'GO!', ttl: 45, y: H * 0.4, big: true }); break;
}
}
}
// --- main render --------------------------------------------------------
export function render(game, opts = {}) {
frame++;
const p = pal(game);
if (!bgCanvas || bgSeed !== game.map.seed) buildBackground(game.map);
ctx.save();
ctx.clearRect(0, 0, W, H);
if (shake > 0.3) {
ctx.translate((Math.random() - 0.5) * shake, (Math.random() - 0.5) * shake);
shake *= 0.85;
} else shake = 0;
ctx.drawImage(bgCanvas, 0, 0);
drawTiles(game, p);
drawSaws(game);
drawEnts(game);
drawGhosts();
for (const pl of game.players) drawDog(game, pl, opts);
drawParticles();
drawHud(game);
drawBanners(game);
ctx.restore();
}
function drawTiles(game, p) {
const tiles = game.map.tiles;
for (let y = 0; y < GH; y++) for (let x = 0; x < GW; x++) {
const t = tiles[y * GW + x];
if (t === T.EMPTY) continue;
const px = x * TILE, py = y * TILE;
if (t === T.SOLID) {
ctx.fillStyle = p.solid;
ctx.fillRect(px, py, TILE, TILE);
if (y === 0 || tiles[(y - 1) * GW + x] === T.EMPTY || tiles[(y - 1) * GW + x] === T.SPIKE) {
ctx.fillStyle = p.top;
ctx.fillRect(px, py, TILE, 3);
}
} else if (t === T.PLAT) {
ctx.fillStyle = p.plat;
ctx.fillRect(px, py, TILE, 4);
ctx.fillStyle = 'rgba(0,0,0,0.25)';
ctx.fillRect(px, py + 3, TILE, 1);
} else if (t === T.SPIKE) {
ctx.fillStyle = p.spike;
ctx.beginPath();
ctx.moveTo(px, py + TILE);
ctx.lineTo(px + TILE / 4, py + 2);
ctx.lineTo(px + TILE / 2, py + TILE);
ctx.lineTo(px + TILE * 0.75, py + 2);
ctx.lineTo(px + TILE, py + TILE);
ctx.fill();
} else if (t === T.PAD) {
ctx.fillStyle = p.pad;
const sq = Math.sin(frame * 0.15 + x) * 1.5;
ctx.fillRect(px, py + 4 + sq, TILE, TILE - 4 - sq);
ctx.fillStyle = '#ffffffaa';
ctx.fillRect(px + 2, py + 4 + sq, TILE - 4, 2);
} else if (t === T.CRYSTAL) {
const hp = game.map.hp[y * GW + x] ?? 3;
ctx.fillStyle = p.crystal;
ctx.beginPath();
ctx.moveTo(px + TILE / 2, py);
ctx.lineTo(px + TILE, py + TILE / 2);
ctx.lineTo(px + TILE / 2, py + TILE);
ctx.lineTo(px, py + TILE / 2);
ctx.fill();
const tw = 0.5 + 0.5 * Math.sin(frame * 0.1 + x * 3 + y);
ctx.fillStyle = `rgba(255,255,255,${0.3 + tw * 0.4})`;
ctx.fillRect(px + 4, py + 3, 2, 2);
if (hp < 3) { // cracks
ctx.strokeStyle = 'rgba(0,0,0,0.6)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(px + 3, py + 4); ctx.lineTo(px + 7, py + 8);
if (hp < 2) { ctx.moveTo(px + 9, py + 3); ctx.lineTo(px + 5, py + 9); }
ctx.stroke();
}
}
}
}
function drawSaws(game) {
for (const s of game.map.saws) {
if (s.x === undefined) continue;
ctx.save();
ctx.translate(s.x, s.y);
ctx.rotate(frame * 0.3);
ctx.fillStyle = '#b0bec5';
ctx.beginPath();
for (let i = 0; i < 8; i++) {
const a = i / 8 * Math.PI * 2;
ctx.lineTo(Math.cos(a) * 13, Math.sin(a) * 13);
ctx.lineTo(Math.cos(a + 0.25) * 8, Math.sin(a + 0.25) * 8);
}
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#546e7a';
ctx.beginPath(); ctx.arc(0, 0, 4, 0, 7); ctx.fill();
ctx.restore();
}
}
const ITEM_COLORS = { ball: '#cddc39', bomb: '#37474f', shield: '#80d8ff', boost: '#ffd740', heal: '#69f0ae' };
function drawItemIcon(x, y, item, r = 5) {
ctx.fillStyle = ITEM_COLORS[item] || '#fff';
ctx.beginPath(); ctx.arc(x, y, r, 0, 7); ctx.fill();
ctx.fillStyle = '#000';
ctx.font = `bold ${r + 3}px monospace`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const ch = { ball: '●', bomb: '✶', shield: '◍', boost: '»', heal: '+' }[item] || '?';
ctx.fillStyle = item === 'bomb' ? '#ff8a65' : '#00000088';
ctx.fillText(ch, x, y + 0.5);
}
function drawEnts(game) {
for (const e of game.ents) {
if (e.k === 'pickup') {
const bob = Math.sin(frame * 0.1 + e.id) * 2;
drawItemIcon(e.x, e.y + bob, e.item, 6);
ctx.strokeStyle = '#ffffff66';
ctx.beginPath(); ctx.arc(e.x, e.y + bob, 8 + Math.sin(frame * 0.2) * 1.5, 0, 7); ctx.stroke();
} else if (e.k === 'ball') {
ctx.fillStyle = '#cddc39';
ctx.beginPath(); ctx.arc(e.x, e.y, 4, 0, 7); ctx.fill();
ctx.strokeStyle = '#9e9d24';
ctx.beginPath(); ctx.arc(e.x, e.y, 4, 0.5, 2.2); ctx.stroke();
} else if (e.k === 'bomb') {
const flash = e.fuse < 30 && (frame >> 2) % 2 === 0;
ctx.fillStyle = flash ? '#ef5350' : '#37474f';
ctx.beginPath(); ctx.arc(e.x, e.y, 5, 0, 7); ctx.fill();
ctx.strokeStyle = '#ffab40';
ctx.beginPath(); ctx.moveTo(e.x, e.y - 5); ctx.lineTo(e.x + 3, e.y - 9); ctx.stroke();
}
}
}
function drawGhosts() {
for (const gh of ghosts) {
gh.ttl--;
const a = gh.ttl / 12;
if (gh.kind === 'blinkline') {
ctx.strokeStyle = `rgba(206,147,216,${a * 0.7})`;
ctx.lineWidth = 2;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(gh.x, gh.y); ctx.lineTo(gh.x2, gh.y2); ctx.stroke();
ctx.setLineDash([]);
} else {
ctx.globalAlpha = a * 0.35;
ctx.fillStyle = gh.c;
ctx.beginPath(); ctx.ellipse(gh.x, gh.y, 11, 7, 0, 0, 7); ctx.fill();
ctx.globalAlpha = 1;
}
}
ghosts = ghosts.filter(g => g.ttl > 0);
}
// --- the dogs -------------------------------------------------------------
function drawDog(game, pl, opts) {
if (pl.stocks <= 0 || pl.respawn > 0) return;
const c = CHARS[pl.charId];
// motion smoothing for netplay snapshots
if (opts.smooth) {
if (pl._dx === undefined || Math.abs(pl._dx - pl.x) > 80 || Math.abs(pl._dy - pl.y) > 80) { pl._dx = pl.x; pl._dy = pl.y; }
pl._dx += (pl.x - pl._dx) * 0.5;
pl._dy += (pl.y - pl._dy) * 0.5;
} else { pl._dx = pl.x; pl._dy = pl.y; }
const x = pl._dx, y = pl._dy;
// speed afterimages
if (Math.hypot(pl.vx, pl.vy) > 5.5 && frame % 2 === 0) {
ghosts.push({ x, y, ttl: 10, c: c.color, kind: 'blob' });
}
// grapple rope
const an = pl.st && pl.st.anchor;
if (an) {
ctx.strokeStyle = c.accent;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(x + pl.face * 8, y - 2);
ctx.lineTo(an.x, an.y);
ctx.stroke();
ctx.fillStyle = c.color;
ctx.beginPath(); ctx.arc(an.x, an.y, 3, 0, 7); ctx.fill();
}
ctx.save();
ctx.translate(x, y);
if (pl.invuln > 0 && (frame >> 2) % 2 === 0) ctx.globalAlpha = 0.45;
if (pl.hitstun > 2) ctx.rotate(Math.sin(frame * 0.8) * 0.3);
if (pl.st && pl.st.pound) ctx.rotate(pl.face * 0.5);
ctx.scale(pl.face, 1);
const squash = pl.landT > 0 ? 1 - pl.landT * 0.03 : 1;
const stretch = !pl.onGround && Math.abs(pl.vy) > 5 ? 1.12 : 1;
ctx.scale(1, squash * stretch);
const running = pl.onGround && Math.abs(pl.vx) > 0.6;
const phase = (x * 0.45);
const bodyL = 11 * c.body, bodyH = 6.5 * c.body;
// tail
const wag = Math.sin(frame * 0.3) * 3;
ctx.strokeStyle = c.color;
ctx.lineWidth = c.tail === 'fluff' || c.tail === 'plume' ? 4 : 2;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(-bodyL + 1, -2);
const tl = c.tail === 'stub' ? 3 : c.tail === 'whip' ? 10 : 7;
ctx.quadraticCurveTo(-bodyL - tl * 0.6, -6, -bodyL - tl, -4 + wag);
ctx.stroke();
// legs
ctx.strokeStyle = c.accent;
ctx.lineWidth = 2.5;
const legLen = c.key === 'rocket' ? 3.5 : 6;
for (let i = 0; i < 4; i++) {
const lx = -bodyL * 0.6 + i * (bodyL * 0.42);
const swing = running ? Math.sin(phase + i * 1.7) * 3 : 0;
const lift = !pl.onGround ? -2 : 0;
ctx.beginPath();
ctx.moveTo(lx, bodyH - 2);
ctx.lineTo(lx + swing, bodyH + legLen + lift);
ctx.stroke();
}
// jetpack (corgi)
if (c.key === 'rocket') {
ctx.fillStyle = '#90a4ae';
ctx.fillRect(-bodyL * 0.5 - 3, -bodyH - 4, 6, 8);
if (pl.st && pl.st.jet) {
ctx.fillStyle = '#ffab40';
ctx.beginPath();
ctx.moveTo(-bodyL * 0.5 - 2, -bodyH + 4);
ctx.lineTo(-bodyL * 0.5, -bodyH + 10 + Math.random() * 4);
ctx.lineTo(-bodyL * 0.5 + 2, -bodyH + 4);
ctx.fill();
}
}
// body
ctx.fillStyle = c.color;
ctx.beginPath();
ctx.ellipse(0, 0, bodyL, bodyH, 0, 0, 7);
ctx.fill();
// charge glow
if (pl.st && pl.st.charge) {
ctx.strokeStyle = '#ff7043';
ctx.lineWidth = 2;
ctx.stroke();
}
// head
const hx = bodyL * 0.75, hy = -bodyH * 0.8;
const hr = 5.5 * c.body;
ctx.beginPath(); ctx.arc(hx, hy, hr, 0, 7); ctx.fill();
// snout
ctx.beginPath();
ctx.ellipse(hx + hr * 0.8, hy + 1, hr * 0.7 * c.snout, hr * 0.45, 0, 0, 7);
ctx.fill();
ctx.fillStyle = c.accent;
ctx.beginPath(); ctx.arc(hx + hr * 0.8 + hr * 0.7 * c.snout, hy + 0.5, 1.5, 0, 7); ctx.fill();
// ears
ctx.fillStyle = c.accent;
if (c.ears === 'point') {
ctx.beginPath(); ctx.moveTo(hx - 3, hy - hr + 1); ctx.lineTo(hx - 1, hy - hr - 6); ctx.lineTo(hx + 2, hy - hr + 2); ctx.fill();
ctx.beginPath(); ctx.moveTo(hx + 2, hy - hr + 1); ctx.lineTo(hx + 4, hy - hr - 5); ctx.lineTo(hx + 6, hy - hr + 2); ctx.fill();
} else if (c.ears === 'fold') {
ctx.beginPath(); ctx.ellipse(hx - 2, hy - hr + 1, 3, 1.8, -0.6, 0, 7); ctx.fill();
} else if (c.ears === 'nub') {
ctx.beginPath(); ctx.arc(hx - 2, hy - hr + 1, 2, 0, 7); ctx.fill();
ctx.beginPath(); ctx.arc(hx + 3, hy - hr + 1, 2, 0, 7); ctx.fill();
} else if (c.ears === 'silk') {
ctx.beginPath(); ctx.ellipse(hx - 3, hy + 2, 2.2, 6, 0.3, 0, 7); ctx.fill();
}
// eye (X when stunned)
if (pl.hitstun > 2) {
ctx.strokeStyle = '#1a1a1a'; ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(hx - 1, hy - 3); ctx.lineTo(hx + 3, hy + 1);
ctx.moveTo(hx + 3, hy - 3); ctx.lineTo(hx - 1, hy + 1);
ctx.stroke();
} else {
ctx.fillStyle = '#1a1a1a';
ctx.beginPath(); ctx.arc(hx + 1, hy - 1.5, 1.6, 0, 7); ctx.fill();
}
// open mouth during attack
if (pl.atkT > 0) {
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(hx + hr * c.snout + 4, hy + 1, 5, -0.7, 0.7);
ctx.stroke();
}
ctx.restore();
// shield bubble
if (pl.shield > 0) {
ctx.strokeStyle = `rgba(128,216,255,${0.4 + 0.3 * Math.sin(frame * 0.2)})`;
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(x, y, 17, 0, 7); ctx.stroke();
}
// boost sparkles
if (pl.boost > 0 && frame % 3 === 0) spawn(x - pl.face * 8, y, -pl.face, -0.5, 12, '#ffd740', 1.5, 0);
// name + damage tag
ctx.font = 'bold 9px monospace';
ctx.textAlign = 'center';
const dmg = Math.round(pl.dmg);
const heat = Math.min(1, dmg / 120);
ctx.fillStyle = '#00000088';
ctx.fillRect(x - 22, y - 27, 44, 11);
ctx.fillStyle = c.color;
ctx.fillText(pl.name, x - 6, y - 18.5);
ctx.fillStyle = `rgb(255,${Math.round(255 - heat * 200)},${Math.round(255 - heat * 255)})`;
ctx.fillText(`${dmg}%`, x + 13, y - 18.5);
}
function drawParticles() {
for (const pt of particles) {
pt.ttl--;
pt.x += pt.vx; pt.y += pt.vy;
pt.vy += pt.grav;
ctx.globalAlpha = Math.max(0, pt.ttl / pt.ttl0);
ctx.fillStyle = pt.c;
ctx.fillRect(pt.x - pt.r / 2, pt.y - pt.r / 2, pt.r, pt.r);
}
ctx.globalAlpha = 1;
particles = particles.filter(p => p.ttl > 0);
}
function drawHud(game) {
game.players.forEach((pl, i) => {
const c = CHARS[pl.charId];
const left = i === 0;
const x = left ? 12 : W - 152;
ctx.fillStyle = 'rgba(0,0,0,0.45)';
ctx.fillRect(x, 8, 140, 34);
ctx.fillStyle = c.color;
ctx.fillRect(x, 8, 4, 34);
ctx.font = 'bold 11px monospace';
ctx.textAlign = 'left';
ctx.fillText(`${pl.name}`, x + 10, 21);
// stocks as paws
for (let s = 0; s < pl.stocks; s++) {
ctx.beginPath(); ctx.arc(x + 14 + s * 12, 32, 3.5, 0, 7); ctx.fill();
ctx.beginPath(); ctx.arc(x + 10 + s * 12, 28, 1.6, 0, 7); ctx.fill();
ctx.beginPath(); ctx.arc(x + 14 + s * 12, 26.5, 1.6, 0, 7); ctx.fill();
ctx.beginPath(); ctx.arc(x + 18 + s * 12, 28, 1.6, 0, 7); ctx.fill();
}
// damage
const dmg = Math.round(pl.dmg);
const heat = Math.min(1, dmg / 120);
ctx.fillStyle = `rgb(255,${Math.round(255 - heat * 200)},${Math.round(255 - heat * 255)})`;
ctx.font = 'bold 14px monospace';
ctx.fillText(`${dmg}%`, x + 72, 34);
// item slot
if (pl.item) drawItemIcon(x + 122, 28, pl.item, 7);
// special meter
const st = pl.st || {};
let meter = null;
if (c.key === 'rocket') meter = (st.fuel ?? 70) / 70;
else if (c.key === 'wisp') meter = 1 - (st.blinkCd || 0) / 48;
else if (c.key === 'tank') meter = 1 - (st.chargeCd || 0) / 80;
else if (c.key === 'bolt') meter = st.dash || pl.onGround ? 1 : 0;
if (meter !== null) {
ctx.fillStyle = '#00000066';
ctx.fillRect(x + 100, 12, 36, 5);
ctx.fillStyle = meter >= 1 ? '#69f0ae' : '#ffd740';
ctx.fillRect(x + 100, 12, 36 * Math.max(0, Math.min(1, meter)), 5);
}
});
}
function drawBanners(game) {
// countdown
if (game.freeze > 0 && !game.over) {
const n = Math.ceil(game.freeze / 60);
ctx.font = 'bold 64px monospace';
ctx.textAlign = 'center';
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000aa';
ctx.lineWidth = 6;
const scale = 1 + (game.freeze % 60) / 120;
ctx.save();
ctx.translate(W / 2, H * 0.4);
ctx.scale(scale, scale);
ctx.strokeText(String(n), 0, 0);
ctx.fillText(String(n), 0, 0);
ctx.restore();
}
// victory
if (game.over) {
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.fillRect(0, H * 0.3, W, 90);
ctx.font = 'bold 36px monospace';
ctx.textAlign = 'center';
ctx.fillStyle = '#ffd740';
const w = game.over.winner;
const txt = w >= 0 ? `${game.players[w].name} WINS!` : 'DOUBLE KO!';
ctx.fillText(txt, W / 2, H * 0.3 + 55);
}
for (const b of banners) {
b.ttl--;
ctx.font = `bold ${b.big ? 48 : 20}px monospace`;
ctx.textAlign = 'center';
ctx.globalAlpha = Math.min(1, b.ttl / 20);
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000aa';
ctx.lineWidth = 4;
ctx.strokeText(b.text, W / 2, b.y);
ctx.fillText(b.text, W / 2, b.y);
ctx.globalAlpha = 1;
}
banners = banners.filter(b => b.ttl > 0);
}