picker/js/renderer.js
2026-10-09 13:45:54 +02:00

468 lines
15 KiB
JavaScript

// 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);
}
}