// 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); ctx.textBaseline = 'alphabetic'; // don't leak 'middle' into HUD/banner text } 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); }