Spaces:
Running
Running
Download kenwren/game.js from CyberMax-tools/cybermax: direct link, hf CLI and curl.
- Browser
- Download file 14 kB
-
https://huggingface.co/spaces/CyberMax-tools/cybermax/resolve/main/kenwren/game.js
- Command line
-
hf download hf://spaces/CyberMax-tools/cybermax/kenwren/game.js
-
curl -L -o game.js https://huggingface.co/spaces/CyberMax-tools/cybermax/resolve/main/kenwren/game.js
14 kB
| // Kenwren: make a "how well do you know me" quiz, share it, friends play, private scoreboard. (c) 2026 CyberMax. | |
| import { QUESTIONS, BY_ID, PACKS, QUIZ_LEN, cleanName, suggest, verdict } from './core.js'; | |
| import { API, SITE, count, store, toast, share, intents, download, canvasBlob, pwa, rr, themeToggle } from './cmx.js'; | |
| import * as pass from './funpass.js'; | |
| import { initSolo, route as soloRoute, renderHome } from './solo.js'; | |
| const GAME = 'kenwren'; | |
| const PATH = '/games/kenwren/'; // canonical path on the CyberMax site (share links) | |
| const HERE = location.pathname; // in-page links work on the site and on HF copies | |
| const $ = (id) => document.getElementById(id); | |
| const SKINS = { dusk: { name: 'Dusk', a: '#2A1B3D', b: '#FFD166', free: true }, sunrise: { name: 'Sunrise', a: '#3D1F1B', b: '#FFE08A' }, lagoon: { name: 'Lagoon', a: '#0B2E3A', b: '#B8FFF0' }, bubblegum: { name: 'Bubblegum', a: '#3A1235', b: '#FFF1A8' }, paper: { name: 'Paper', a: '#EFE7D6', b: '#C2412D' } }; | |
| let mode = 'make'; // make | play | |
| let name = '', packs = ['classic'], qs = [], answers = [], i = 0, quiz = null, swaps = 0; | |
| pass.init({ game: GAME, name: 'Kenwren', perks: ['Solo levels 21-40: Memory Lane and Mind Reader', 'Deep and Spicy (clean) question packs', 'Quizzes last a year instead of 30 days', 'Sunrise, Lagoon, Bubblegum and Paper themes'] }); | |
| const install = pwa(GAME, $('install')); | |
| themeToggle($('theme')); | |
| function show(id) { | |
| document.querySelectorAll('.screen').forEach((s) => { s.hidden = s.id !== id; }); | |
| $('err').hidden = true; | |
| $('stage').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| } | |
| function err(msg) { const e = $('err'); e.textContent = msg; e.hidden = false; } | |
| const quizUrl = (id) => `${SITE}${PATH}?q=${id}`; | |
| async function api(path, init) { | |
| const r = await fetch(`${API}${path}`, init ? { ...init, headers: { 'content-type': 'application/json' } } : undefined); | |
| const j = await r.json().catch(() => ({})); | |
| if (!r.ok) throw new Error(j.message || 'Something went wrong. Try again in a moment.'); | |
| return j; | |
| } | |
| const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); | |
| const qText = (q) => (mode === 'make' ? q.me : q.you.replaceAll('{n}', quiz.name)); | |
| // ------------------------------------------------------------------ making a quiz | |
| function renderPacks() { | |
| const el = $('packs'); el.innerHTML = ''; | |
| for (const [id, p] of Object.entries(PACKS)) { | |
| const b = document.createElement('button'); | |
| const lock = !p.free && !pass.isPro(); | |
| b.type = 'button'; b.className = 'chip'; b.setAttribute('aria-pressed', String(packs.includes(id))); | |
| b.innerHTML = `${p.name}${lock ? ' <span class="lock">🔒</span>' : ''}`; | |
| b.onclick = () => { | |
| if (lock) { pass.open(`pack-${id}`); return; } | |
| packs = packs.includes(id) ? packs.filter((x) => x !== id) : [...packs, id]; | |
| if (!packs.length) packs = ['classic']; | |
| renderPacks(); | |
| }; | |
| el.appendChild(b); | |
| } | |
| el.hidden = mode !== 'make'; | |
| } | |
| $('start').onclick = () => { mode = 'make'; $('name-q').textContent = "What's your name?"; $('name-note').textContent = 'Friends will see this name. No email, no account.'; renderPacks(); show('s-name'); $('name').focus(); }; | |
| $('name-form').onsubmit = (e) => { | |
| e.preventDefault(); | |
| name = cleanName($('name').value); | |
| if (!name) { err('Add a name (letters and numbers).'); return; } | |
| store('kenwren:name', name); | |
| if (mode === 'make') { | |
| qs = suggest(`${Date.now()}`, packs); answers = []; i = 0; swaps = 0; | |
| count(GAME, 'play', 'make'); | |
| } else { qs = quiz.questions; answers = []; i = 0; count(GAME, 'play', 'friend'); } | |
| ask(); | |
| }; | |
| function ask() { | |
| const q = BY_ID[qs[i]]; | |
| $('bar').style.width = `${(i / QUIZ_LEN) * 100}%`; | |
| $('q-count').textContent = `Question ${i + 1} of ${QUIZ_LEN}`; | |
| $('q-text').textContent = qText(q); | |
| const box = $('opts'); box.innerHTML = ''; | |
| q.opts.forEach((o, k) => { | |
| const b = document.createElement('button'); | |
| b.type = 'button'; b.className = 'opt'; b.textContent = o; b.setAttribute('aria-pressed', String(answers[i] === k)); | |
| b.onclick = () => pick(k); | |
| box.appendChild(b); | |
| }); | |
| $('swap').hidden = mode !== 'make'; | |
| $('back').disabled = i === 0; | |
| show('s-q'); | |
| } | |
| function pick(k) { | |
| answers[i] = k; | |
| if (navigator.vibrate) try { navigator.vibrate(8); } catch { /* */ } | |
| if (i < QUIZ_LEN - 1) { i++; setTimeout(ask, 120); } else setTimeout(finish, 120); | |
| } | |
| $('back').onclick = () => { if (i > 0) { i--; ask(); } }; | |
| $('swap').onclick = () => { | |
| const used = new Set(qs); | |
| const pool = QUESTIONS.filter((q) => packs.includes(q.pack) && !used.has(q.id)); | |
| if (!pool.length) { toast('No more questions in these packs.'); return; } | |
| qs[i] = pool[(swaps++ * 7 + i) % pool.length].id; | |
| answers[i] = undefined; | |
| ask(); | |
| }; | |
| document.addEventListener('keydown', (e) => { if (!$('s-q').hidden && /^[1-4]$/.test(e.key)) pick(Number(e.key) - 1); }); | |
| async function finish() { | |
| $('bar').style.width = '100%'; | |
| if (mode === 'make') { | |
| try { | |
| const st = store('cmx-pro:fun-pass'); | |
| const j = await api('/api/quiz', { method: 'POST', body: JSON.stringify({ name, items: qs.map((q, k) => ({ q, a: answers[k] })), pass: st?.code }) }); | |
| const mine = store('kenwren:mine') || []; | |
| mine.unshift({ id: j.id, token: j.token, name, at: new Date().toISOString().slice(0, 10), expires: j.expires }); | |
| store('kenwren:mine', mine.slice(0, 30)); | |
| count(GAME, 'win', 'made'); | |
| shareScreen({ id: j.id, token: j.token, name, expires: j.expires }); | |
| install.offer(); | |
| } catch (e) { err(e.message); } | |
| } else { | |
| try { | |
| const j = await api(`/api/quiz/${quiz.id}/play`, { method: 'POST', body: JSON.stringify({ nick: name, picks: answers }) }); | |
| count(GAME, 'win', 'friend'); | |
| resultScreen(j); | |
| install.offer(); | |
| } catch (e) { err(e.message); } | |
| } | |
| } | |
| function shareScreen(m) { | |
| $('share-name').textContent = m.name; | |
| $('share-link').value = quizUrl(m.id); | |
| $('days').textContent = m.expires ? `until ${m.expires}` : '30 days'; | |
| const text = `How well do you know me? Take my 10-question quiz and see where you rank 👀`; | |
| const path = `${PATH}?q=${m.id}`; | |
| const it = intents(GAME, text, path); | |
| $('sw').href = it.whatsapp; $('sx').href = it.x; $('sb').href = it.bsky; | |
| $('share').onclick = async () => share(GAME, text, path, await canvasBlob(card(`How well do you know ${m.name}?`, 'Take my quiz', '10 questions · see where you rank')), `kenwren-${m.id}.png`); | |
| $('card').onclick = async () => { download(await canvasBlob(card(`How well do you know ${m.name}?`, 'Take my quiz', '10 questions · see where you rank')), `kenwren-${m.id}.png`); count(GAME, 'share', 'png'); }; | |
| $('results-link').href = `${HERE}?q=${m.id}&t=${m.token}`; | |
| show('s-share'); | |
| } | |
| $('copy').onclick = async () => { try { await navigator.clipboard.writeText($('share-link').value); toast('Link copied. Send it to your friends!'); } catch { $('share-link').select(); } count(GAME, 'share', 'copy'); }; | |
| ['sw', 'sx', 'sb'].forEach((id) => $(id).addEventListener('click', () => count(GAME, 'share', { sw: 'whatsapp', sx: 'x', sb: 'bsky' }[id]))); | |
| // ------------------------------------------------------------------ playing a friend's quiz | |
| function resultScreen(j) { | |
| $('r-title').textContent = `How well do you know ${j.name}?`; | |
| $('r-score').textContent = `${j.score}/${j.of}`; | |
| $('r-verdict').textContent = `${verdict(j.score)} You're #${j.rank} of ${j.board.length >= 20 ? 'the top 20' : j.board.length} on ${j.name}'s board.`; | |
| $('r-recap').innerHTML = quiz.questions.map((qid, k) => { | |
| const q = BY_ID[qid]; const ok = answers[k] === j.answers[k]; | |
| return `<li>${ok ? '✅' : '❌'} ${esc(q.you.replaceAll('{n}', j.name))} <b>${esc(q.opts[j.answers[k]])}</b>${ok ? '' : ` (you said ${esc(q.opts[answers[k]])})`}</li>`; | |
| }).join(''); | |
| const text = `I scored ${j.score}/10 on how well I know ${j.name} 👀 ${'🟩'.repeat(j.score)}${'⬜'.repeat(10 - j.score)}`; | |
| $('r-share').onclick = async () => share(GAME, text, `${PATH}?q=${quiz.id}`, await canvasBlob(card(`I scored ${j.score}/10`, `on ${j.name}'s friend quiz`, verdict(j.score))), `kenwren-score.png`); | |
| $('r-make').onclick = () => { count(GAME, 'cta', 'make-own'); history.replaceState(null, '', HERE); quiz = null; $('start').click(); }; | |
| show('s-result'); | |
| } | |
| async function loadQuiz(id, token) { | |
| try { | |
| if (token) { | |
| const b = await api(`/api/quiz/${id}/board?t=${encodeURIComponent(token)}`); | |
| boardScreen(b, token); | |
| return; | |
| } | |
| quiz = await api(`/api/quiz/${id}`); | |
| mode = 'play'; | |
| $('name-q').textContent = `How well do you know ${quiz.name}?`; | |
| $('name-note').textContent = `${quiz.plays} friend${quiz.plays === 1 ? ' has' : 's have'} played. Type your name to start; ${quiz.name} will see it on the scoreboard.`; | |
| $('name').value = store('kenwren:name') || ''; | |
| renderPacks(); | |
| show('s-name'); | |
| } catch (e) { show('s-home'); err(e.message); } | |
| } | |
| // ------------------------------------------------------------------ creator's scoreboard | |
| function boardScreen(b, token) { | |
| $('b-title').textContent = `${b.name}'s scoreboard`; | |
| $('b-meta').textContent = `${b.plays} play${b.plays === 1 ? '' : 's'} · open until ${b.expires}`; | |
| $('b-list').innerHTML = b.board.length ? b.board.map((e) => `<li>${esc(e.nick)} <b>${e.score}/10</b></li>`).join('') : '<li>No one has played yet. Share your link!</li>'; | |
| $('b-ans').innerHTML = b.answers.map((x) => `<li>${esc(BY_ID[x.q].me)} <b>${esc(BY_ID[x.q].opts[x.a])}</b></li>`).join(''); | |
| $('b-refresh').onclick = () => loadQuiz(b.id, token); | |
| $('b-share').onclick = () => shareScreen({ id: b.id, token, name: b.name, expires: b.expires }); | |
| show('s-board'); | |
| } | |
| $('mine-btn').onclick = () => { | |
| const mine = (store('kenwren:mine') || []).filter((m) => !m.expires || m.expires >= new Date().toISOString().slice(0, 10)); | |
| if (!mine.length) { toast('No quizzes on this device yet. Make one!'); return; } | |
| const m = mine[0]; | |
| if (mine.length > 1) { | |
| const k = Number(prompt(mine.map((x, n) => `${n + 1}. ${x.name} (${x.at})`).join('\n') + '\n\nOpen which scoreboard?', '1')); | |
| if (!(k >= 1 && k <= mine.length)) return; | |
| history.replaceState(null, '', `${HERE}?q=${mine[k - 1].id}&t=${mine[k - 1].token}`); | |
| loadQuiz(mine[k - 1].id, mine[k - 1].token); | |
| return; | |
| } | |
| history.replaceState(null, '', `${HERE}?q=${m.id}&t=${m.token}`); | |
| loadQuiz(m.id, m.token); | |
| }; | |
| // ------------------------------------------------------------------ story card (1080×1350) | |
| function card(title, sub, foot) { | |
| const c = document.createElement('canvas'); c.width = 1080; c.height = 1350; | |
| const g = c.getContext('2d'); const S = SKINS[store('kenwren:skin') || 'dusk'] || SKINS.dusk; | |
| const grd = g.createLinearGradient(0, 0, 1080, 1350); grd.addColorStop(0, S.a); grd.addColorStop(1, '#11141B'); | |
| g.fillStyle = grd; g.fillRect(0, 0, 1080, 1350); | |
| g.fillStyle = S.b; g.font = '900 60px system-ui, sans-serif'; g.fillText('KENWREN', 80, 130); | |
| g.fillStyle = '#fff'; g.font = '900 96px system-ui, sans-serif'; | |
| const words = title.split(' '); let line = '', y = 420; | |
| for (const w of words) { if (g.measureText(`${line}${w} `).width > 920) { g.fillText(line, 80, y); y += 110; line = ''; } line += `${w} `; } | |
| g.fillText(line, 80, y); | |
| g.fillStyle = S.b; g.font = '800 64px system-ui, sans-serif'; g.fillText(sub, 80, y + 120); | |
| g.fillStyle = '#ffffffcc'; g.font = '600 40px system-ui, sans-serif'; g.fillText(foot, 80, y + 200); | |
| for (let k = 0; k < 10; k++) { g.fillStyle = k % 3 ? '#ffffff22' : S.b; rr(g, 80 + k * 92, 1060, 76, 76, 18); g.fill(); } | |
| g.fillStyle = '#ffffffaa'; g.font = '600 32px system-ui, sans-serif'; g.fillText('Free: cybermaxtools.com/games/kenwren', 80, 1290); | |
| return c; | |
| } | |
| // ------------------------------------------------------------------ themes, pass, start | |
| function renderSkins() { | |
| const el = $('skins'); el.innerHTML = ''; | |
| const cur = store('kenwren:skin') || 'dusk'; | |
| for (const [id, s] of Object.entries(SKINS)) { | |
| const b = document.createElement('button'); | |
| b.type = 'button'; b.title = `${s.name} theme${s.free ? '' : ' (Fun Pass)'}`; b.setAttribute('aria-label', b.title); | |
| b.style.background = `linear-gradient(135deg, ${s.a} 50%, ${s.b} 50%)`; b.setAttribute('aria-pressed', String(id === cur)); | |
| if (!s.free && !pass.isPro()) b.classList.add('locked'); | |
| b.onclick = () => { if (!s.free && !pass.isPro()) { pass.open('theme'); return; } store('kenwren:skin', id); document.documentElement.dataset.skin = id === 'dusk' ? '' : id; renderSkins(); }; | |
| el.appendChild(b); | |
| } | |
| } | |
| document.addEventListener('click', (e) => { const b = e.target.closest('[data-pass]'); if (b) pass.open(b.dataset.pass); }); | |
| pass.onChange((pro) => { | |
| if (!pro && (store('kenwren:skin') || 'dusk') !== 'dusk') { store('kenwren:skin', 'dusk'); document.documentElement.dataset.skin = ''; } | |
| if (!pro) packs = packs.filter((p) => PACKS[p].free); | |
| renderSkins(); renderPacks(); | |
| }); | |
| // Screenshot helper for scripts/games/render_assets.mjs and studio shots (no effect on normal play). | |
| window.__cmxShot = async (stage = 1) => { | |
| if (stage === 2) { | |
| quiz = { id: 'demo1234', name: 'Sam', questions: suggest('demo') }; mode = 'play'; answers = [0, 1, 0, 2, 1, 0, 3, 1, 0, 2]; | |
| resultScreen({ name: 'Sam', score: 8, of: 10, rank: 2, board: [{ nick: 'Alex', score: 9 }, { nick: 'Jo', score: 8 }, { nick: 'Riley', score: 6 }], answers: [0, 1, 0, 2, 1, 0, 2, 1, 3, 2] }); | |
| return; | |
| } | |
| mode = 'make'; name = 'Sam'; qs = suggest('demo'); answers = [0]; i = 1; ask(); | |
| document.querySelector('.opt').setAttribute('aria-pressed', 'true'); | |
| }; | |
| initSolo({ show, path: PATH }); | |
| $('home-btn').onclick = () => { renderHome(); show('s-home'); }; | |
| const q = new URLSearchParams(location.search); | |
| if (/^[a-z0-9]{8}$/.test(q.get('q') || '')) loadQuiz(q.get('q'), q.get('t')); | |
| else if (!soloRoute(location.search)) show('s-home'); | |