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