Spaces:
Running
Running
Download game.js from CyberMax-tools/hushfox: direct link, hf CLI and curl.
- Browser
- Download file 44 kB
-
https://huggingface.co/spaces/CyberMax-tools/hushfox/resolve/main/game.js
- Command line
-
hf download hf://spaces/CyberMax-tools/hushfox/game.js
-
curl -L -o game.js https://huggingface.co/spaces/CyberMax-tools/hushfox/resolve/main/game.js
44 kB
| // 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); | |
| }; | |