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

369 lines
11 KiB
JavaScript

// Game state machine. Pure logic + timing; no DOM, no drawing.
//
// idle ──(≥2 fingers)──▶ gathering ──(stable)──▶ countdown ──▶ suspense
// ▲ ▲ │ join │ lift ▶ chicken ─┘
// │ └────┴─────────────────┘
// suspense ──▶ result (one, pickN, teams)
// suspense ──▶ ordering ──▶ result (order)
// suspense ──▶ elimPause ──▶ suspense … ──▶ result (elimination)
import { planRound, makeHighlightSchedule, makeTeamSchedule } from './modes.js';
import { randomInt } from './random.js';
export const TIMING = {
stableMs: 2500, // finger set must be unchanged this long before the countdown
beatMs: 520, // countdown beat
beats: 3,
suspenseMs: 2700,
elimSuspenseMs: 1800,
settleMs: 650, // pause on the final highlight before the reveal
chickenMs: 1700,
elimPauseMs: 1500,
orderStepMs: 650,
restartGraceMs: 1200, // ignore taps right after a reveal
ringFadeMs: 350,
};
export const REVEAL_STYLES = ['flood', 'confetti', 'spotlight', 'shockwave'];
// Phases where the participants are locked in; new fingers are spectators.
const LOCKED = new Set(['suspense', 'elimPause', 'ordering', 'result']);
export class Game {
/**
* @param {{emit:(type:string,data?:object)=>void, getSettings:()=>object, isReduced:()=>boolean}} opts
*/
constructor({ emit, getSettings, isReduced }) {
this.emit = emit;
this.getSettings = getSettings;
this.isReduced = isReduced;
this.rings = new Map();
this.lastRevealStyle = null;
this.reset(performance.now());
}
reset(now) {
this.rings.clear();
this.phase = 'idle';
this.phaseAt = now;
this.lastChangeAt = now;
this.plan = null;
this.players = [];
this.schedule = null;
this.stepIndex = -1;
this.stepAt = 0;
this.highlight = null; // Set of highlighted ids
this.displayTeams = null; // Map id -> team shown during team suspense
this.beat = -1;
this.revealAt = 0;
this.revealStyle = null;
this.winners = [];
this.orderNums = new Map(); // id -> {n, at}
this.orderIdx = 0;
this.nextOrderAt = 0;
this.remaining = [];
this.lastOut = null; // {id, chickened}
this.chickenUntil = 0;
this.pauseUntil = 0;
}
// ---- queries -----------------------------------------------------------
/** Fingers currently down that count as players (not spectators). */
activeRings() {
const out = [];
for (const r of this.rings.values()) if (r.down && !r.goneAt && !r.late) out.push(r);
return out;
}
anyDown() {
for (const r of this.rings.values()) if (r.down) return true;
return false;
}
canRestart(now) {
return this.phase === 'result' && now - this.revealAt > TIMING.restartGraceMs && !this.anyDown();
}
/** 0..1 progress of the stability wait. */
stableProgress(now) {
return this.phase === 'gathering' ? Math.min(1, (now - this.lastChangeAt) / TIMING.stableMs) : 0;
}
// ---- input -------------------------------------------------------------
fingerDown(id, x, y, now) {
if (this.rings.has(id)) return;
if (this.phase === 'result' && this.canRestart(now)) {
this.reset(now);
this.emit('restart');
}
const late = LOCKED.has(this.phase);
const ring = { id, x, y, bornAt: now, down: true, goneAt: 0, late, colorIdx: late ? -1 : this.freeColor(), outAt: 0 };
this.rings.set(id, ring);
if (late) return;
this.lastChangeAt = now;
this.emit('join', { ring });
if (this.phase === 'countdown') this.setPhase('gathering', now);
}
fingerMove(id, x, y) {
const r = this.rings.get(id);
if (r && r.down) {
r.x = x;
r.y = y;
}
}
fingerUp(id, now) {
const r = this.rings.get(id);
if (!r || !r.down) return;
r.down = false;
r.upAt = now;
if (r.late) {
r.goneAt = now;
return;
}
switch (this.phase) {
case 'idle':
case 'gathering':
case 'chicken':
r.goneAt = now;
this.lastChangeAt = now;
this.emit('leave', { ring: r });
break;
case 'countdown':
r.goneAt = now;
this.chicken(now);
break;
case 'suspense':
if (this.plan.mode === 'elimination') {
this.elimChicken(r, now);
} else {
r.goneAt = now;
this.chicken(now);
}
break;
case 'elimPause':
this.elimChicken(r, now);
break;
default:
// ordering / result: the outcome is fixed, the ring just freezes in place.
break;
}
}
/** Page hidden etc.: drop every touch without drama. */
cancelAll(now) {
if (this.phase === 'result' || this.phase === 'ordering') {
for (const r of this.rings.values()) r.down = false;
} else {
this.reset(now);
this.emit('phase', { phase: 'idle' });
}
}
// ---- update ------------------------------------------------------------
update(now) {
for (const [id, r] of this.rings) {
if (r.goneAt && now - r.goneAt > TIMING.ringFadeMs) this.rings.delete(id);
}
switch (this.phase) {
case 'idle':
if (this.activeRings().length >= 2) this.setPhase('gathering', now);
break;
case 'gathering':
if (this.activeRings().length < 2) this.setPhase('idle', now);
else if (now - this.lastChangeAt >= TIMING.stableMs) {
this.beat = -1;
this.setPhase('countdown', now);
}
break;
case 'countdown': {
const b = Math.floor((now - this.phaseAt) / TIMING.beatMs);
if (b >= TIMING.beats) this.startRound(now);
else if (b !== this.beat) {
this.beat = b;
this.emit('beat', { index: b, remaining: TIMING.beats - b });
}
break;
}
case 'chicken':
if (now >= this.chickenUntil) {
this.lastChangeAt = now;
this.setPhase(this.activeRings().length >= 2 ? 'gathering' : 'idle', now);
}
break;
case 'suspense':
this.advanceSchedule(now);
break;
case 'elimPause':
if (now >= this.pauseUntil) {
if (this.remaining.length <= 1) this.reveal(now, this.remaining);
else this.startElimSuspense(now, TIMING.elimSuspenseMs);
}
break;
case 'ordering':
if (now >= this.nextOrderAt) {
if (this.orderIdx >= this.plan.order.length) this.reveal(now, [this.plan.order[0]]);
else this.assignNextOrder(now);
}
break;
}
}
// ---- transitions -------------------------------------------------------
setPhase(phase, now) {
this.phase = phase;
this.phaseAt = now;
this.emit('phase', { phase });
}
freeColor() {
const used = new Set();
for (const r of this.rings.values()) if (!r.goneAt && r.colorIdx >= 0) used.add(r.colorIdx);
let i = 0;
while (used.has(i)) i++;
return i;
}
chicken(now) {
this.plan = null;
this.schedule = null;
this.highlight = null;
this.displayTeams = null;
// Spectators who are still holding on become players for the next try.
for (const r of this.rings.values()) {
if (r.late && r.down) {
r.late = false;
r.colorIdx = this.freeColor();
r.bornAt = now;
}
}
this.chickenUntil = now + TIMING.chickenMs;
this.setPhase('chicken', now);
this.emit('chicken');
}
startRound(now) {
const rings = this.activeRings();
const ids = rings.map((r) => r.id);
const settings = this.getSettings();
const reduced = this.isReduced();
this.players = ids;
this.plan = planRound(settings.mode, ids, settings);
switch (this.plan.mode) {
case 'elimination':
this.remaining = ids.slice();
this.startElimSuspense(now, TIMING.suspenseMs);
return;
case 'teams':
this.startSuspense(now, makeTeamSchedule(ids, this.plan.teams, this.plan.teamCount, TIMING.suspenseMs, reduced));
return;
case 'order':
this.startSuspense(now, makeHighlightSchedule(rings, [this.plan.order[0]], TIMING.suspenseMs, reduced));
return;
default:
this.startSuspense(now, makeHighlightSchedule(rings, this.plan.winners, TIMING.suspenseMs, reduced));
}
}
startSuspense(now, schedule) {
this.schedule = schedule;
this.stepIndex = -1;
this.highlight = null;
this.displayTeams = null;
this.setPhase('suspense', now);
this.emit('suspenseStart');
this.advanceSchedule(now);
}
advanceSchedule(now) {
const steps = this.schedule;
const last = steps.length - 1;
const t = now - this.phaseAt;
while (this.stepIndex < last && steps[this.stepIndex + 1].t <= t) {
this.stepIndex++;
const step = steps[this.stepIndex];
if (step.teams) this.displayTeams = step.teams;
else this.highlight = new Set(step.ids);
this.stepAt = now;
this.emit('tick', { progress: last ? this.stepIndex / last : 1, final: this.stepIndex === last });
}
if (this.stepIndex === last && t >= steps[last].t + TIMING.settleMs) this.land(now);
}
land(now) {
const plan = this.plan;
if (plan.mode === 'order') {
this.orderIdx = 0;
this.setPhase('ordering', now);
this.assignNextOrder(now);
} else if (plan.mode === 'elimination') {
this.eliminate(this.currentTarget, now, false);
} else if (plan.mode === 'teams') {
this.reveal(now, []);
} else {
this.reveal(now, plan.winners);
}
}
reveal(now, winners) {
this.winners = winners.slice();
this.highlight = new Set(winners);
this.revealAt = now;
if (this.plan.mode === 'teams') this.revealStyle = 'teams';
else if (this.isReduced()) this.revealStyle = 'calm';
else {
const options = REVEAL_STYLES.filter((s) => s !== this.lastRevealStyle);
this.revealStyle = options[randomInt(options.length)];
this.lastRevealStyle = this.revealStyle;
}
this.setPhase('result', now);
this.emit('reveal', { plan: this.plan, style: this.revealStyle, winners: this.winners });
}
// ---- order mode ----
assignNextOrder(now) {
const id = this.plan.order[this.orderIdx];
const n = this.orderIdx + 1;
this.orderNums.set(id, { n, at: now });
this.highlight = new Set([id]);
this.stepAt = now;
this.orderIdx++;
this.nextOrderAt = now + TIMING.orderStepMs;
this.emit('orderStep', { id, n, total: this.plan.order.length });
}
// ---- elimination mode ----
startElimSuspense(now, duration) {
const target = this.plan.eliminationOrder.find((id) => this.remaining.includes(id));
this.currentTarget = target;
const rings = this.remaining.map((id) => this.rings.get(id));
this.startSuspense(now, makeHighlightSchedule(rings, [target], duration, this.isReduced()));
}
eliminate(id, now, chickened) {
const r = this.rings.get(id);
if (r) r.outAt = now;
this.remaining = this.remaining.filter((x) => x !== id);
this.lastOut = { id, chickened };
this.highlight = null;
this.pauseUntil = now + (this.remaining.length <= 1 ? 900 : TIMING.elimPauseMs);
this.setPhase('elimPause', now);
this.emit('eliminate', { id, chickened, remaining: this.remaining.length });
}
/** A player let go mid-elimination: they're out. */
elimChicken(r, now) {
if (!this.remaining.includes(r.id)) return; // already knocked out, lifting as asked
this.eliminate(r.id, now, true);
}
}