468 lines
15 KiB
JavaScript
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, view: this, 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);
|
|
}
|
|
}
|