cybermax / kenwren /game.js
CyberMax-tools's picture
Deploy kenwren to the hub
f6d583b verified
Raw History Blame Contribute Delete
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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');