369 lines
11 KiB
JavaScript
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);
|
|
}
|
|
}
|