barkour/js/render.js
w1n5t0n f57c1f16f8 feat: top-down 'Dogpit' arena mode (dota-like view)
Second game mode selectable at dog select (host-authoritative online):
- js/sim_arena.js: top-down sim, same export surface as sim.js — x/y plane
  plus a z hop axis (Space / comma); KOs by knocking dogs into abyss pits
- js/mapgen_arena.js: mirrored symmetric arenas — walls, pits, center
  clearing, guaranteed spawn-to-center corridors, crystals/spikes/launch
  pads, mirrored saw pairs; fallback pit pair since pits are the only KO
  mechanic
- specials re-imagined per dog: zoom dash, ram + hop-slam, zip-leash
  slingshot, blink, hover (floats over pits/saws/spikes)
- render: arena tiles with wall shadows + void pits, top-down dog drawing
  with z-lift over a ground shadow; new fall event/sfx
- B.HOP input bit so 'up' can mean north in top-down (Space also jumps in
  side view); mode toggle UI + mode carried in start/rematch net messages

Tested: arena mapgen determinism/symmetry/connectivity + chaotic matches +
pit/hop scenarios headless; local chaos + full P2P arena match (host mode
flip syncs to client) in two headless browsers with zero page errors.
2026-06-10 15:23:22 +02:00

780 lines
27 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, game.kind);
}
function pal(game) { return PALETTES[game.map.pal % PALETTES.length]; }
// --- static background, cached to an offscreen canvas ---
function buildBackground(map, kind) {
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);
if (kind === 'arena') { // top-down: tinted floor with mottled patches + vignette
b.fillStyle = p.hill;
b.fillRect(0, 0, W, H);
for (let i = 0; i < 260; i++) {
const x = r() * W, y = r() * H, s = 6 + r() * 22;
b.fillStyle = r() < 0.5 ? 'rgba(255,255,255,0.025)' : 'rgba(0,0,0,0.05)';
b.fillRect(x, y, s, s);
}
const vg = b.createRadialGradient(W / 2, H / 2, H * 0.45, W / 2, H / 2, H * 0.95);
vg.addColorStop(0, 'rgba(0,0,0,0)');
vg.addColorStop(1, 'rgba(0,0,0,0.45)');
b.fillStyle = vg;
b.fillRect(0, 0, W, H);
return;
}
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 'fall': burst(ev.x, ev.y, 14, '#263238', 2.2, 26, -0.04); shake = Math.max(shake, 3); 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);
const arena = game.kind === 'arena';
if (!bgCanvas || bgSeed !== game.map.seed) buildBackground(game.map, game.kind);
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);
if (arena) drawTilesArena(game, p); else drawTiles(game, p);
drawSaws(game);
drawEnts(game);
drawGhosts();
for (const pl of game.players) (arena ? drawDogTop : 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();
}
}
}
}
// top-down arena tiles: walls with drop shadows, void pits, caltrops, pads
function drawTilesArena(game, p) {
const tiles = game.map.tiles;
// shadow pass so walls read as raised
ctx.fillStyle = 'rgba(0,0,0,0.3)';
for (let y = 0; y < GH; y++) for (let x = 0; x < GW; x++) {
const t = tiles[y * GW + x];
if (t === T.SOLID || t === T.CRYSTAL) ctx.fillRect(x * TILE + 3, y * TILE + 4, TILE, TILE);
}
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.SOLID) {
ctx.fillStyle = p.top;
ctx.fillRect(px, py, TILE, 3);
}
} else if (t === T.PIT) {
ctx.fillStyle = '#04060a';
ctx.fillRect(px, py, TILE, TILE);
if (y > 0 && tiles[(y - 1) * GW + x] !== T.PIT) { // rim on the near edge
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(px, py, TILE, 4);
}
} else if (t === T.SPIKE) {
ctx.fillStyle = p.spike;
for (const [ox, oy] of [[3, 3], [9, 5], [5, 9]]) {
ctx.beginPath();
ctx.moveTo(px + ox - 2.5, py + oy + 2);
ctx.lineTo(px + ox, py + oy - 3);
ctx.lineTo(px + ox + 2.5, py + oy + 2);
ctx.fill();
}
} else if (t === T.PADTOP) {
const pulse = 0.5 + 0.5 * Math.sin(frame * 0.12 + x + y);
ctx.strokeStyle = p.pad;
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(px + 6, py + 6, 4 + pulse * 1.5, 0, 7); ctx.stroke();
ctx.fillStyle = p.pad;
ctx.beginPath();
ctx.moveTo(px + 3.5, py + 7.5); ctx.lineTo(px + 6, py + 3.5); ctx.lineTo(px + 8.5, py + 7.5);
ctx.fill();
} 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) {
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);
}
// top-down dog: shadow at ground level, body lifted by hop height, rotated to
// the facing angle. Same per-character visual params as the side view.
function drawDogTop(game, pl, opts) {
if (pl.stocks <= 0 || pl.respawn > 0) return;
const c = CHARS[pl.charId];
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;
const z = pl.z || 0;
const falling = pl.falling || 0;
if (Math.hypot(pl.vx, pl.vy) > 5.5 && frame % 2 === 0) {
ghosts.push({ x, y, ttl: 10, c: c.color, kind: 'blob' });
}
// leash
const an = pl.st && pl.st.anchor;
if (an && !falling) {
ctx.strokeStyle = c.accent;
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(x, y - z * 1.8); 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();
}
// shadow stays on the floor
if (!falling) {
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.beginPath();
ctx.ellipse(x, y + 3, Math.max(3, 10 - z * 0.8), Math.max(2, 5 - z * 0.4), 0, 0, 7);
ctx.fill();
}
// hover flame between shadow and dog
if (pl.st && (pl.st.hover || pl.st.jet) && !falling) {
ctx.fillStyle = '#ffab40';
ctx.beginPath();
ctx.ellipse(x, y + 1 - z * 0.9, 3, 5 + Math.random() * 2, 0, 0, 7);
ctx.fill();
}
ctx.save();
ctx.translate(x, y - z * 1.8);
if (pl.invuln > 0 && (frame >> 2) % 2 === 0) ctx.globalAlpha = 0.45;
if (falling) { // spiral down the pit
const f = falling / 36;
ctx.globalAlpha = f;
ctx.scale(f, f);
ctx.rotate((36 - falling) * 0.35);
}
ctx.rotate(pl.ang !== undefined ? pl.ang : (pl.face < 0 ? Math.PI : 0));
if (pl.hitstun > 2) ctx.rotate(Math.sin(frame * 0.8) * 0.3);
const bodyL = 10 * c.body, bodyW = 6.5 * c.body;
const moving = Math.hypot(pl.vx, pl.vy) > 0.6 && z <= 0;
const phase = (x + y) * 0.45;
// tail (behind, wags sideways)
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';
const tl = c.tail === 'stub' ? 3 : c.tail === 'whip' ? 10 : 7;
ctx.beginPath();
ctx.moveTo(-bodyL + 1, 0);
ctx.quadraticCurveTo(-bodyL - tl * 0.6, wag * 0.5, -bodyL - tl, wag);
ctx.stroke();
// paws poking out the sides
ctx.fillStyle = c.accent;
for (let i = 0; i < 4; i++) {
const lx = -bodyL * 0.55 + (i >> 1) * bodyL * 0.9;
const ly = (i % 2 ? 1 : -1) * (bodyW + 1);
const swing = moving ? Math.sin(phase + i * 1.7) * 2.5 : 0;
ctx.beginPath(); ctx.arc(lx + swing, ly, 1.8, 0, 7); ctx.fill();
}
// jetpack on the back (corgi)
if (c.key === 'rocket') {
ctx.fillStyle = '#90a4ae';
ctx.fillRect(-bodyL * 0.5 - 2, -3, 5, 6);
}
// body + head + snout
ctx.fillStyle = c.color;
ctx.beginPath(); ctx.ellipse(0, 0, bodyL, bodyW, 0, 0, 7); ctx.fill();
if (pl.st && pl.st.charge) { ctx.strokeStyle = '#ff7043'; ctx.lineWidth = 2; ctx.stroke(); }
const hx = bodyL * 0.85, hr = 5 * c.body;
ctx.beginPath(); ctx.arc(hx, 0, hr, 0, 7); ctx.fill();
ctx.beginPath(); ctx.ellipse(hx + hr * 0.8, 0, hr * 0.65 * c.snout, hr * 0.45, 0, 0, 7); ctx.fill();
ctx.fillStyle = c.accent;
ctx.beginPath(); ctx.arc(hx + hr * 0.8 + hr * 0.65 * c.snout, 0, 1.5, 0, 7); ctx.fill();
// ears either side of the head
ctx.fillStyle = c.accent;
for (const side of [-1, 1]) {
if (c.ears === 'silk') {
ctx.beginPath(); ctx.ellipse(hx - 2, side * (hr + 1), 4, 2, side * 0.4, 0, 7); ctx.fill();
} else if (c.ears === 'nub') {
ctx.beginPath(); ctx.arc(hx - 1, side * hr, 1.8, 0, 7); ctx.fill();
} else {
ctx.beginPath();
ctx.moveTo(hx - 3, side * (hr - 1));
ctx.lineTo(hx - 1, side * (hr + 4));
ctx.lineTo(hx + 2, side * (hr - 1));
ctx.fill();
}
}
// bite arc
if (pl.atkT > 0) {
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(hx + hr * c.snout + 4, 0, 5, -0.7, 0.7);
ctx.stroke();
}
ctx.restore();
if (falling) return; // no bubble/tag while disappearing into the void
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 - z * 1.8, 17, 0, 7); ctx.stroke();
}
if (pl.boost > 0 && frame % 3 === 0) spawn(x, y + 2, (Math.random() - 0.5) * 2, 0.5, 12, '#ffd740', 1.5, 0);
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 - z * 1.8 - 27, 44, 11);
ctx.fillStyle = c.color;
ctx.fillText(pl.name, x - 6, y - z * 1.8 - 18.5);
ctx.fillStyle = `rgb(255,${Math.round(255 - heat * 200)},${Math.round(255 - heat * 255)})`;
ctx.fillText(`${dmg}%`, x + 13, y - z * 1.8 - 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);
}