// Canvas renderer. Reads game state every frame; never mutates it. // Glowing rings are pre-rendered sprites (shadowBlur is far too slow to // run per frame on phones), scaled with drawImage. import { TIMING } from './game.js'; import { SHAPES } from './themes.js'; import { TEAM_NAMES } from './modes.js'; import { createReveal, createRipple, clamp01, easeOutCubic } from './reveals.js'; const TAU = Math.PI * 2; const SPRITE_SCALE = 1.7; // sprites are drawn at the largest ring size, then scaled down function easeOutBack(t) { const c1 = 1.70158; const c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); } export class Renderer { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.sprites = new Map(); this.ripples = []; this.reveal = null; this.shards = new Map(); this.theme = null; this.resize(); } setTheme(theme) { this.theme = theme; this.sprites.clear(); } resize() { const dpr = Math.min(window.devicePixelRatio || 1, 3); const w = this.canvas.clientWidth || window.innerWidth; const h = this.canvas.clientHeight || window.innerHeight; this.dpr = dpr; this.w = w; this.h = h; this.canvas.width = Math.round(w * dpr); this.canvas.height = Math.round(h * dpr); this.R = Math.max(40, Math.min(64, Math.min(w, h) * 0.115)); this.sprites.clear(); } colorOf(ring) { return ring.colorIdx < 0 ? this.theme.spectator : this.theme.colors[ring.colorIdx % this.theme.colors.length]; } lineWidth() { return this.R * 0.13 * this.theme.lineScale; } // ---- events from the game ---------------------------------------------- onEvent(type, data, now, game) { const R = this.R; switch (type) { case 'join': this.ripples.push(createRipple(data.ring.x, data.ring.y, this.colorOf(data.ring), now, R * 1.8, 450, 3)); break; case 'beat': for (const r of game.activeRings()) this.ripples.push(createRipple(r.x, r.y, this.colorOf(r), now, R * 2.1, 500, 3)); break; case 'orderStep': { const r = game.rings.get(data.id); if (r) this.ripples.push(createRipple(r.x, r.y, this.colorOf(r), now, R * 2.6, 600, 5)); break; } case 'reveal': { const targets = data.style === 'teams' ? game.players.map((id) => game.rings.get(id)).filter(Boolean).map((ring) => ({ ring, color: this.theme.team[game.plan.teams.get(ring.id)] })) : data.winners.map((id) => game.rings.get(id)).filter(Boolean).map((ring) => ({ ring, color: this.colorOf(ring) })); this.reveal = createReveal(data.style, { targets, w: this.w, h: this.h, R, start: now, palette: this.theme.colors }); break; } case 'phase': if (data.phase !== 'result') this.reveal = null; if (data.phase === 'idle' || data.phase === 'gathering') this.shards.clear(); break; case 'restart': this.reveal = null; this.shards.clear(); break; } } // ---- sprites ----------------------------------------------------------- sprite(color) { let s = this.sprites.get(color); if (s) return s; const { dpr, theme } = this; const r = this.R * SPRITE_SCALE; const lw = this.lineWidth() * SPRITE_SCALE; const pad = r * 0.55; const half = r + pad; const c = document.createElement('canvas'); c.width = c.height = Math.ceil(half * 2 * dpr); const g = c.getContext('2d'); g.scale(dpr, dpr); // faint inner fill const fill = g.createRadialGradient(half, half, r * 0.3, half, half, r); fill.addColorStop(0, 'rgba(0,0,0,0)'); fill.addColorStop(1, color + '38'); g.fillStyle = fill; g.beginPath(); g.arc(half, half, r, 0, TAU); g.fill(); g.strokeStyle = color; g.lineWidth = lw; if (theme.glow) { g.shadowColor = color; g.shadowBlur = r * 0.5 * theme.glow * dpr; g.beginPath(); g.arc(half, half, r, 0, TAU); g.stroke(); } g.beginPath(); g.arc(half, half, r, 0, TAU); g.stroke(); g.shadowBlur = 0; if (theme.additive) { // white-hot core, neon tube style g.strokeStyle = 'rgba(255,255,255,0.7)'; g.lineWidth = lw * 0.3; g.stroke(); } s = { canvas: c, half, base: r }; this.sprites.set(color, s); return s; } // ---- drawing ----------------------------------------------------------- draw(game, now) { // Cheaper and more reliable than trusting every resize/rotation event. const c = this.canvas; if (c.clientWidth !== this.w || c.clientHeight !== this.h || Math.min(window.devicePixelRatio || 1, 3) !== this.dpr) this.resize(); const { ctx, dpr, w, h, theme } = this; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const shake = this.reveal?.shake?.(now); if (shake) ctx.translate(shake[0], shake[1]); ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = 1; ctx.fillStyle = theme.bg; ctx.fillRect(-20, -20, w + 40, h + 40); if (this.reveal) this.reveal.drawBack(ctx, now); // Two passes so highlighted rings sit on top. if (theme.additive) ctx.globalCompositeOperation = 'lighter'; const hl = game.highlight; for (const ring of game.rings.values()) if (!hl?.has(ring.id)) this.drawRing(ctx, ring, game, now); for (const ring of game.rings.values()) if (hl?.has(ring.id)) this.drawRing(ctx, ring, game, now); this.ripples = this.ripples.filter((r) => !r.done(now)); for (const r of this.ripples) r.draw(ctx, now); ctx.globalCompositeOperation = 'source-over'; if (this.reveal) this.reveal.drawFront(ctx, now); this.drawLabels(ctx, game, now); } drawRing(ctx, ring, game, now) { const { theme } = this; const R = this.R; let scale = 1; let alpha = 1; let color = this.colorOf(ring); let shapeIdx = ring.colorIdx; const appear = clamp01((now - ring.bornAt) / 320); if (appear < 1) scale *= easeOutBack(appear); if (ring.goneAt) { const k = clamp01((now - ring.goneAt) / TIMING.ringFadeMs); scale *= 1 - 0.5 * k; alpha *= 1 - k; } if (ring.late) { this.drawShape(ctx, ring.x, ring.y, R * 0.75 * scale, theme.spectator, alpha * 0.6, -1); return; } if (ring.outAt) { this.drawCrack(ctx, ring, color, now); return; } const phase = game.phase; const mode = game.plan?.mode; const hl = game.highlight; const isHl = !!hl?.has(ring.id); const pop = Math.exp(-(now - game.stepAt) / 90); if (phase === 'idle' || phase === 'gathering' || phase === 'chicken') { scale *= 1 + 0.05 * Math.sin(((now - ring.bornAt) / 1000) * TAU * 0.7); if (phase === 'chicken') alpha *= 0.55; } else if (phase === 'countdown') { const bp = ((now - game.phaseAt) % TIMING.beatMs) / TIMING.beatMs; scale *= 1 + 0.2 * Math.exp(-bp * 6); } else if (mode === 'teams') { const team = phase === 'result' ? game.plan.teams.get(ring.id) : game.displayTeams?.get(ring.id); if (team != null) { color = theme.team[team]; shapeIdx = team; } scale *= phase === 'result' ? 1.1 + 0.04 * Math.sin(now / 300) : 1 + 0.1 * pop; } else if (phase === 'result') { if (mode === 'order') { if (isHl) scale *= 1.3 + 0.05 * Math.sin(now / 250); } else if (isHl) { scale *= 1.35 + 0.05 * Math.sin(now / 250); if (this.reveal?.winnerWhite) color = '#ffffff'; } else { const k = clamp01((now - game.revealAt) / 650); if (k >= 1) return; scale *= 1 - k * k; alpha *= 1 - k; } } else if (phase === 'ordering') { const num = game.orderNums.get(ring.id); if (isHl) scale *= 1.2 + 0.15 * pop; else if (!num) { scale *= 0.85; alpha *= 0.4; } } else { // suspense / elimPause if (isHl) scale *= 1.18 + 0.14 * pop; else if (hl) { scale *= 0.86; alpha *= 0.4; } } this.drawShape(ctx, ring.x, ring.y, R * scale, color, alpha, theme.shapes ? shapeIdx : -1); if (phase === 'gathering') { const p = game.stableProgress(now); if (p > 0.08) this.drawArc(ctx, ring.x, ring.y, R * 1.32 * scale, color, 0.55, p); } else if (phase === 'countdown') { const bp = ((now - game.phaseAt) % TIMING.beatMs) / TIMING.beatMs; this.drawArc(ctx, ring.x, ring.y, R * 1.32 * scale, color, 0.3 + 0.6 * Math.exp(-bp * 5), 1); } } drawArc(ctx, x, y, r, color, alpha, p) { ctx.globalAlpha = alpha; ctx.strokeStyle = color; ctx.lineWidth = Math.max(2, this.R * 0.05); ctx.beginPath(); ctx.arc(x, y, r, -Math.PI / 2, -Math.PI / 2 + TAU * p); ctx.stroke(); ctx.globalAlpha = 1; } /** A ring (sprite) or, in high-contrast, a shape outline. */ drawShape(ctx, x, y, r, color, alpha, shapeIdx) { if (r <= 0.5 || alpha <= 0.01) return; ctx.globalAlpha = alpha; if (shapeIdx >= 0) { const lw = this.lineWidth(); tracePath(ctx, SHAPES[shapeIdx % SHAPES.length], x, y, r); ctx.lineJoin = 'round'; ctx.strokeStyle = '#000'; ctx.lineWidth = lw + 6; ctx.stroke(); ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.stroke(); // second, inner outline doubles as a pattern cue tracePath(ctx, SHAPES[shapeIdx % SHAPES.length], x, y, r * 0.72); ctx.lineWidth = Math.max(2, lw * 0.35); ctx.stroke(); } else { const s = this.sprite(color); const k = r / s.base; const size = s.half * 2 * k; ctx.drawImage(s.canvas, x - s.half * k, y - s.half * k, size, size); } ctx.globalAlpha = 1; } /** Elimination: the ring cracks, splits into shards that fall and fade. */ drawCrack(ctx, ring, color, now) { const R = this.R; const t = (now - ring.outAt) / 1000; if (t > 1.3) return; let shards = this.shards.get(ring.id); if (!shards) { shards = []; const n = 7; let a = Math.random() * TAU; for (let i = 0; i < n; i++) { const span = (TAU / n) * (0.7 + Math.random() * 0.6); const mid = a + span / 2; shards.push({ a0: a, a1: a + span * 0.92, dx: Math.cos(mid), dy: Math.sin(mid), v: 70 + Math.random() * 110, spin: (Math.random() - 0.5) * 4 }); a += span; } const cracks = []; for (let i = 0; i < 3; i++) { const ang = Math.random() * TAU; const pts = []; for (let j = 0; j <= 4; j++) { const rr = R * (0.55 + j * 0.2); const jit = (Math.random() - 0.5) * 0.35; pts.push([Math.cos(ang + jit) * rr, Math.sin(ang + jit) * rr]); } cracks.push(pts); } shards.cracks = cracks; this.shards.set(ring.id, shards); } const lw = this.lineWidth(); if (t < 0.18) { // intact, flashing white and trembling, cracks spreading const j = 3 * (1 - t / 0.18); const x = ring.x + (Math.random() - 0.5) * j; const y = ring.y + (Math.random() - 0.5) * j; this.drawShape(ctx, x, y, R * 1.1, t < 0.06 ? '#ffffff' : color, 1, this.theme.shapes ? ring.colorIdx : -1); ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; const k = t / 0.18; for (const pts of shards.cracks) { ctx.beginPath(); const m = Math.max(2, Math.ceil(pts.length * k)); for (let i = 0; i < m && i < pts.length; i++) ctx[i ? 'lineTo' : 'moveTo'](x + pts[i][0], y + pts[i][1]); ctx.stroke(); } return; } const tt = t - 0.18; const fade = clamp01(1 - tt / 1.0); ctx.lineCap = 'round'; for (const s of shards) { const d = s.v * tt; const x = ring.x + s.dx * d; const y = ring.y + s.dy * d + 500 * tt * tt; ctx.save(); ctx.translate(x, y); ctx.rotate(s.spin * tt); ctx.strokeStyle = color; ctx.globalAlpha = fade * 0.35; ctx.lineWidth = lw * 2.4; ctx.beginPath(); ctx.arc(0, 0, R * 1.1, s.a0, s.a1); ctx.stroke(); ctx.globalAlpha = fade; ctx.lineWidth = lw; ctx.stroke(); ctx.restore(); } ctx.globalAlpha = 1; ctx.lineCap = 'butt'; } /** Number / team badges, placed on the side of the finger facing the * screen centre, since the hand covers the other side. */ drawLabels(ctx, game, now) { const mode = game.plan?.mode; const showOrder = mode === 'order' && (game.phase === 'ordering' || game.phase === 'result'); const showTeams = mode === 'teams' && game.phase === 'result'; if (!showOrder && !showTeams) return; const R = this.R; const br = R * 0.42; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = `800 ${Math.round(br * 1.1)}px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`; for (const ring of game.rings.values()) { let text; let color; let at; if (showOrder) { const o = game.orderNums.get(ring.id); if (!o) continue; text = String(o.n); color = this.colorOf(ring); at = o.at; } else { const team = game.plan.teams.get(ring.id); if (team == null) continue; text = TEAM_NAMES[team]; color = this.theme.team[team]; at = game.revealAt; } let dx = this.w / 2 - ring.x; let dy = this.h / 2 - ring.y; const len = Math.hypot(dx, dy); if (len < R) { dx = 0; dy = -1; } else { dx /= len; dy /= len; } const s = easeOutBack(clamp01((now - at) / 350)); const dist = R * 1.95; const x = ring.x + dx * dist; const y = ring.y + dy * dist; ctx.globalAlpha = clamp01(s); ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x, y, br * s, 0, TAU); ctx.fill(); ctx.lineWidth = 3; ctx.strokeStyle = '#000'; ctx.stroke(); if (s > 0.3) { ctx.fillStyle = '#000'; ctx.fillText(text, x, y + br * 0.05); } } ctx.globalAlpha = 1; } } function tracePath(ctx, shape, x, y, r) { ctx.beginPath(); const poly = (n, rot, rr = r) => { for (let i = 0; i < n; i++) { const a = rot + (i / n) * TAU; ctx[i ? 'lineTo' : 'moveTo'](x + Math.cos(a) * rr, y + Math.sin(a) * rr); } ctx.closePath(); }; switch (shape) { case 'square': poly(4, Math.PI / 4, r * 1.05); break; case 'triangle': poly(3, -Math.PI / 2, r * 1.2); break; case 'diamond': poly(4, 0, r * 1.15); break; case 'hexagon': poly(6, 0); break; case 'pentagon': poly(5, -Math.PI / 2, r * 1.05); break; case 'star': for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + (i / 10) * TAU; const rr = i % 2 ? r * 0.62 : r * 1.2; ctx[i ? 'lineTo' : 'moveTo'](x + Math.cos(a) * rr, y + Math.sin(a) * rr); } ctx.closePath(); break; case 'cross': { const a = r * 0.42; const b = r * 1.05; const pts = [[-a, -b], [a, -b], [a, -a], [b, -a], [b, a], [a, a], [a, b], [-a, b], [-a, a], [-b, a], [-b, -a], [-a, -a]]; pts.forEach(([px, py], i) => ctx[i ? 'lineTo' : 'moveTo'](x + px, y + py)); ctx.closePath(); break; } default: ctx.arc(x, y, r, 0, TAU); } }