5 dogs with distinct movement physics, seeded procedural arenas with traps and mineable powerups, smash-style knockback/stocks combat, local 2P and P2P netplay (PeerJS room keys), procedural art and sfx, headless sim smoke tests.
548 lines
19 KiB
JavaScript
548 lines
19 KiB
JavaScript
// 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);
|
|
}
|