Spaces:
Running
Running
File size: 43,966 Bytes
0cc8837 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 | // Hushfox UI: pass-the-phone party mode (works offline), room-code mode (each player on their own phone), and the
// solo Fox Hunt (daily + 30 levels). Rules live in core.js; the room runs on the hushfox Worker (net.js).
// (c) 2026 CyberMax. All rights reserved.
import * as C from './core.js';
import { PACKS, deck, packById } from './packs.js';
import { count, store, toast, share, pwa, themeToggle, canvasBlob, download, today, dayNumber, SITE } from './cmx.js';
import { progress, daily, readChallenge, challengeLink, shareChallenge, sfx, burstAt, shake, renderMap, scoreCard, starsFor, starsHtml, css as lvCss } from './levels.js';
import * as pass from './funpass.js';
import { apiBase, createRoom, roomInfo, connect } from './net.js';
const GAME = 'hushfox';
const PATH = '/games/hushfox/';
const stage = document.getElementById('stage');
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
let muted = !!store(`${GAME}:muted`);
const snd = sfx(() => muted);
const buzz = (p) => { try { if (!muted && navigator.vibrate) navigator.vibrate(p); } catch { /* */ } };
const FOX_SVG = '<svg class="fox" viewBox="0 0 512 512" aria-hidden="true"><path d="M106 132 196 226h120l90-94-26 168-124 112-124-112Z" fill="#FF7A2F"/><path d="M130 178 182 230l-30 30ZM382 178l-52 52 30 30Z" fill="#FFD9B8"/><path d="M132 300 256 412V330l-66-40ZM380 300 256 412V330l66-40Z" fill="#FFF3E6"/><circle cx="256" cy="396" r="19" fill="#1C1530"/><path d="M178 284q24 18 48 0M286 284q24 18 48 0" fill="none" stroke="#1C1530" stroke-width="15" stroke-linecap="round"/></svg>';
const fox = (cls = '') => FOX_SVG.replace('class="fox"', `class="fox ${cls}"`);
let tick = 0; // the one running interval (timers); cleared on every screen change
function show(html, cls = '') {
clearInterval(tick);
clearInterval(huntTick);
stage.innerHTML = `<div class="scr ${cls}">${html}</div>`;
stage.scrollTop = 0;
return stage.firstElementChild;
}
function on(id, fn) { const el = $(id); if (el) el.addEventListener('click', (e) => { snd.tap(); fn(e); }); return el; }
const isPro = () => pass.isPro();
// ------------------------------------------------------------------ shared bits
const LS_NAMES = `${GAME}:names`, LS_PACKS = `${GAME}:packs`, LS_ROUNDS = `${GAME}:rounds`, LS_PID = `${GAME}:pid`, LS_ME = `${GAME}:me`;
function pid() {
let id = store(LS_PID);
if (!id) { id = `p${Math.random().toString(36).slice(2, 12)}${Date.now().toString(36).slice(-4)}`; store(LS_PID, id); }
return id;
}
function packChoice() {
const saved = store(LS_PACKS) || ['classic'];
const ok = saved.filter((id) => packById(id) && (!packById(id).pass || isPro()));
return ok.length ? ok : ['classic'];
}
function packsHtml(sel, canEdit = true) {
return `<div class="packs" role="group" aria-label="Packs">${PACKS.map((p) => {
const locked = p.pass && !isPro();
const n = p.cats.reduce((a, c) => a + c[2].split('|').length, 0);
return `<button type="button" class="pack" data-pack="${p.id}" aria-pressed="${sel.includes(p.id)}" ${canEdit ? '' : 'disabled'}><i>${p.emoji}</i><span><b>${esc(p.name)}</b><span>${esc(p.desc)} · ${n} words</span></span>${locked ? '<em>🔒 Fun Pass</em>' : p.pass ? '<em>✨ Pass</em>' : '<em>Free</em>'}</button>`;
}).join('')}</div>`;
}
function bindPacks(sel, onChange) {
stage.querySelectorAll('[data-pack]').forEach((b) => b.addEventListener('click', () => {
const p = packById(b.dataset.pack);
if (p.pass && !isPro()) { pass.open(`pack-${p.id}`); return; }
snd.tap();
const i = sel.indexOf(p.id);
if (i >= 0) { if (sel.length > 1) sel.splice(i, 1); } else sel.push(p.id);
stage.querySelectorAll('[data-pack]').forEach((x) => x.setAttribute('aria-pressed', String(sel.includes(x.dataset.pack))));
store(LS_PACKS, sel);
onChange && onChange(sel);
}));
}
function roundsHtml(n, canEdit = true) {
return `<div class="chips" role="group" aria-label="Rounds">${C.ROUND_OPTIONS.map((r) => `<button type="button" class="chip" data-rounds="${r}" aria-pressed="${r === n}" ${canEdit ? '' : 'disabled'}>${r} rounds</button>`).join('')}</div>`;
}
function bindRounds(onPick) {
stage.querySelectorAll('[data-rounds]').forEach((b) => b.addEventListener('click', () => {
snd.tap();
const n = Number(b.dataset.rounds);
stage.querySelectorAll('[data-rounds]').forEach((x) => x.setAttribute('aria-pressed', String(x === b)));
store(LS_ROUNDS, n);
onPick(n);
}));
}
function timerHtml() {
return `<div class="timer" id="timer"><svg viewBox="0 0 120 120"><circle class="bg" cx="60" cy="60" r="52"/><circle class="fg" id="ring" cx="60" cy="60" r="52" stroke-dasharray="326.7" stroke-dashoffset="0"/></svg><b id="secs">30</b></div>`;
}
/** Drive the ring until `ends` (ms, local clock). onTwist fires once at TWIST_AT left; onEnd at zero. */
function runTimer(ends, total, { twistAt = 0, onTwist, onEnd } = {}) {
let twisted = !twistAt || Date.now() >= twistAt, lastSec = -1;
if (twisted && twistAt && onTwist) onTwist(true);
const step = () => {
const left = Math.max(0, ends - Date.now());
const s = Math.ceil(left / 1000);
const ring = $('ring'), secs = $('secs'), t = $('timer');
if (!ring) { clearInterval(tick); return; }
ring.style.strokeDashoffset = String(326.7 * (1 - left / total));
if (s !== lastSec) {
secs.textContent = s; lastSec = s;
t.classList.toggle('hurry', s <= 5 && s > 0);
if (s <= 5 && s > 0) snd.tone(520 + (5 - s) * 60, 0.05, 'square', 0.04, 1);
}
if (!twisted && twistAt && Date.now() >= twistAt) { twisted = true; onTwist && onTwist(false); }
if (left <= 0) { clearInterval(tick); onEnd && onEnd(); }
};
step();
tick = setInterval(step, 200);
}
function twistHtml(id, waiting = false) {
if (waiting) return '<div class="twist wait" id="twist"><i>🃏</i><span>Twist card flips at 15 seconds…</span></div>';
const t = C.twistById(id);
return `<div class="twist flip" id="twist"><i>${t.icon}</i><span>TWIST! ${esc(t.text)}</span></div>`;
}
function flipTwist(id, quiet) {
const el = $('twist');
if (el) el.outerHTML = twistHtml(id);
if (!quiet) { snd.boss(); buzz([60, 40, 60]); const t = $('twist'); if (t) burstAt(t, 0.5, 0.5, { colors: ['#FFE36B', '#FF7A2F', '#fff'], n: 24 }); }
}
// ------------------------------------------------------------------ home
function home() {
const dd = dayNumber(today());
show(`${fox()}<p class="logo">HUSH<i>FOX</i></p><p class="lead">Everyone knows the word. Except the Fox.</p>
<button class="big hot" id="h-pass" type="button"><b>📱 Pass one phone</b><span>3-12 players around one phone · works offline</span></button>
<button class="big" id="h-room" type="button"><b>🔗 Room code</b><span>Everyone joins on their own phone</span></button>
<button class="big" id="h-hunt" type="button"><b>🔎 Fox Hunt (solo)</b><span>Daily #${dd}: spot the word that doesn't belong · 30 levels</span></button>
<button class="lnk" id="h-how" type="button">How to play</button>`);
on('h-pass', () => passSetup());
on('h-room', () => roomHome());
on('h-hunt', () => huntHome());
on('h-how', () => howTo());
}
function howTo() {
show(`<p class="out">How to play</p>
<ol class="lead" style="text-align:left;font-weight:600;font-size:18px;padding-left:1.2em;max-width:34ch">
<li>Everyone secretly sees the word. The <b>Fox</b> only sees the category.</li>
<li>Go round the table: one clue each, out loud. Don't make it too easy!</li>
<li>At 15 seconds a <b>Twist card</b> flips (mime it, one syllable, opposites…). Do a second lap with the twist.</li>
<li>At zero, count 3-2-1 and point at the Fox.</li>
<li>Caught? The Fox can still <b>steal</b> the round by picking the word from six.</li></ol>
<p class="small muted2">Fox escapes: +2 · each finger on the Fox: +1 · Fox steals the word: +2</p>
<button class="btn go" id="b-back" type="button">Got it</button>`);
on('b-back', home);
}
// ------------------------------------------------------------------ pass-the-phone mode (offline)
let G = null; // the local game
function passSetup(keep) {
let names = keep || store(LS_NAMES) || ['', '', ''];
while (names.length < 3) names.push('');
let rounds = store(LS_ROUNDS) || 5;
const sel = packChoice();
let useDaily = false;
const draw = () => {
show(`<p class="out">Who's playing?</p>
<ul class="names" id="names">${names.map((n, i) => `<li><input class="inp" maxlength="16" aria-label="Player ${i + 1} name" placeholder="Player ${i + 1}" value="${esc(n)}" data-i="${i}">${names.length > 3 ? `<button class="x" type="button" data-del="${i}" aria-label="Remove player ${i + 1}">×</button>` : ''}</li>`).join('')}</ul>
${names.length < C.MAX_PLAYERS ? '<button class="btn ghost sm" id="add" type="button">+ Add player</button>' : ''}
<div class="field"><label>Rounds</label>${roundsHtml(rounds)}</div>
<div class="field"><label>Deck</label><div class="chips"><button type="button" class="chip" id="dly" aria-pressed="${useDaily}">📅 Today's deck #${dayNumber(today())} (same 5 words worldwide)</button></div></div>
<div class="field"><label>Packs</label>${packsHtml(sel)}</div>
<div class="field"><button class="btn ghost sm" id="ai" type="button">✨ Add a surprise AI category (optional)</button> <span class="small muted2" id="ai-note"></span></div>
<button class="btn go" id="start" type="button">▶ Start (${names.length} players)</button>
<button class="lnk" id="back" type="button">Back</button>`, 'top');
stage.querySelectorAll('[data-i]').forEach((el) => el.addEventListener('input', () => { names[Number(el.dataset.i)] = el.value; }));
stage.querySelectorAll('[data-del]').forEach((b) => b.addEventListener('click', () => { names.splice(Number(b.dataset.del), 1); draw(); }));
on('add', () => { names.push(''); draw(); const l = stage.querySelectorAll('[data-i]'); l[l.length - 1].focus(); });
bindRounds((n) => { rounds = n; });
bindPacks(sel);
on('dly', () => { useDaily = !useDaily; $('dly').setAttribute('aria-pressed', String(useDaily)); if (useDaily) rounds = 5; });
on('ai', () => aiCategory());
on('back', home);
on('start', () => {
const clean = names.map((n, i) => C.cleanName(n || `Player ${i + 1}`));
const uniq = []; for (const n of clean) uniq.push(C.uniqueName(n, uniq));
store(LS_NAMES, uniq);
newPassGame(uniq, useDaily ? 5 : rounds, sel.slice(), useDaily);
});
if (aiExtra.length) $('ai-note').textContent = `Added: ${aiExtra.map((x) => x.cat).join(', ')}`;
};
draw();
}
let aiExtra = [];
async function aiCategory() {
const note = $('ai-note');
if (!navigator.onLine) { toast('Offline: the built-in decks work without internet.'); return; }
const theme = prompt('A theme for the surprise category (or leave empty for a random one):', '') ?? null;
if (theme === null) return;
note.textContent = 'Asking the AI…';
try {
const r = await fetch(`${apiBase()}/api/ai?theme=${encodeURIComponent(theme.slice(0, 30))}`);
const j = await r.json();
if (!r.ok || !j.cat) throw new Error(j.error || 'No category');
aiExtra = [...aiExtra.filter((x) => x.cat !== j.cat), { cat: j.cat, words: j.words }].slice(-3);
note.textContent = `Added: ${aiExtra.map((x) => x.cat).join(', ')}`;
count(GAME, 'cta', 'ai-category');
} catch (e) { note.textContent = `${e.message || 'AI is busy'}. The built-in decks are ready.`; }
}
function localDeck(packs) {
const d = deck(packs, isPro());
return [...(d.length ? d : deck(['classic'])), ...aiExtra.map((x) => ({ ...x, group: 'ai', pack: 'ai', lang: 'en' }))];
}
function newPassGame(names, rounds, packs, useDaily) {
G = { players: names.map((n, i) => ({ id: `s${i}`, name: n, score: 0 })), rounds, packs, useDaily, round: 0, history: [], used: new Set(), cur: null, seed: Math.random() };
count(GAME, 'play', useDaily ? 'pass-daily' : 'pass');
passDeal();
}
function passDeal() {
const ids = G.players.map((p) => p.id);
const r = C.rng(`${G.seed}-${G.round}-${Date.now()}`);
const d = localDeck(G.packs);
let cur = C.deal(d, ids, r, { used: G.used, round: G.round });
if (G.useDaily) {
const dr = C.dailyRounds(deck(['classic']), today())[G.round % 5];
const cat = deck(['classic']).find((c) => c.cat === dr.cat);
const others = C.shuffle(cat.words.filter((w) => w !== dr.word), r).slice(0, C.CHOICES - 1);
cur = { ...cur, cat: dr.cat, word: dr.word, twist: dr.twist, choices: C.shuffle([dr.word, ...others], r) };
}
// one shared phone can't show the Fox a private letter mid-round, so the peek twist is swapped for another
if (cur.twist === 'peek') cur.twist = C.TWISTS.filter((t) => t.id !== 'peek')[Math.floor(r() * (C.TWISTS.length - 1))].id;
G.cur = cur;
G.used.add(`${cur.cat}|${cur.word}`);
passTo(0);
}
const nameOf = (id) => (G.players.find((p) => p.id === id) || {}).name || '?';
function passTo(i) {
const p = G.players[i];
show(`<p class="small muted2">Round ${G.round + 1} of ${G.rounds} · card ${i + 1} of ${G.players.length}</p>
<p class="out">Pass the phone to</p><p class="code-big" style="letter-spacing:0;font-size:clamp(40px,12vw,64px)">${esc(p.name)}</p>
<p class="lead">Only ${esc(p.name)} looks!</p>
<button class="btn go" id="see" type="button">👀 I'm ${esc(p.name)}: show my card</button>`);
on('see', () => cardFor(i));
}
function cardFor(i) {
const p = G.players[i];
const c = C.cardFor(G.cur, p.id);
const last = i === G.players.length - 1;
show(`${c.fox ? `<div class="card foxcard flip"><p class="k">You are</p><p class="w">THE FOX 🦊</p><p class="c">Category: ${esc(c.cat)}</p><p class="c small">You don't know the word. Listen, blend in, give a vague clue.</p></div>`
: `<div class="card flip"><p class="k">${esc(c.cat)}</p><p class="w">${esc(c.word)}</p><p class="c small">Give a clue that proves you know it, without giving it away to the Fox.</p></div>`}
<button class="btn go" id="hide" type="button">${last ? '🙈 Hide & start the clues' : `🙈 Hide & pass to ${esc(G.players[i + 1].name)}`}</button>`);
if (c.fox) buzz([30, 30, 30]); else buzz(20);
on('hide', () => (last ? passClues() : passTo(i + 1)));
}
function passClues() {
const ends = Date.now() + C.CLUE_MS;
show(`<p class="small muted2">Round ${G.round + 1} of ${G.rounds}</p><p class="cat">${esc(G.cur.cat)}</p>
<p class="lead">🎤 ${esc(nameOf(G.cur.starter))} starts. One clue each, go round!</p>${timerHtml()}${twistHtml(G.cur.twist, true)}
<button class="btn ghost sm" id="skip" type="button">Everyone's done: vote now</button>`);
snd.win();
runTimer(ends, C.CLUE_MS, { twistAt: ends - C.TWIST_AT, onTwist: (q) => flipTwist(G.cur.twist, q), onEnd: () => { snd.hit(); buzz(200); passVote(); } });
on('skip', passVote);
}
function passVote() {
show(`<p class="out">Where's the Fox?</p><p class="lead">Count 3-2-1 and everyone points at once!</p>
<button class="btn go" id="count" type="button">3 · 2 · 1</button>
<div id="pick" hidden><p class="lead">Who got the most fingers?</p><div class="grid">${G.players.map((p) => `<button class="pick" type="button" data-acc="${p.id}">${esc(p.name)}</button>`).join('')}<button class="pick" type="button" data-acc="">🤷 A tie / split</button></div></div>`);
on('count', () => {
const b = $('count');
let n = 3;
b.disabled = true;
const t = setInterval(() => {
snd.tone(440 + (3 - n) * 120, 0.12, 'square', 0.05, 1);
b.textContent = n > 0 ? String(n) : 'POINT! 👉';
if (n-- <= 0) { clearInterval(t); buzz(120); $('pick').hidden = false; }
}, 550);
});
stage.querySelectorAll('[data-acc]').forEach((b) => b.addEventListener('click', () => {
snd.tap();
const acc = b.dataset.acc;
G.votes = {};
if (acc) for (const p of G.players) if (p.id !== acc) G.votes[p.id] = acc;
if (acc && acc === G.cur.fox) passGuess(); else passResult(null);
}));
}
function passGuess() {
show(`${fox('sm')}<p class="out">${esc(nameOf(G.cur.fox))} is the Fox!</p><p class="lead">Fox, one last chance: steal the round by picking the word.</p>
<div class="grid">${G.cur.choices.map((w) => `<button class="pick" type="button" data-w="${esc(w)}">${esc(w)}</button>`).join('')}</div>`);
burstAt(stage.querySelector('.fox'), 0.5, 0.5);
snd.good(8);
stage.querySelectorAll('[data-w]').forEach((b) => b.addEventListener('click', () => {
const ok = C.norm(b.dataset.w) === C.norm(G.cur.word);
b.classList.add(ok ? 'good' : 'bad');
if (!ok) stage.querySelector(`[data-w="${CSS.escape(G.cur.word)}"]`)?.classList.add('good');
setTimeout(() => passResult(b.dataset.w), 700);
}));
}
function passResult(guess) {
const s = C.score({ fox: G.cur.fox, word: G.cur.word, votes: G.votes || {}, guess });
for (const [id, n] of Object.entries(s.deltas)) G.players.find((p) => p.id === id).score += n;
G.history.push({ cat: G.cur.cat, word: G.cur.word, fox: G.cur.fox, foxName: nameOf(G.cur.fox), outcome: s.outcome });
resultScreen({ outcome: s.outcome, word: G.cur.word, foxName: nameOf(G.cur.fox), accusedName: s.accused ? nameOf(s.accused) : null, deltas: s.deltas, players: G.players, round: G.round, rounds: G.rounds },
{ next: () => { G.round++; if (G.round >= G.rounds) passFinal(); else passDeal(); } });
}
function resultScreen(r, { next, host = true }) {
const last = r.round + 1 >= r.rounds;
const sub = r.outcome === 'escaped' ? (r.accusedName ? `You pointed at ${esc(r.accusedName)}. Wrong!` : 'The table split, and the Fox slipped away.')
: r.outcome === 'stole' ? 'Caught… but the Fox guessed the word!' : r.outcome === 'void' ? 'The Fox left the room. Nobody scores.' : 'Every finger on the Fox scores a point.';
show(`<p class="emoji">${C.OUTCOME_EMOJI[r.outcome]}</p><p class="out">${esc(C.OUTCOME_TEXT[r.outcome])}</p><p class="lead">${sub}</p>
<div class="card"><p class="k">The word was</p><p class="w">${esc(r.word)}</p><p class="c">🦊 The Fox: ${esc(r.foxName)}</p></div>
<ul class="board">${[...r.players].sort((a, b) => b.score - a.score).map((p) => `<li>${esc(p.name)}${r.deltas[p.id] ? ` <span class="d">+${r.deltas[p.id]}</span>` : ''}<b>${p.score}</b></li>`).join('')}</ul>
${host ? `<button class="btn go" id="next" type="button">${last ? '🏆 Final scores' : `▶ Round ${r.round + 2}`}</button>` : '<p class="small muted2">Next round starts in a moment…</p>'}`);
if (r.outcome === 'caught' || r.outcome === 'stole') { snd.win(); burstAt(stage.querySelector('.out'), 0.5, 0.5); } else if (r.outcome === 'escaped') { snd.lose(); shake(stage.querySelector('.out')); }
if (host) on('next', next);
}
function passFinal() {
const scores = Object.fromEntries(G.players.map((p) => [p.id, p.score]));
finalScreen({ players: G.players, history: G.history, winners: C.winners(scores), daily: G.useDaily },
{ rematch: () => { G.players.forEach((p) => { p.score = 0; }); newPassGame(G.players.map((p) => p.name), G.rounds, G.packs, G.useDaily); },
edit: () => passSetup(G.players.map((p) => p.name)) });
}
function finalScreen({ players, history, winners, daily: dly }, { rematch, edit, host = true, waitingNote = '' }) {
const names = winners.map((id) => players.find((p) => p.id === id)?.name).filter(Boolean);
const line = C.emojiLine(history);
show(`${fox()}<p class="out">${names.length ? `🏆 ${esc(names.join(' & '))} ${names.length > 1 ? 'win' : 'wins'}!` : 'Nobody scored. Sneaky foxes!'}</p>
<p class="emoji" aria-label="Round results">${line}</p>
<ul class="board">${[...players].sort((a, b) => b.score - a.score).map((p) => `<li class="${winners.includes(p.id) ? 'win' : ''}">${winners.includes(p.id) ? '👑 ' : ''}${esc(p.name)}<b>${p.score}</b></li>`).join('')}</ul>
${host ? '<button class="btn go" id="again" type="button">↻ Rematch</button>' : `<p class="lead">${esc(waitingNote || 'Waiting for the host to start a rematch…')}</p>`}
<div class="row" style="justify-content:center"><button class="btn sm" id="share" type="button">📤 Share result</button><button class="btn ghost sm" id="card" type="button">🖼 Save card</button>${edit ? '<button class="btn ghost sm" id="edit" type="button">👥 Players</button>' : ''}<button class="btn ghost sm" id="menu" type="button">Menu</button></div>
<div id="fp"></div>`);
snd.win(); [0, 1, 2].forEach((i) => setTimeout(() => burstAt(stage.querySelector('.out'), 0.2 + i * 0.3, 0.5), i * 180));
const text = `Hushfox 🦊 ${history.length} rounds${dly ? ` · Daily deck #${dayNumber(today())}` : ''}\n${line}\n${names.length ? `Winner: ${names.join(' & ')}` : 'The Fox won the night'}`;
if (host) on('again', () => { count(GAME, 'play', 'rematch'); rematch(); });
on('share', async () => { const b = await canvasBlob(partyCard(players, history, names)); share(GAME, text, PATH, b, 'hushfox.png'); });
on('card', async () => { download(await canvasBlob(partyCard(players, history, names)), 'hushfox.png'); count(GAME, 'share', 'card'); });
if (edit) on('edit', edit);
on('menu', () => { leaveRoom(); home(); });
count(GAME, 'win', 'party');
pass.afterWin($('fp'), 'party');
installer.offer();
}
function drawFox(x, cx, cy, s) {
x.save(); x.translate(cx - 256 * s, cy - 256 * s); x.scale(s, s);
const P = (d, f) => { x.fillStyle = f; x.fill(new Path2D(d)); };
P('M106 132 196 226h120l90-94-26 168-124 112-124-112Z', '#FF7A2F'); P('M130 178 182 230l-30 30ZM382 178l-52 52 30 30Z', '#FFD9B8');
P('M132 300 256 412V330l-66-40ZM380 300 256 412V330l66-40Z', '#FFF3E6'); x.beginPath(); x.arc(256, 396, 19, 0, 7); x.fillStyle = '#1C1530'; x.fill();
x.strokeStyle = '#1C1530'; x.lineWidth = 15; x.lineCap = 'round'; x.stroke(new Path2D('M178 284q24 18 48 0M286 284q24 18 48 0'));
x.restore();
}
function partyCard(players, history, names) {
const top = [...players].sort((a, b) => b.score - a.score).slice(0, 4);
return scoreCard({ title: 'Hushfox', sub: `${history.length} rounds · ${players.length} players`, big: C.emojiLine(history) || '🦊', unit: names.length ? `🏆 ${names.join(' & ')}` : 'The Fox won the night',
line: 'Can your group catch the Fox?', bg: '#1C1530', fg: '#FFF3E6', accent: '#FF7A2F', foot: 'cybermaxtools.com/games/hushfox' }, (x) => {
drawFox(x, 540, 470, 1.05);
x.font = '700 40px system-ui, sans-serif'; x.fillStyle = '#FFF3E6'; x.textAlign = 'center';
x.fillText(top.map((p) => `${p.name} ${p.score}`).join(' · '), 540, 790);
});
}
// ------------------------------------------------------------------ room-code mode
let R = null; // { code, conn, s, me, error }
function roomHome(prefill = '') {
if (!navigator.onLine) {
show(`${fox('sm')}<p class="out">You're offline</p><p class="lead">Room codes need internet. Pass-the-phone works offline!</p><button class="btn go" id="pp" type="button">📱 Pass one phone</button><button class="lnk" id="back" type="button">Back</button>`);
on('pp', () => passSetup()); on('back', home); return;
}
const me = store(LS_ME) || '';
show(`<p class="out">Room code</p><p class="lead">Each player joins on their own phone. Same room, real voices.</p>
<div class="field"><label for="me">Your name</label><input class="inp" id="me" maxlength="16" placeholder="Your name" value="${esc(me)}" autocomplete="nickname"></div>
<button class="btn go" id="create" type="button">➕ Create a room</button>
<p class="small muted2">or join one</p>
<div class="field"><input class="inp code" id="code" maxlength="4" placeholder="CODE" value="${esc(prefill)}" aria-label="Room code" autocapitalize="characters" autocomplete="off"></div>
<button class="btn ghost" id="join" type="button" style="width:min(420px,100%)">Join room</button>
<p class="small" id="err" role="alert"></p><button class="lnk" id="back" type="button">Back</button>`, 'top');
const nm = () => { const v = C.cleanName($('me').value); store(LS_ME, v); return v; };
on('create', async () => {
const b = $('create'); b.disabled = true;
try { const code = await createRoom(); count(GAME, 'play', 'room-create'); joinRoom(code, nm()); }
catch { $('err').textContent = 'Could not create a room right now. Try again, or pass one phone.'; b.disabled = false; }
});
on('join', async () => {
const code = $('code').value.trim().toUpperCase();
if (!C.validCode(code)) { $('err').textContent = 'Codes are 4 letters, like KQBX.'; shake($('code')); return; }
try {
const i = await roomInfo(code);
if (!i.open) { $('err').textContent = 'No room with that code. Check the letters.'; shake($('code')); return; }
if (i.players >= C.MAX_PLAYERS) { $('err').textContent = 'That room is full (12 players).'; return; }
} catch { $('err').textContent = 'Could not reach the room. Check your connection.'; return; }
count(GAME, 'play', 'room-join');
joinRoom(code, nm());
});
on('back', home);
if (prefill) $('me').focus();
}
function joinRoom(code, name) {
leaveRoom();
R = { code, me: pid(), s: null, peek: false, voted: null, last: '' };
history.replaceState(null, '', `${location.pathname}?room=${code}${location.search.includes('api=') ? `&api=${encodeURIComponent(new URLSearchParams(location.search).get('api'))}` : ''}`);
show(`<p class="code-big">${esc(code)}</p><p class="lead">Joining…</p>`);
R.conn = connect(code, R.me, name, {
state: (s) => { if (!R) return; R.s = s; renderRoom(); },
error: (e) => { toast(e, 3000); },
status: (st) => {
if (!R) return;
if (st === 'gone') { const c = R.code; leaveRoom(); toast('You left the room.'); roomHome(c); return; }
let n = stage.querySelector('.net');
if (st === 'retry') { if (!n) { n = document.createElement('div'); n.className = 'net'; stage.appendChild(n); } n.textContent = 'Reconnecting…'; }
else if (n) n.remove();
},
});
}
function leaveRoom() {
if (!R) return;
try { R.conn.send({ t: 'leave' }); } catch { /* */ }
setTimeout(((c) => () => c.close())(R.conn), 150);
R = null;
if (location.search.includes('room=')) history.replaceState(null, '', location.pathname);
}
const rsend = (o) => R && R.conn.send(o);
const rname = (id) => (R.s.players.find((p) => p.id === id) || {}).name || '?';
function renderRoom() {
const s = R.s, me = s.players.find((p) => p.id === R.me), host = s.host === R.me;
const key = `${s.phase}|${s.round}|${s.game}`;
const fresh = key !== R.last;
if (fresh) { R.peek = false; R.voted = null; }
R.last = key;
const local = (t) => t - (s.offset || 0); // server time -> this phone's clock
if (me && me.waiting && ['reveal', 'clue', 'vote', 'guess', 'result'].includes(s.phase)) {
show(`<p class="code-big">${esc(s.code)}</p><p class="out">You're in!</p><p class="lead">A round is on. You join from the next round.</p><p class="small muted2">${s.players.filter((p) => p.on).length} players in the room</p><button class="lnk" id="leave" type="button">Leave room</button>`);
on('leave', () => { leaveRoom(); home(); });
return;
}
if (s.phase === 'lobby') return roomLobby(s, host);
if (s.phase === 'reveal' || s.phase === 'clue') {
if (!fresh && s.phase === 'clue' && $('card')) {
// same clue phase: only the twist/peek letter may have changed
if (s.cur.twist && $('twist') && $('twist').classList.contains('wait')) flipTwist(s.cur.twist, false);
if (s.card && s.card.letter && !stage.querySelector('.letter')) $('card').insertAdjacentHTML('beforeend', `<p class="c letter">👀 First letter: <b>${esc(s.card.letter)}</b></p>`);
return;
}
const c = s.card;
show(`<p class="small muted2">Room ${esc(s.code)} · round ${s.round + 1} of ${s.rounds}</p>
${c ? `<button type="button" id="card" class="card ${c.fox ? 'foxcard' : ''} hidden" aria-label="Tap to peek at your card"><p class="k">${c.fox ? 'You are' : esc(c.cat)}</p><p class="w">${c.fox ? 'THE FOX 🦊' : esc(c.word)}</p><p class="c">${c.fox ? `Category: ${esc(c.cat)}` : 'Tap to hide'}</p>${c.letter ? `<p class="c letter">👀 First letter: <b>${esc(c.letter)}</b></p>` : ''}</button><p class="small muted2">Tap the card to peek. Keep it hidden!</p>` : ''}
${s.phase === 'reveal' ? `<p class="lead">Look at your card… clues start in a moment.</p>${timerHtml()}` : `<p class="cat">${esc(s.cur.cat)}</p><p class="lead">🎤 ${esc(rname(s.cur.starter))} starts. One clue each!</p>${timerHtml()}${twistHtml(s.cur.twist, !s.cur.twist)}`}
${host ? '<button class="btn ghost sm" id="skip" type="button">Skip ▶</button>' : ''}`);
const card = $('card');
if (card) card.addEventListener('click', () => { R.peek = !R.peek; card.classList.toggle('hidden', !R.peek); snd.tap(); buzz(15); });
if (fresh && s.phase === 'reveal') { snd.boss(); buzz(c && c.fox ? [30, 30, 30] : 30); }
if (fresh && s.phase === 'clue') snd.win();
const total = s.phase === 'reveal' ? C.REVEAL_MS : C.CLUE_MS;
runTimer(local(s.ends), total, s.phase === 'clue' ? { twistAt: local(s.twistAt), onTwist: () => {} } : {});
if (host) on('skip', () => rsend({ t: 'next' }));
return;
}
if (s.phase === 'vote') {
const voted = me && s.players.find((p) => p.id === R.me)?.voted;
const left = s.players.filter((p) => p.on && !p.waiting && !p.voted).length;
show(`<p class="out">Who's the Fox?</p><p class="cat" style="font-size:22px">${esc(s.cur.cat)}</p>${timerHtml()}
${voted ? `<p class="lead">✓ Vote in. Waiting for ${left} more…</p>` : '<p class="lead">Tap the player you think is the Fox.</p>'}
<div class="grid">${s.players.filter((p) => p.on && !p.waiting && p.id !== R.me).map((p) => `<button class="pick ${R.voted === p.id ? 'on' : ''}" type="button" data-v="${p.id}" ${voted ? 'disabled' : ''}>${esc(p.name)}${p.voted ? '<small>voted</small>' : ''}</button>`).join('')}</div>`);
if (fresh) { snd.hit(); buzz(150); }
runTimer(local(s.ends), C.VOTE_MS);
stage.querySelectorAll('[data-v]').forEach((b) => b.addEventListener('click', () => { R.voted = b.dataset.v; snd.good(3); rsend({ t: 'vote', target: b.dataset.v }); b.classList.add('on'); }));
return;
}
if (s.phase === 'guess') {
const isFox = s.cur.accused === R.me;
show(`${fox('sm')}<p class="out">${isFox ? 'You were caught!' : `${esc(rname(s.cur.accused))} is the Fox!`}</p>
${isFox ? `<p class="lead">Steal the round: pick the word.</p><div class="grid">${(s.cur.choices || []).map((w) => `<button class="pick" type="button" data-w="${esc(w)}">${esc(w)}</button>`).join('')}</div>` : '<p class="lead">The Fox is trying to steal the word…</p>'}${timerHtml()}`);
if (fresh) { snd.good(8); burstAt(stage.querySelector('.fox'), 0.5, 0.5); }
runTimer(local(s.ends), C.GUESS_MS);
stage.querySelectorAll('[data-w]').forEach((b) => b.addEventListener('click', () => { b.classList.add('on'); stage.querySelectorAll('[data-w]').forEach((x) => { x.disabled = true; }); rsend({ t: 'guess', word: b.dataset.w }); }));
return;
}
if (s.phase === 'result') {
if (!fresh) return;
const c = s.cur;
resultScreen({ outcome: c.outcome, word: c.word, foxName: rname(c.fox), accusedName: c.accused ? rname(c.accused) : null, deltas: c.deltas || {}, players: s.players.filter((p) => p.on), round: s.round, rounds: s.rounds },
{ next: () => rsend({ t: 'next' }), host });
return;
}
if (s.phase === 'final') {
if (!fresh && stage.querySelector('#share')) return;
const scores = Object.fromEntries(s.players.map((p) => [p.id, p.score]));
finalScreen({ players: s.players, history: s.history, winners: C.winners(scores) }, { rematch: () => rsend({ t: 'rematch' }), host });
}
}
function roomLobby(s, host) {
const link = `${SITE}${PATH}?room=${s.code}`;
const active = s.players.filter((p) => p.on);
show(`<p class="small muted2">Room code</p><p class="code-big" id="rcode">${esc(s.code)}</p>
<div class="row" style="justify-content:center"><button class="btn sm" id="invite" type="button">📨 Invite link</button><button class="btn ghost sm" id="copy" type="button">Copy code</button></div>
${s.note ? `<p class="banner">${esc(s.note)}</p>` : ''}
<ul class="board" id="plist">${active.map((p) => `<li>${p.id === s.host ? '👑 ' : ''}${esc(p.name)}${p.id === R.me ? ' (you)' : ''}${host && p.id !== R.me ? `<button class="x" type="button" data-kick="${p.id}" aria-label="Remove ${esc(p.name)}" style="margin-left:auto">×</button>` : ''}</li>`).join('')}</ul>
<p class="small muted2">${active.length}/${C.MAX_PLAYERS} players · need ${C.MIN_PLAYERS}+</p>
${host ? `<div class="field"><label>Rounds</label>${roundsHtml(s.rounds)}</div><div class="field"><label>Packs</label>${packsHtml(s.packs)}</div>
<button class="btn go" id="start" type="button" ${active.length < C.MIN_PLAYERS ? 'disabled' : ''}>▶ Start game</button>`
: `<p class="lead">Waiting for ${esc(rname(s.host))} to start…</p><p class="small muted2">Packs: ${s.packs.map((id) => esc(packById(id)?.name || id)).join(', ')} · ${s.rounds} rounds</p>`}
<button class="lnk" id="leave" type="button">Leave room</button>`, 'top');
on('invite', () => { share(GAME, `Join my Hushfox party game! Room ${s.code}`, `${PATH}?room=${s.code}`); });
on('copy', async () => { try { await navigator.clipboard.writeText(s.code); toast('Code copied'); } catch { prompt('Room code:', s.code); } });
on('leave', () => { leaveRoom(); home(); });
stage.querySelectorAll('[data-kick]').forEach((b) => b.addEventListener('click', () => rsend({ t: 'kick', target: b.dataset.kick })));
if (host) {
const passCode = () => (store('cmx-pro:fun-pass') || {}).code || '';
bindRounds((n) => rsend({ t: 'settings', rounds: n }));
const sel = s.packs.slice();
bindPacks(sel, (p) => rsend({ t: 'settings', packs: p, pass: passCode() }));
on('start', () => { rsend({ t: 'settings', packs: sel, pass: passCode(), extra: aiExtra }); setTimeout(() => rsend({ t: 'start' }), 120); });
}
void link;
}
// ------------------------------------------------------------------ solo: Fox Hunt (daily + levels)
const HUNT_DECK = deck(['classic']);
const WORLDS = ['Meadow', 'Den', 'Moonlit woods'];
const LEVELS = Array.from({ length: 30 }, (_, i) => {
const n = i + 1, sp = C.levelSpec(n);
const twist = n === 7 ? '5 words' : n === 9 ? 'Sneaky foxes from a neighbouring category' : n === 13 ? '6 words' : n === 19 ? '7 words' : n === 25 ? '8 words' : undefined;
return { n, name: `${sp.size} words${sp.hard ? ', sneaky' : ''}`, twist, boss: n % 10 === 0 };
});
const prog = progress(GAME, { isPro });
const dly = daily(GAME);
function huntHome() {
lvCss();
const done = dly.best() !== undefined;
const el = show(`<p class="out">🔎 Fox Hunt</p><p class="lead">One word in each set doesn't belong: it's the Fox. Find 5 foxes in 30 seconds.</p>
<button class="big hot" id="hd" type="button"><b>📅 Daily #${dly.num}</b><span>${done ? `Best today: ${dly.best()} · streak ${dly.streak()}` : 'Same puzzles for everyone today'}</span></button>
<button class="big" id="hl" type="button"><b>▶ Level ${prog.reached()}</b><span>${prog.cleared()}/30 levels · ${prog.totalStars()}/90 ⭐</span></button>
<section class="lvmap" aria-label="Levels"><div id="lvmap"></div></section>
<button class="lnk" id="back" type="button">Back</button>`, 'top');
on('hd', () => hunt({ daily: true }));
on('hl', () => hunt({ level: prog.reached() }));
on('back', home);
renderMap(el.querySelector('#lvmap'), prog, LEVELS, { worlds: WORLDS, onPick: (n) => hunt({ level: n }), onLocked: (n, why) => (why === 'pass' ? pass.open(`level-${n}`) : toast('Clear the level before first.')) });
}
let H = null;
function hunt({ daily: isDaily = false, level = 0, vs = null } = {}) {
const spec = isDaily ? { size: 6, hard: true, puzzles: 5, ms: C.CLUE_MS } : C.levelSpec(level);
if (level) { const lock = prog.lock(level, { challenge: !!vs }); if (lock === 'pass') { pass.open(`level-${level}`); return huntHome(); } prog.open(level); }
const set = C.huntSet(HUNT_DECK, isDaily ? `hunt-${dly.date}` : `hunt-L${level}`, spec);
H = { isDaily, level, spec, set, i: 0, marks: [], ends: Date.now() + spec.ms, vs, over: false };
count(GAME, 'play', isDaily ? 'hunt-daily' : `hunt-l${level}`);
huntPuzzle();
const step = () => {
const left = Math.max(0, H.ends - Date.now());
const bar = stage.querySelector('.hunt-bar i');
if (bar) bar.style.width = `${(left / spec.ms) * 100}%`;
if (left <= 0 && !H.over) huntEnd();
};
clearInterval(huntTick);
huntTick = setInterval(step, 150);
}
let huntTick = 0;
function huntPuzzle() {
const p = H.set[H.i];
stage.innerHTML = `<div class="scr top"><p class="small muted2">${H.isDaily ? `Daily #${dly.num}` : `Level ${H.level}`} · fox ${H.i + 1} of ${H.spec.puzzles}</p>
<div class="hunt-bar"><i style="width:100%"></i></div><div class="dots">${H.set.map((_, k) => `<i class="${H.marks[k] === true ? 'ok' : H.marks[k] === false ? 'no' : k === H.i ? 'now' : ''}"></i>`).join('')}</div>
<p class="lead">Which word is the Fox?</p><div class="grid">${p.words.map((w) => `<button class="pick" type="button" data-h="${esc(w)}">${esc(w)}</button>`).join('')}</div></div>`;
stage.querySelectorAll('[data-h]').forEach((b) => b.addEventListener('click', () => {
if (H.over || H.lock) return;
H.lock = true;
const ok = b.dataset.h === p.fox;
H.marks[H.i] = ok;
b.classList.add(ok ? 'good' : 'bad');
if (ok) { snd.good(H.i * 3); burstAt(b, 0.5, 0.5, { n: 18 }); } else { snd.lose(); shake(b); buzz(80); stage.querySelector(`[data-h="${CSS.escape(p.fox)}"]`)?.classList.add('good'); }
const note = document.createElement('p'); note.className = 'small muted2'; note.textContent = `${p.fox} is from "${p.foxCat}"; the rest are ${p.cat}.`;
stage.firstElementChild.appendChild(note);
setTimeout(() => { H.lock = false; H.i++; if (H.i >= H.spec.puzzles) huntEnd(); else if (!H.over) huntPuzzle(); }, ok ? 650 : 1300);
}));
}
function huntEnd() {
if (H.over) return;
H.over = true;
clearInterval(huntTick);
const correct = H.marks.filter(Boolean).length;
const msLeft = Math.max(0, H.ends - Date.now());
const score = C.huntScore(correct, msLeft, H.spec.puzzles);
let head = '', stars = 0, rec = null;
if (H.isDaily) { rec = dly.record(score); head = `Daily #${dly.num}`; } else { stars = starsFor(score, C.LEVEL_STARS); rec = prog.record(H.level, stars, score); head = `Level ${H.level}`; }
updateUnder();
const grid = H.set.map((_, k) => (H.marks[k] ? '🦊' : H.marks[k] === false ? '❌' : '⬜')).join('');
const vs = H.vs !== null && H.vs !== undefined ? (score > H.vs ? `You beat your friend's ${H.vs}! 🎉` : score === H.vs ? `A tie with your friend's ${H.vs}.` : `Your friend's ${H.vs} still stands. Again?`) : '';
const canNext = !H.isDaily && stars > 0 && H.level < 30;
show(`<p class="out">${head}: ${correct}/5 foxes</p>${H.isDaily ? '' : `<div class="lv-stars">${starsHtml(stars)}</div>`}
<p class="code-big" style="font-size:clamp(46px,14vw,72px);letter-spacing:0">${score}</p><p class="emoji">${grid}</p>
${vs ? `<p class="banner">${esc(vs)}</p>` : ''}${H.isDaily ? `<p class="small muted2">🔥 streak ${rec.streak} · best today ${rec.best}</p>` : ''}
<div class="row" style="justify-content:center">${canNext ? '<button class="btn" id="nx" type="button">Next level ▶</button>' : ''}<button class="btn ${canNext ? 'ghost' : ''}" id="re" type="button">↻ Again</button></div>
<div class="row" style="justify-content:center"><button class="btn sm" id="beat" type="button">🎯 Challenge a friend</button><button class="btn ghost sm" id="hcard" type="button">🖼 Card</button><button class="btn ghost sm" id="hm" type="button">Fox Hunt</button></div><div id="fp"></div>`);
if (correct >= 4) snd.win(); else snd.lose();
if (stars) [0, 1, 2].slice(0, stars).forEach((i) => setTimeout(() => snd.star(i), 200 + i * 140));
const text = `Hushfox Fox Hunt ${H.isDaily ? `#${dly.num}` : `level ${H.level}`}: ${score}\n${grid}`;
const link = challengeLink(PATH, GAME, H.isDaily ? { date: dly.date, score } : { level: H.level, score });
const card = () => scoreCard({ title: 'Hushfox', sub: H.isDaily ? `Fox Hunt daily #${dly.num}` : `Fox Hunt level ${H.level}`, big: score, unit: grid, stars: H.isDaily ? undefined : stars, bg: '#1C1530', fg: '#FFF3E6', accent: '#FF7A2F', line: 'Can you spot the Fox faster?', foot: 'cybermaxtools.com/games/hushfox' }, (x) => drawFox(x, 540, 520, 1.1));
const again = { daily: H.isDaily, level: H.level };
on('re', () => hunt(again));
if (canNext) on('nx', () => hunt({ level: H.level + 1 }));
on('beat', async () => { const b = await canvasBlob(card()); const r = await shareChallenge(`${text}\nBeat me:`, link, b, 'hushfox-hunt.png'); if (r !== 'cancelled') count(GAME, 'share', 'challenge'); });
on('hcard', async () => { download(await canvasBlob(card()), 'hushfox-hunt.png'); count(GAME, 'share', 'card'); });
on('hm', huntHome);
count(GAME, 'win', H.isDaily ? 'hunt-daily' : 'hunt-level');
if (correct >= 3) pass.afterWin($('fp'), 'hunt');
installer.offer();
}
function updateUnder() {
const b = dly.best();
$('best').textContent = b === undefined ? '-' : b;
$('streak').textContent = dly.streak();
}
// ------------------------------------------------------------------ boot
const installer = pwa(GAME, $('install'));
pass.init({ game: GAME, name: 'Hushfox', perks: ['8 extra packs: Grown-ups night, Movie night, Office hours, Holidays, Español, Português, Deutsch, Français', 'Fox Hunt levels 21-30'],
extra: 'The Fun Pass adds 8 party packs (movies, office, holidays, grown-ups night and Spanish, Portuguese, German, French decks) and Fox Hunt levels 21-30, in every CyberMax game.' });
pass.onChange(() => { /* packs re-render on the next screen */ });
on('pass-btn', () => pass.open('button'));
themeToggle($('theme'));
const mb = $('mute');
if (mb) { mb.textContent = muted ? '🔇' : '🔊'; mb.addEventListener('click', () => { muted = !muted; store(`${GAME}:muted`, muted); mb.textContent = muted ? '🔇' : '🔊'; }); }
$('num').textContent = `#${dayNumber(today())}`;
updateUnder();
window.addEventListener('beforeunload', () => { if (R) try { R.conn.send({ t: 'leave' }); } catch { /* */ } });
(function route() {
const q = new URLSearchParams(location.search);
const room = (q.get('room') || '').toUpperCase();
const ch = readChallenge();
if (room && C.validCode(room)) {
roomHome(room);
return;
}
if (ch) {
if (ch.level) return hunt({ level: Math.min(30, ch.level), vs: ch.score });
if (ch.date === dly.date) return hunt({ daily: true, vs: ch.score });
}
home();
})();
// test + studio hooks (no effect for players)
window.__cmxTest = { C, G: () => G, R: () => R, H: () => H, prog, dly, home, passSetup, huntHome, deck };
window.__cmxShot = () => { newPassGame(['Ana', 'Ben', 'Cy', 'Di'], 5, ['classic'], false); passClues(); flipTwist(G.cur.twist, true); muted = true; };
window.__cmxDemo = async () => { // a real round on the real screens, sped up for the demo video (~11 s)
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
newPassGame(['Ana', 'Ben', 'Cy', 'Di'], 3, ['classic'], false);
await wait(900); $('see').click(); await wait(1500);
const fi = G.players.findIndex((p) => p.id === G.cur.fox);
if (fi !== 0) { cardFor(fi); await wait(1500); }
passClues(); await wait(1300); flipTwist(G.cur.twist, false); await wait(1900);
passVote(); await wait(300); $('count').click(); await wait(2300);
stage.querySelector(`[data-acc="${G.cur.fox}"]`).click(); await wait(1400);
stage.querySelector(`[data-w="${CSS.escape(G.cur.word)}"]`).click(); await wait(1600);
};
|