Spaces:
Running
Running
Deploy kenwren to the hub
Browse files- kenwren/cmx.js +3 -1
- kenwren/funpass.js +2 -2
- kenwren/game.js +6 -2
- kenwren/index.html +65 -20
- kenwren/juice.js +77 -0
- kenwren/levels.js +189 -0
- kenwren/solo.js +261 -0
- kenwren/style.css +42 -0
- kenwren/sw.js +2 -2
kenwren/cmx.js
CHANGED
|
@@ -129,7 +129,7 @@ export function pwa(game, btn) {
|
|
| 129 |
let deferred = null;
|
| 130 |
let wanted = false;
|
| 131 |
const ios = /iphone|ipad|ipod/i.test(navigator.userAgent) && !/crios|fxios/i.test(navigator.userAgent);
|
| 132 |
-
window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferred = e; if (wanted && btn) btn.hidden = false; });
|
| 133 |
window.addEventListener('appinstalled', () => { count(game, 'install', 'prompt'); if (btn) btn.hidden = true; });
|
| 134 |
if (btn) {
|
| 135 |
btn.addEventListener('click', async () => {
|
|
@@ -137,10 +137,12 @@ export function pwa(game, btn) {
|
|
| 137 |
deferred.prompt();
|
| 138 |
const r = await deferred.userChoice.catch(() => null);
|
| 139 |
if (r && r.outcome === 'accepted') btn.hidden = true;
|
|
|
|
| 140 |
deferred = null;
|
| 141 |
} else if (ios) {
|
| 142 |
toast('Tap the Share button, then "Add to Home Screen".', 5000);
|
| 143 |
count(game, 'install', 'ios-hint');
|
|
|
|
| 144 |
}
|
| 145 |
});
|
| 146 |
}
|
|
|
|
| 129 |
let deferred = null;
|
| 130 |
let wanted = false;
|
| 131 |
const ios = /iphone|ipad|ipod/i.test(navigator.userAgent) && !/crios|fxios/i.test(navigator.userAgent);
|
| 132 |
+
window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferred = e; if (wanted && btn && !store(`cmx-install-no:${game}`)) btn.hidden = false; });
|
| 133 |
window.addEventListener('appinstalled', () => { count(game, 'install', 'prompt'); if (btn) btn.hidden = true; });
|
| 134 |
if (btn) {
|
| 135 |
btn.addEventListener('click', async () => {
|
|
|
|
| 137 |
deferred.prompt();
|
| 138 |
const r = await deferred.userChoice.catch(() => null);
|
| 139 |
if (r && r.outcome === 'accepted') btn.hidden = true;
|
| 140 |
+
else if (r && r.outcome === 'dismissed') { store(`cmx-install-no:${game}`, 1); btn.hidden = true; } // "Not now" is remembered
|
| 141 |
deferred = null;
|
| 142 |
} else if (ios) {
|
| 143 |
toast('Tap the Share button, then "Add to Home Screen".', 5000);
|
| 144 |
count(game, 'install', 'ios-hint');
|
| 145 |
+
store(`cmx-install-no:${game}`, 1);
|
| 146 |
}
|
| 147 |
});
|
| 148 |
}
|
kenwren/funpass.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
// CyberMax Fun Pass: ONE optional supporter pass for every CyberMax game (copied into each game's web/ folder).
|
| 2 |
// Free games stay free. The pass removes the supporter nudges and unlocks extra packs, the full archive and themes.
|
| 3 |
-
// Monthly $2.99 · Yearly $19 ·
|
| 4 |
// Buy: counted redirect https://data.cybermaxtools.com/buy/st-fun-pass-<plan>?s=game-<id> -> Stripe checkout.
|
| 5 |
// After checkout Stripe sends the buyer to data.cybermaxtools.com/unlock/fun-pass?session_id=..., which checks
|
| 6 |
// the Checkout Session (and, for a subscription, that it is still active) and returns to /games/ with #cmx-pro=<code>.
|
|
@@ -15,7 +15,7 @@ const RECHECK = 3 * 864e5; // subscriptions are re-checked every 3 days; a cance
|
|
| 15 |
export const PLANS = [
|
| 16 |
{ id: 'monthly', label: 'Monthly', price: '$2.99', per: '/month', note: 'Cancel any time' },
|
| 17 |
{ id: 'yearly', label: 'Yearly', price: '$19', per: '/year', note: 'Save 47%', best: true },
|
| 18 |
-
{ id: 'unlock', label: '
|
| 19 |
];
|
| 20 |
export const COMMON_PERKS = ['No supporter nudges, ever', 'Extra puzzle packs in every game', 'The full archive of past dailies', 'Custom colour themes'];
|
| 21 |
|
|
|
|
| 1 |
// CyberMax Fun Pass: ONE optional supporter pass for every CyberMax game (copied into each game's web/ folder).
|
| 2 |
// Free games stay free. The pass removes the supporter nudges and unlocks extra packs, the full archive and themes.
|
| 3 |
+
// Monthly $2.99 · Yearly $19 · Unlock all $4.99 once (Stripe Payment Links, pack revenue/packs/stripe/fun-pass).
|
| 4 |
// Buy: counted redirect https://data.cybermaxtools.com/buy/st-fun-pass-<plan>?s=game-<id> -> Stripe checkout.
|
| 5 |
// After checkout Stripe sends the buyer to data.cybermaxtools.com/unlock/fun-pass?session_id=..., which checks
|
| 6 |
// the Checkout Session (and, for a subscription, that it is still active) and returns to /games/ with #cmx-pro=<code>.
|
|
|
|
| 15 |
export const PLANS = [
|
| 16 |
{ id: 'monthly', label: 'Monthly', price: '$2.99', per: '/month', note: 'Cancel any time' },
|
| 17 |
{ id: 'yearly', label: 'Yearly', price: '$19', per: '/year', note: 'Save 47%', best: true },
|
| 18 |
+
{ id: 'unlock', label: 'Unlock all', price: '$4.99', per: ' once', note: 'Pay once, keep forever' },
|
| 19 |
];
|
| 20 |
export const COMMON_PERKS = ['No supporter nudges, ever', 'Extra puzzle packs in every game', 'The full archive of past dailies', 'Custom colour themes'];
|
| 21 |
|
kenwren/game.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
import { QUESTIONS, BY_ID, PACKS, QUIZ_LEN, cleanName, suggest, verdict } from './core.js';
|
| 3 |
import { API, SITE, count, store, toast, share, intents, download, canvasBlob, pwa, rr, themeToggle } from './cmx.js';
|
| 4 |
import * as pass from './funpass.js';
|
|
|
|
| 5 |
|
| 6 |
const GAME = 'kenwren';
|
| 7 |
const PATH = '/games/kenwren/'; // canonical path on the CyberMax site (share links)
|
|
@@ -11,7 +12,7 @@ const SKINS = { dusk: { name: 'Dusk', a: '#2A1B3D', b: '#FFD166', free: true },
|
|
| 11 |
let mode = 'make'; // make | play
|
| 12 |
let name = '', packs = ['classic'], qs = [], answers = [], i = 0, quiz = null, swaps = 0;
|
| 13 |
|
| 14 |
-
pass.init({ game: GAME, name: 'Kenwren', perks: ['Deep and Spicy (clean) question packs', 'Quizzes last a year instead of 30 days', 'Sunrise, Lagoon, Bubblegum and Paper themes'] });
|
| 15 |
const install = pwa(GAME, $('install'));
|
| 16 |
themeToggle($('theme'));
|
| 17 |
|
|
@@ -237,5 +238,8 @@ window.__cmxShot = async (stage = 1) => {
|
|
| 237 |
document.querySelector('.opt').setAttribute('aria-pressed', 'true');
|
| 238 |
};
|
| 239 |
|
|
|
|
|
|
|
| 240 |
const q = new URLSearchParams(location.search);
|
| 241 |
-
if (/^[a-z0-9]{8}$/.test(q.get('q') || '')) loadQuiz(q.get('q'), q.get('t'));
|
|
|
|
|
|
| 2 |
import { QUESTIONS, BY_ID, PACKS, QUIZ_LEN, cleanName, suggest, verdict } from './core.js';
|
| 3 |
import { API, SITE, count, store, toast, share, intents, download, canvasBlob, pwa, rr, themeToggle } from './cmx.js';
|
| 4 |
import * as pass from './funpass.js';
|
| 5 |
+
import { initSolo, route as soloRoute, renderHome } from './solo.js';
|
| 6 |
|
| 7 |
const GAME = 'kenwren';
|
| 8 |
const PATH = '/games/kenwren/'; // canonical path on the CyberMax site (share links)
|
|
|
|
| 12 |
let mode = 'make'; // make | play
|
| 13 |
let name = '', packs = ['classic'], qs = [], answers = [], i = 0, quiz = null, swaps = 0;
|
| 14 |
|
| 15 |
+
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'] });
|
| 16 |
const install = pwa(GAME, $('install'));
|
| 17 |
themeToggle($('theme'));
|
| 18 |
|
|
|
|
| 238 |
document.querySelector('.opt').setAttribute('aria-pressed', 'true');
|
| 239 |
};
|
| 240 |
|
| 241 |
+
initSolo({ show, path: PATH });
|
| 242 |
+
$('home-btn').onclick = () => { renderHome(); show('s-home'); };
|
| 243 |
const q = new URLSearchParams(location.search);
|
| 244 |
+
if (/^[a-z0-9]{8}$/.test(q.get('q') || '')) loadQuiz(q.get('q'), q.get('t'));
|
| 245 |
+
else if (!soloRoute(location.search)) show('s-home');
|
kenwren/index.html
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
| 3 |
-
<title>Kenwren:
|
| 4 |
-
<meta name="description" content="Make a free
|
| 5 |
<link rel="canonical" href="https://cybermaxtools.com/games/kenwren/">
|
| 6 |
<meta property="og:type" content="website"><meta property="og:site_name" content="CyberMax">
|
| 7 |
-
<meta property="og:title" content="
|
| 8 |
-
<meta property="og:description" content="10 questions
|
| 9 |
<meta property="og:url" content="https://cybermaxtools.com/games/kenwren/">
|
| 10 |
<meta property="og:image" content="https://cybermaxtools.com/games/kenwren/og.png"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">
|
| 11 |
<meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="https://cybermaxtools.com/games/kenwren/og.png">
|
|
@@ -15,19 +15,54 @@
|
|
| 15 |
<meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-title" content="Kenwren">
|
| 16 |
<link rel="stylesheet" href="fun.css"><link rel="stylesheet" href="style.css">
|
| 17 |
<script>try{var t=localStorage.getItem('cm-theme');if(t)document.documentElement.dataset.theme=t;var s=localStorage.getItem('kenwren:skin');if(s)document.documentElement.dataset.skin=JSON.parse(s)}catch(e){}</script>
|
| 18 |
-
<
|
|
|
|
|
|
|
|
|
|
| 19 |
</head><body>
|
| 20 |
<div class="wrap">
|
| 21 |
<header class="top"><a class="brand" href="https://cybermaxtools.com/games/"><svg viewBox="0 0 512 512" aria-hidden="true"><clipPath id="lg"><rect width="512" height="512" rx="116"/></clipPath><g clip-path="url(#lg)"><rect width="512" height="512" fill="#FF6A3D"/><path d="M512 250V512H250Z" fill="#D9481F"/></g><g transform="translate(86 86) scale(3.4)"><path d="M66 27 A27 27 0 1 0 66 73" fill="none" stroke="#11141B" stroke-width="17" stroke-linecap="round"/><rect x="66" y="41" width="20" height="18" rx="4" fill="#11141B"/></g></svg>CyberMax</a>
|
| 22 |
-
<nav><a href="https://cybermaxtools.com/games/">Games</a></nav><button class="icon" id="mine-btn" type="button" aria-label="My quizzes">📋</button><button class="icon" id="theme" type="button" aria-label="Switch light or dark">◐</button></header>
|
| 23 |
<main>
|
| 24 |
<section class="stage" id="stage" aria-live="polite">
|
| 25 |
<div class="screen" id="s-home">
|
| 26 |
<p class="kick">Free · no sign-up <span class="pro-badge">PASS</span></p>
|
| 27 |
-
<h1>How well do your friends <em>really</em> know you?</h1>
|
| 28 |
<p class="lede">Answer 10 fun questions about yourself. Share the link. Friends guess your answers and land on your private scoreboard.</p>
|
| 29 |
<div class="row"><button class="btn big" id="start" type="button">Make my quiz</button><button class="btn ghost" id="install" type="button" hidden>📲 Add to Home Screen</button></div>
|
| 30 |
<ol class="how"><li><b>1 minute</b> to make it</li><li><b>One link</b> for every friend</li><li><b>Live scoreboard</b> only you can open</li></ol>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
</div>
|
| 32 |
<div class="screen" id="s-name" hidden>
|
| 33 |
<h2 id="name-q">What's your name?</h2>
|
|
@@ -65,23 +100,33 @@
|
|
| 65 |
</div>
|
| 66 |
<p class="err" id="err" role="alert" hidden></p>
|
| 67 |
</section>
|
| 68 |
-
<div class="nudge" id="nudge"><span>
|
| 69 |
<div class="row skins" id="skins" aria-label="Colour theme"></div>
|
| 70 |
|
| 71 |
-
<section class="seo">
|
| 72 |
-
<h2>
|
| 73 |
-
<p>
|
| 74 |
-
<
|
| 75 |
-
<
|
| 76 |
-
<h3>
|
| 77 |
-
<p>
|
| 78 |
-
<
|
| 79 |
-
<
|
| 80 |
-
<
|
| 81 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
</section>
|
| 83 |
</main>
|
| 84 |
<footer>© 2026 CyberMax · <a href="https://cybermaxtools.com/games/">Games</a> · <a href="https://cybermaxtools.com/toys/">Toys</a> · <a href="https://cybermaxtools.com/store/">Store</a> · <a href="https://cybermaxtools.com/privacy">Privacy</a> · <a href="https://cybermaxtools.com/terms">Terms</a></footer>
|
| 85 |
</div>
|
| 86 |
<script type="module" src="game.js"></script>
|
| 87 |
-
<
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
| 3 |
+
<title>Kenwren: Free Friend Quiz Maker, How Well Do Your Friends Know You?</title>
|
| 4 |
+
<meta name="description" content="Make a free friend quiz with Kenwren: answer 10 questions, send one link, see who knows you best. Or play solo: 40 levels and a daily read. No sign-up.">
|
| 5 |
<link rel="canonical" href="https://cybermaxtools.com/games/kenwren/">
|
| 6 |
<meta property="og:type" content="website"><meta property="og:site_name" content="CyberMax">
|
| 7 |
+
<meta property="og:title" content="Kenwren: Free Friend Quiz Maker">
|
| 8 |
+
<meta property="og:description" content="Make a free friend quiz with Kenwren: answer 10 questions, send one link, see who knows you best. Or play solo: 40 levels and a daily read. No sign-up.">
|
| 9 |
<meta property="og:url" content="https://cybermaxtools.com/games/kenwren/">
|
| 10 |
<meta property="og:image" content="https://cybermaxtools.com/games/kenwren/og.png"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">
|
| 11 |
<meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="https://cybermaxtools.com/games/kenwren/og.png">
|
|
|
|
| 15 |
<meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-title" content="Kenwren">
|
| 16 |
<link rel="stylesheet" href="fun.css"><link rel="stylesheet" href="style.css">
|
| 17 |
<script>try{var t=localStorage.getItem('cm-theme');if(t)document.documentElement.dataset.theme=t;var s=localStorage.getItem('kenwren:skin');if(s)document.documentElement.dataset.skin=JSON.parse(s)}catch(e){}</script>
|
| 18 |
+
<meta name="twitter:title" content="Kenwren: Free Friend Quiz Maker">
|
| 19 |
+
<meta name="twitter:description" content="Make a free friend quiz with Kenwren: answer 10 questions, send one link, see who knows you best. Or play solo: 40 levels and a daily read. No sign-up.">
|
| 20 |
+
<style>.h1kw{display:block;font-size:.42em;font-weight:600;opacity:.75;letter-spacing:.01em;margin-top:.15em}.seo ol,.seo ul{padding-left:1.2em}.seo li{margin:.25em 0}</style>
|
| 21 |
+
<script type="application/ld+json">{"@context": "https://schema.org", "@graph": [{"@type": "VideoGame", "@id": "https://cybermaxtools.com/games/kenwren/#game", "name": "Kenwren", "alternateName": "Friend quiz game online free", "url": "https://cybermaxtools.com/games/kenwren/", "image": "https://cybermaxtools.com/games/kenwren/og.png", "description": "Kenwren is a free friend quiz maker. Answer 10 light questions about yourself, share one link with friends, family or a group chat, and see who knows you best on your private scoreboard. Solo, read a made-up friend's clue card and guess their answers across 40 levels and a new daily read each day. No account, no email.", "genre": ["Quiz", "Social", "Party game"], "gamePlatform": ["Web browser", "Android", "iOS", "Windows", "macOS", "ChromeOS"], "applicationCategory": "Game", "operatingSystem": "Any (web browser)", "playMode": "SinglePlayer", "inLanguage": "en", "isAccessibleForFree": true, "offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"}, "author": {"@type": "Organization", "name": "CyberMax", "url": "https://cybermaxtools.com/"}, "publisher": {"@type": "Organization", "name": "CyberMax"}, "datePublished": "2026-10-02", "keywords": "Friend quiz game online free, free friend quiz maker, unblocked, no download, browser game", "trailer": {"@id": "https://cybermaxtools.com/games/kenwren/#video"}}, {"@type": "FAQPage", "@id": "https://cybermaxtools.com/games/kenwren/#faq", "mainEntity": [{"@type": "Question", "name": "Is Kenwren free?", "acceptedAnswer": {"@type": "Answer", "text": "Yes. The Classic quiz pack, solo levels 1-20 and the daily read are free."}}, {"@type": "Question", "name": "Do friends need an account?", "acceptedAnswer": {"@type": "Answer", "text": "No, they just open the link and type a nickname."}}, {"@type": "Question", "name": "Can friends see my answers?", "acceptedAnswer": {"@type": "Answer", "text": "Not until they finish; scoring happens on our server."}}, {"@type": "Question", "name": "How long are quizzes kept?", "acceptedAnswer": {"@type": "Answer", "text": "30 days free, or a year with the Fun Pass."}}, {"@type": "Question", "name": "Does it work in WhatsApp and Instagram?", "acceptedAnswer": {"@type": "Answer", "text": "Yes, the link opens in any browser."}}]}, {"@type": "VideoObject", "name": "Kenwren demo: How well do your friends really know you?", "description": "A 15-second screen recording of the live Kenwren page: Make a quiz about yourself; Answer 10 quick questions; Send the link. Friends guess, you see the scores. Real result, no voice.", "thumbnailUrl": ["https://cybermax.cybermax-tools.workers.dev/media/demos/kenwren/poster.jpg"], "uploadDate": "2026-10-02T18:08:58+00:00", "duration": "PT15S", "contentUrl": "https://cybermax.cybermax-tools.workers.dev/media/demos/kenwren/demo-16x9.mp4", "embedUrl": "https://cybermaxtools.com/games/kenwren/", "inLanguage": "en", "publisher": {"@type": "Organization", "name": "CyberMax", "logo": {"@type": "ImageObject", "url": "https://cybermax.cybermax-tools.workers.dev/store/img/favicon.webp"}}, "@id": "https://cybermaxtools.com/games/kenwren/#video"}]}</script>
|
| 22 |
</head><body>
|
| 23 |
<div class="wrap">
|
| 24 |
<header class="top"><a class="brand" href="https://cybermaxtools.com/games/"><svg viewBox="0 0 512 512" aria-hidden="true"><clipPath id="lg"><rect width="512" height="512" rx="116"/></clipPath><g clip-path="url(#lg)"><rect width="512" height="512" fill="#FF6A3D"/><path d="M512 250V512H250Z" fill="#D9481F"/></g><g transform="translate(86 86) scale(3.4)"><path d="M66 27 A27 27 0 1 0 66 73" fill="none" stroke="#11141B" stroke-width="17" stroke-linecap="round"/><rect x="66" y="41" width="20" height="18" rx="4" fill="#11141B"/></g></svg>CyberMax</a>
|
| 25 |
+
<nav><a href="https://cybermaxtools.com/games/">Games</a></nav><button class="icon" id="home-btn" type="button" aria-label="Kenwren home">🏠</button><button class="icon" id="mine-btn" type="button" aria-label="My quizzes">📋</button><button class="icon" id="mute" type="button" aria-label="Sound on or off">🔊</button><button class="icon" id="theme" type="button" aria-label="Switch light or dark">◐</button></header>
|
| 26 |
<main>
|
| 27 |
<section class="stage" id="stage" aria-live="polite">
|
| 28 |
<div class="screen" id="s-home">
|
| 29 |
<p class="kick">Free · no sign-up <span class="pro-badge">PASS</span></p>
|
| 30 |
+
<h1>How well do your friends <em>really</em> know you? <small class="h1kw">Free friend quiz maker</small></h1>
|
| 31 |
<p class="lede">Answer 10 fun questions about yourself. Share the link. Friends guess your answers and land on your private scoreboard.</p>
|
| 32 |
<div class="row"><button class="btn big" id="start" type="button">Make my quiz</button><button class="btn ghost" id="install" type="button" hidden>📲 Add to Home Screen</button></div>
|
| 33 |
<ol class="how"><li><b>1 minute</b> to make it</li><li><b>One link</b> for every friend</li><li><b>Live scoreboard</b> only you can open</li></ol>
|
| 34 |
+
<div class="solo-strip">
|
| 35 |
+
<p class="kick">Or play solo now</p>
|
| 36 |
+
<div class="row"><button class="btn" id="daily-btn" type="button"><span>🗓 Daily read #<span id="daily-n"></span><span id="daily-done" hidden> ✓</span></span></button><button class="btn ghost" id="levels-btn" type="button">▶ 40 levels <span id="levels-stars" class="small"></span></button><span class="streak" id="streak" hidden>🔥 <b>0</b></span></div>
|
| 37 |
+
<p class="muted small">Read a made-up friend's clue card and guess their answers. A new daily read for everyone each day. Levels 1–20 free; 21–40 come with the Fun Pass.</p>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
<div class="screen" id="s-map" hidden>
|
| 41 |
+
<div class="hud"><button class="btn ghost sm" id="map-home" type="button">← Home</button><b>Solo levels</b><span id="map-stars"></span></div>
|
| 42 |
+
<p class="goal">Goal: read each friend's clue card and guess their answers. Clear a level to open the next.</p>
|
| 43 |
+
<div id="map"></div>
|
| 44 |
+
</div>
|
| 45 |
+
<div class="screen" id="s-solo" hidden>
|
| 46 |
+
<div class="hud"><button class="btn ghost sm" id="so-map" type="button" aria-label="Level map">🗺</button><b id="so-title"></b><button class="btn sm" id="so-retry" type="button" aria-label="Restart (R)">↻</button></div>
|
| 47 |
+
<p class="goal" id="so-goal"></p>
|
| 48 |
+
<div class="pips" id="so-pips"></div>
|
| 49 |
+
<div class="clue-card" id="so-card"><p class="who"><span id="so-emoji"></span> <b id="so-who"></b></p><ul id="so-clues"></ul><p class="hid">🙈</p><div class="peek" id="so-peek" hidden><i></i></div><button class="btn sm" id="so-ready" type="button" hidden>Ready ▶</button></div>
|
| 50 |
+
<div id="so-q-wrap">
|
| 51 |
+
<div class="tbar" id="so-timer" hidden><i></i></div>
|
| 52 |
+
<h2 id="so-q"></h2>
|
| 53 |
+
<div class="opts" id="so-opts"></div>
|
| 54 |
+
</div>
|
| 55 |
+
<div class="hand" id="so-hand" aria-hidden="true" hidden>👆</div>
|
| 56 |
+
</div>
|
| 57 |
+
<div class="screen" id="s-done" hidden>
|
| 58 |
+
<p class="kick" id="dn-kick"></p>
|
| 59 |
+
<h2 id="dn-title"></h2>
|
| 60 |
+
<div class="stars" id="dn-stars"></div>
|
| 61 |
+
<p class="score" id="dn-score"></p>
|
| 62 |
+
<p class="grid" id="dn-grid"></p>
|
| 63 |
+
<p class="muted" id="dn-note"></p>
|
| 64 |
+
<div class="row"><button class="btn big" id="dn-next" type="button">Next ▶</button><button class="btn" id="dn-retry" type="button">↻ Retry</button><button class="btn ghost" id="dn-share" type="button">Share</button><button class="btn ghost" id="dn-map" type="button">🗺 Map</button></div>
|
| 65 |
+
<div id="dn-after"></div>
|
| 66 |
</div>
|
| 67 |
<div class="screen" id="s-name" hidden>
|
| 68 |
<h2 id="name-q">What's your name?</h2>
|
|
|
|
| 100 |
</div>
|
| 101 |
<p class="err" id="err" role="alert" hidden></p>
|
| 102 |
</section>
|
| 103 |
+
<div class="nudge" id="nudge"><span>Get more with the <b>Fun Pass</b>: solo levels 21–40, Deep and Spicy question packs, quizzes that last a year, themes. Works in every CyberMax game.</span><button class="btn pass sm" data-pass="nudge" type="button">See the pass</button></div>
|
| 104 |
<div class="row skins" id="skins" aria-label="Colour theme"></div>
|
| 105 |
|
| 106 |
+
<section class="seo" id="guide">
|
| 107 |
+
<h2>Kenwren: friend quiz game online free</h2>
|
| 108 |
+
<p>Kenwren is a free friend quiz maker. Answer 10 light questions about yourself, share one link with friends, family or a group chat, and see who knows you best on your private scoreboard. Solo, read a made-up friend's clue card and guess their answers across 40 levels and a new daily read each day. No account, no email.</p>
|
| 109 |
+
<h2>How to play Kenwren</h2>
|
| 110 |
+
<ol><li>Pick your answers to 10 questions.</li><li>Get one link to share.</li><li>Friends type a nickname and guess your answers.</li><li>They see their score and rank when done.</li><li>Open your scoreboard any time.</li></ol>
|
| 111 |
+
<h3>Controls</h3>
|
| 112 |
+
<p><b>Phone and tablet:</b> Tap your answers and share the link in any chat app.</p>
|
| 113 |
+
<p><b>Desktop, laptop and Chromebook:</b> Click your answers, copy the link and paste it anywhere.</p>
|
| 114 |
+
<h2>Kenwren tips and strategy</h2>
|
| 115 |
+
<ul><li>Mix easy and tricky questions so the scoreboard spreads out.</li><li>Share in a group chat for the most players.</li><li>Swap quizzes with a friend and compare scores.</li><li>In solo levels, match each clue to its question before you answer; from level 21 the card hides, so read it first.</li></ul>
|
| 116 |
+
<h2>Game modes</h2>
|
| 117 |
+
<ul><li>Classic pack (free, 20 questions).</li><li>Solo levels 1-20 and the daily read (free).</li><li>Fun Pass: solo levels 21-40, Deep and Spicy (clean) packs, quizzes kept for a year.</li></ul>
|
| 118 |
+
<p>Free to play with no ads and no sign-up. The optional <a href="https://cybermaxtools.com/games/#pass">CyberMax Fun Pass</a> ($2.99/month, $19/year or $29.99 for life) adds cosmetic extras and bonus modes in every CyberMax game.</p>
|
| 119 |
+
<h2>Kenwren FAQ</h2>
|
| 120 |
+
<h3>Is Kenwren free?</h3><p>Yes. The Classic quiz pack, solo levels 1-20 and the daily read are free.</p>
|
| 121 |
+
<h3>Do friends need an account?</h3><p>No, they just open the link and type a nickname.</p>
|
| 122 |
+
<h3>Can friends see my answers?</h3><p>Not until they finish; scoring happens on our server.</p>
|
| 123 |
+
<h3>How long are quizzes kept?</h3><p>30 days free, or a year with the Fun Pass.</p>
|
| 124 |
+
<h3>Does it work in WhatsApp and Instagram?</h3><p>Yes, the link opens in any browser.</p>
|
| 125 |
+
<h2>Similar free games</h2>
|
| 126 |
+
<div class="more"><a href="https://cybermaxtools.com/games/pawfoil/"><b>Pawfoil</b><span>Make your pet a holo trading card</span></a><a href="https://cybermaxtools.com/games/townduel/"><b>Townduel</b><span>Which US town costs more? 10 daily duels</span></a><a href="https://cybermaxtools.com/games/pathmoor/"><b>Pathmoor</b><span>Turn stones until every path glows</span></a><a href="https://cybermaxtools.com/games/"><b>All CyberMax games</b><span>Free browser games, no download</span></a></div>
|
| 127 |
</section>
|
| 128 |
</main>
|
| 129 |
<footer>© 2026 CyberMax · <a href="https://cybermaxtools.com/games/">Games</a> · <a href="https://cybermaxtools.com/toys/">Toys</a> · <a href="https://cybermaxtools.com/store/">Store</a> · <a href="https://cybermaxtools.com/privacy">Privacy</a> · <a href="https://cybermaxtools.com/terms">Terms</a></footer>
|
| 130 |
</div>
|
| 131 |
<script type="module" src="game.js"></script>
|
| 132 |
+
</body></html>
|
kenwren/juice.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Kenwren juice: synthesised WebAudio sounds (no audio files), confetti particles and screen shake.
|
| 2 |
+
// Mute is remembered in localStorage (via the kit's store(), which never throws). Shake respects reduced motion.
|
| 3 |
+
// (c) 2026 CyberMax.
|
| 4 |
+
import { store } from './cmx.js';
|
| 5 |
+
|
| 6 |
+
let ctx = null;
|
| 7 |
+
let muted = !!store('kenwren:mute');
|
| 8 |
+
export const isMuted = () => muted;
|
| 9 |
+
export function setMuted(m) { muted = !!m; store('kenwren:mute', muted ? 1 : 0); }
|
| 10 |
+
const calm = () => { try { return matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; } };
|
| 11 |
+
|
| 12 |
+
function ac() {
|
| 13 |
+
if (muted) return null;
|
| 14 |
+
try {
|
| 15 |
+
if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)();
|
| 16 |
+
if (ctx.state === 'suspended') ctx.resume();
|
| 17 |
+
return ctx;
|
| 18 |
+
} catch { return null; }
|
| 19 |
+
}
|
| 20 |
+
function tone(freq, at, dur, type = 'sine', vol = 0.18, slide = 0) {
|
| 21 |
+
const a = ac(); if (!a) return;
|
| 22 |
+
const t = a.currentTime + at;
|
| 23 |
+
const o = a.createOscillator(); const g = a.createGain();
|
| 24 |
+
o.type = type; o.frequency.setValueAtTime(freq, t);
|
| 25 |
+
if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(40, freq + slide), t + dur);
|
| 26 |
+
g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(vol, t + 0.012); g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
| 27 |
+
o.connect(g); g.connect(a.destination); o.start(t); o.stop(t + dur + 0.02);
|
| 28 |
+
}
|
| 29 |
+
export const sfx = {
|
| 30 |
+
tap() { tone(660, 0, 0.05, 'triangle', 0.08); },
|
| 31 |
+
ok() { tone(784, 0, 0.09, 'triangle'); tone(1175, 0.07, 0.14, 'triangle'); },
|
| 32 |
+
bad() { tone(196, 0, 0.22, 'sawtooth', 0.09, -90); tone(147, 0.05, 0.22, 'square', 0.05, -60); },
|
| 33 |
+
star(k = 0) { tone([523, 659, 784][k] || 784, 0, 0.18, 'triangle', 0.16); tone(([523, 659, 784][k] || 784) * 2, 0.04, 0.16, 'sine', 0.06); },
|
| 34 |
+
win() { [523, 659, 784, 1047].forEach((f, k) => tone(f, k * 0.09, 0.22, 'triangle', 0.15)); },
|
| 35 |
+
lose() { tone(330, 0, 0.18, 'triangle', 0.12, -60); tone(247, 0.16, 0.3, 'triangle', 0.12, -60); },
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
export function shake(el) {
|
| 39 |
+
if (!el || calm()) return;
|
| 40 |
+
el.classList.remove('shake'); void el.offsetWidth; el.classList.add('shake');
|
| 41 |
+
setTimeout(() => el.classList.remove('shake'), 420);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
let cv = null; let parts = []; let raf = 0;
|
| 45 |
+
const COLORS = ['#FFD166', '#FF6A3D', '#2FD3A0', '#B8FFF0', '#FF7AC6', '#ffffff'];
|
| 46 |
+
/** Burst of particles from an element (or a point). `n` pieces; bad=true gives a small grey puff instead. */
|
| 47 |
+
export function burst(from, n = 26, bad = false) {
|
| 48 |
+
try {
|
| 49 |
+
if (!cv) {
|
| 50 |
+
cv = document.createElement('canvas'); cv.className = 'fx'; cv.setAttribute('aria-hidden', 'true');
|
| 51 |
+
document.body.appendChild(cv);
|
| 52 |
+
}
|
| 53 |
+
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
| 54 |
+
if (cv.width !== innerWidth * dpr) { cv.width = innerWidth * dpr; cv.height = innerHeight * dpr; }
|
| 55 |
+
const r = from && from.getBoundingClientRect ? from.getBoundingClientRect() : { left: innerWidth / 2, top: innerHeight / 3, width: 0, height: 0 };
|
| 56 |
+
const x = r.left + r.width / 2; const y = r.top + r.height / 2;
|
| 57 |
+
const count = calm() ? Math.ceil(n / 3) : n;
|
| 58 |
+
for (let k = 0; k < count; k++) {
|
| 59 |
+
const a = Math.random() * Math.PI * 2; const v = (bad ? 1.5 : 3) + Math.random() * (bad ? 2 : 5);
|
| 60 |
+
parts.push({ x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v - (bad ? 0.5 : 3), life: 1, s: 4 + Math.random() * 5, c: bad ? '#9aa0ad' : COLORS[k % COLORS.length], rot: Math.random() * 6 });
|
| 61 |
+
}
|
| 62 |
+
if (!raf) raf = requestAnimationFrame(step);
|
| 63 |
+
} catch { /* never break the game */ }
|
| 64 |
+
}
|
| 65 |
+
function step() {
|
| 66 |
+
const g = cv.getContext('2d'); const dpr = cv.width / innerWidth;
|
| 67 |
+
g.setTransform(dpr, 0, 0, dpr, 0, 0); g.clearRect(0, 0, innerWidth, innerHeight);
|
| 68 |
+
parts = parts.filter((p) => p.life > 0);
|
| 69 |
+
for (const p of parts) {
|
| 70 |
+
p.x += p.vx; p.y += p.vy; p.vy += 0.22; p.vx *= 0.985; p.life -= 0.022; p.rot += 0.2;
|
| 71 |
+
g.globalAlpha = Math.max(0, p.life); g.fillStyle = p.c;
|
| 72 |
+
g.save(); g.translate(p.x, p.y); g.rotate(p.rot); g.fillRect(-p.s / 2, -p.s / 4, p.s, p.s / 2); g.restore();
|
| 73 |
+
}
|
| 74 |
+
g.globalAlpha = 1;
|
| 75 |
+
raf = parts.length ? requestAnimationFrame(step) : 0;
|
| 76 |
+
if (!raf) g.clearRect(0, 0, innerWidth, innerHeight);
|
| 77 |
+
}
|
kenwren/levels.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Kenwren solo mode: "Read a friend". Each level is a fictional friend with a clue card; guess how they would answer.
|
| 2 |
+
// Pure logic + data, no DOM (tested by tests/levels.test.mjs). Uses only the Classic questions from core.js, which are
|
| 3 |
+
// opinion/personality questions; the people and clues below are made up, so there are no facts to source.
|
| 4 |
+
// core.js is NOT changed by this file, so the toys Worker copy (kenwren-core.js) stays identical. (c) 2026 CyberMax.
|
| 5 |
+
import { BY_ID } from './core.js';
|
| 6 |
+
import { rng, hash, dayNumber } from './cmx.js';
|
| 7 |
+
|
| 8 |
+
export const FREE_MAX = 20; // levels 1-20 are free for everyone
|
| 9 |
+
export const SEED_MAX = 100000; // ?seed= range for "beat my score" links
|
| 10 |
+
export const DAILY_LEN = 6;
|
| 11 |
+
|
| 12 |
+
/** One clue line per option of every Classic question (same order as core.js opts). Each clue points at one answer. */
|
| 13 |
+
export const CLUES = {
|
| 14 |
+
c01: ['Has a group chat that plans every Saturday.', 'Owns a blanket with a pocket for the TV remote.', 'Collects a fridge magnet from every new town.', 'Spends weekends ticking off a to-do list.'],
|
| 15 |
+
c02: ['Grinds espresso beans before saying a word.', 'Keeps twelve kinds of loose-leaf in a drawer.', 'Latte at 8 AM, chamomile at 8 PM.', 'Hot drinks? Just a glass of water, thanks.'],
|
| 16 |
+
c03: ['Always has a crinkly bag of something salty.', 'Hides a cocoa bar in every coat pocket.', 'Brings an apple and a banana everywhere.', 'Calls cheddar a personality trait.'],
|
| 17 |
+
c04: ['Stares at birds with open jealousy.', 'Wishes they could vanish at every awkward party.', 'Hates commutes; wants to blink straight to the office.', 'Always wants to know what you are thinking.'],
|
| 18 |
+
c05: ['Up at 5 AM, smiling, already jogged.', 'Does their best work at 2 AM.', 'Disappears for a siesta at 3 PM.', 'Yawns at breakfast, lunch and dinner.'],
|
| 19 |
+
c06: ['Packs sunscreen and flip-flops first.', 'Owns hiking boots and a summit selfie.', 'Plans holidays around museums and skylines.', 'Has a 40-song driving playlist ready.'],
|
| 20 |
+
c07: ['Phone gallery: 900 photos of a tabby.', 'Stops to pet every pup on the street.', 'Home has a purring sofa and a barking doorbell.', 'Talks to a goldfish named Bubbles.'],
|
| 21 |
+
c08: ['Starts every party on the dance floor.', 'Ends every party in a two-hour kitchen chat.', 'Knows every late-night takeaway menu by heart.', 'Leaves every party before 11.'],
|
| 22 |
+
c09: ['Gets giddy when the first blossoms appear.', 'Lives for long, hot days.', 'Kicks piles of crunchy leaves on purpose.', 'Counts down to snow and scarves.'],
|
| 23 |
+
c10: ['Says fruit on pizza is not a crime.', 'Grabs the spicy salami slices first.', 'Orders anything with porcini on it.', 'Likes their pizza plain. Very plain.'],
|
| 24 |
+
c11: ['Carries two power banks, just in case.', 'Phone is never full, never empty.', 'Always asking: "Anyone got a charger?"', 'Calls you back from someone else\'s phone.'],
|
| 25 |
+
c12: ['Belts out ballads at every open mic.', 'Joins every flash-mob routine.', 'Always first to give the wedding toast.', 'Would skip any stage for a quiet night in.'],
|
| 26 |
+
c13: ['Makes the team plan and hands out the jobs.', 'Fills a whiteboard with wild ideas.', 'Quietly finishes everyone else\'s slides.', 'Group project? Last seen in week one.'],
|
| 27 |
+
c14: ['Rewatches the funniest films just to laugh again.', 'Watches scary movies with the lights off.', 'Cries at every on-screen wedding.', 'Loves car chases and explosions.'],
|
| 28 |
+
c15: ['Answers texts before you lock your phone.', 'Replies… eventually. Like, next Thursday.', 'Sends four-minute audio messages.', 'Answers everything with 👍 or 🙃.'],
|
| 29 |
+
c16: ['Cooks eggs, toast and beans every morning.', 'Owns six boxes of flakes and hoops.', 'Skips food; one mug of coffee is breakfast.', 'Forgets to eat anything until lunch.'],
|
| 30 |
+
c17: ['Can quote every sitcom line.', 'Remembers every team\'s results from last season.', 'Names any song in two notes.', 'Full of trivia nobody asked for.'],
|
| 31 |
+
c18: ['Bed made, shelves dusted, socks sorted.', 'Messy desk, but knows where everything is.', 'Lost a shoe in their own bedroom. Twice.', 'Tidy on Monday, tornado by Friday.'],
|
| 32 |
+
c19: ['Checks the shower for eight-legged guests.', 'Won\'t go up the glass lookout tower.', 'Hands shake before any presentation.', 'Sleeps with a night light on.'],
|
| 33 |
+
c20: ['Has a world map pinned with 50 places to go.', 'Browses home listings for fun.', 'Has a resignation letter drafted, just in case.', 'Would win and keep it a total secret.'],
|
| 34 |
+
};
|
| 35 |
+
export const CLUE_QS = Object.keys(CLUES);
|
| 36 |
+
|
| 37 |
+
/** Fictional friends (one per level, in order). */
|
| 38 |
+
const CAST = [
|
| 39 |
+
['Mo', '🦊'], ['Priya', '🐼'], ['Jules', '🐸'], ['Theo', '🐻'], ['Ana', '🦉'], ['Kai', '🐯'], ['Zara', '🦄'], ['Leo', '🦁'], ['Nia', '🐨'], ['Omar', '🐙'],
|
| 40 |
+
['Ivy', '🐝'], ['Ravi', '🐧'], ['Luna', '🐰'], ['Finn', '🐳'], ['Maya', '🦋'], ['Sol', '🐢'], ['Ezra', '🦝'], ['Rosa', '🐞'], ['Dev', '🐲'], ['Tess', '🦩'],
|
| 41 |
+
['Ari', '🐺'], ['Bea', '🐮'], ['Cal', '🦔'], ['Dani', '🐹'], ['Eli', '🦈'], ['Fay', '🦚'], ['Gus', '🐗'], ['Hana', '🐱'], ['Ike', '��'], ['Jo', '🐶'],
|
| 42 |
+
['Kit', '🦦'], ['Lou', '🦥'], ['Mei', '🐿️'], ['Ned', '🦬'], ['Ola', '🦜'], ['Pip', '🐣'], ['Quin', '🦭'], ['Remy', '🐭'], ['Suki', '🦢'], ['Tomi', '🐉'],
|
| 43 |
+
];
|
| 44 |
+
|
| 45 |
+
/** The four solo packs: each adds one twist; every 10th level is a challenge level. */
|
| 46 |
+
export const SOLO_PACKS = [
|
| 47 |
+
{ id: 'first', name: 'First Impressions', from: 1, to: 10, twist: 'Clues stay on screen.' },
|
| 48 |
+
{ id: 'chat', name: 'Group Chat', from: 11, to: 20, twist: 'Extra clues about other things mixed in.' },
|
| 49 |
+
{ id: 'memory', name: 'Memory Lane', from: 21, to: 30, twist: 'The clue card hides after a few seconds.' },
|
| 50 |
+
{ id: 'mind', name: 'Mind Reader', from: 31, to: 40, twist: 'Hidden card and a clock on every question.' },
|
| 51 |
+
];
|
| 52 |
+
|
| 53 |
+
// Hand-tuned table: qn = questions, order = clue order matches questions ('in') or is shuffled, noise = extra
|
| 54 |
+
// off-topic clues, peek = seconds the card stays visible (0 = always), timer = seconds per question (0 = none).
|
| 55 |
+
const T = [
|
| 56 |
+
/* 1-5 */ [3, 'in', 0, 0, 0], [3, 'in', 0, 0, 0], [3, 'in', 0, 0, 0], [4, 'in', 0, 0, 0], [4, 'in', 0, 0, 0],
|
| 57 |
+
/* 6-9 */ [4, 'mix', 0, 0, 0], [4, 'mix', 0, 0, 0], [5, 'mix', 0, 0, 0], [5, 'mix', 0, 0, 0],
|
| 58 |
+
/* 10 */ [6, 'mix', 0, 0, 0],
|
| 59 |
+
/* 11-15 */ [4, 'mix', 1, 0, 0], [4, 'mix', 2, 0, 0], [5, 'mix', 1, 0, 0], [5, 'mix', 2, 0, 0], [5, 'mix', 2, 0, 0],
|
| 60 |
+
/* 16-19 */ [6, 'mix', 2, 0, 0], [6, 'mix', 3, 0, 0], [6, 'mix', 3, 0, 0], [7, 'mix', 3, 0, 0],
|
| 61 |
+
/* 20 */ [8, 'mix', 4, 0, 0],
|
| 62 |
+
/* 21-25 */ [3, 'in', 0, 10, 0], [4, 'in', 0, 10, 0], [4, 'mix', 0, 10, 0], [5, 'mix', 0, 12, 0], [5, 'mix', 1, 12, 0],
|
| 63 |
+
/* 26-29 */ [5, 'mix', 1, 9, 0], [6, 'mix', 1, 12, 0], [6, 'mix', 2, 11, 0], [6, 'mix', 2, 9, 0],
|
| 64 |
+
/* 30 */ [8, 'mix', 2, 14, 0],
|
| 65 |
+
/* 31-35 */ [4, 'mix', 0, 10, 8], [4, 'mix', 1, 10, 7], [5, 'mix', 1, 11, 7], [5, 'mix', 1, 10, 6], [6, 'mix', 1, 12, 6],
|
| 66 |
+
/* 36-39 */ [6, 'mix', 2, 11, 6], [6, 'mix', 2, 10, 5], [7, 'mix', 2, 12, 5], [7, 'mix', 3, 12, 5],
|
| 67 |
+
/* 40 */ [10, 'mix', 2, 16, 6],
|
| 68 |
+
];
|
| 69 |
+
export const LEVELS = T.map(([qn, order, noise, peek, timer], k) => {
|
| 70 |
+
const n = k + 1;
|
| 71 |
+
return { n, who: CAST[k][0], emoji: CAST[k][1], qn, order, noise, peek, timer, challenge: n % 10 === 0, pack: SOLO_PACKS.find((p) => n >= p.from && n <= p.to).id };
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
function shuffle(arr, r) {
|
| 75 |
+
const a = arr.slice();
|
| 76 |
+
for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(r() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
|
| 77 |
+
return a;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
/** Build a round from a seed string: questions (with the friend's answers) and the clue card. Same seed, same round. */
|
| 81 |
+
function build(seedStr, who, emoji, qn, order, noise) {
|
| 82 |
+
const r = rng(hash(seedStr));
|
| 83 |
+
let qs = shuffle(CLUE_QS, r);
|
| 84 |
+
// Never pair the two coffee questions (a "tea" friend whose breakfast is "just coffee" would read as a contradiction).
|
| 85 |
+
const pair = { c02: 'c16', c16: 'c02' };
|
| 86 |
+
qs = qs.filter((q, i) => !(pair[q] && qs.indexOf(pair[q]) < i));
|
| 87 |
+
const items = qs.slice(0, qn).map((q) => ({ q, a: Math.floor(r() * BY_ID[q].opts.length) }));
|
| 88 |
+
const extra = qs.slice(qn, qn + noise).map((q) => ({ q, a: Math.floor(r() * BY_ID[q].opts.length), noise: true }));
|
| 89 |
+
let card = [...items, ...extra].map((x) => ({ text: CLUES[x.q][x.a], q: x.q, noise: !!x.noise }));
|
| 90 |
+
if (order !== 'in') card = shuffle(card, r);
|
| 91 |
+
return { who, emoji, items, card };
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
export function buildLevel(n) {
|
| 95 |
+
const L = LEVELS[n - 1];
|
| 96 |
+
if (!L) return null;
|
| 97 |
+
return { ...L, kind: 'level', ...build(`kenwren-level-${n}`, L.who, L.emoji, L.qn, L.order, L.noise) };
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/** "Daily read" / challenge round from a numeric seed (the daily uses today's puzzle number). */
|
| 101 |
+
export function buildRead(seed) {
|
| 102 |
+
const r = rng(hash(`kenwren-read-${seed}`));
|
| 103 |
+
const [who, emoji] = CAST[Math.floor(r() * CAST.length)];
|
| 104 |
+
return { n: seed, kind: 'read', who, emoji, qn: DAILY_LEN, order: 'mix', noise: 1, peek: 12, timer: 0, challenge: false, ...build(`kenwren-read-${seed}`, who, emoji, DAILY_LEN, 'mix', 1) };
|
| 105 |
+
}
|
| 106 |
+
export const dailySeed = (iso) => dayNumber(iso);
|
| 107 |
+
|
| 108 |
+
/** Stars: 3 = all right, 2 = at most one miss (on 4+ questions), 1 = at least 60% right, 0 = not cleared. */
|
| 109 |
+
export function starsFor(right, qn) {
|
| 110 |
+
if (!(qn > 0) || !(right >= 0)) return 0;
|
| 111 |
+
const wrong = qn - right;
|
| 112 |
+
if (wrong <= 0) return 3;
|
| 113 |
+
if (wrong === 1 && qn >= 4) return 2;
|
| 114 |
+
return right >= Math.ceil(qn * 0.6) ? 1 : 0;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
/** Saved progress, sanitised: { stars: { [n]: 1..3 }, reached: highest level ever started }. */
|
| 118 |
+
export function cleanProgress(raw) {
|
| 119 |
+
const out = { stars: {}, reached: 1 };
|
| 120 |
+
if (!raw || typeof raw !== 'object') return out;
|
| 121 |
+
for (const [k, v] of Object.entries(raw.stars || {})) {
|
| 122 |
+
const n = Number(k);
|
| 123 |
+
if (Number.isInteger(n) && n >= 1 && n <= LEVELS.length && Number.isInteger(v) && v >= 1 && v <= 3) out.stars[n] = v;
|
| 124 |
+
}
|
| 125 |
+
if (Number.isInteger(raw.reached) && raw.reached >= 1 && raw.reached <= LEVELS.length) out.reached = raw.reached;
|
| 126 |
+
return out;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/** Can level n be played? 'ok' | 'progress' (clear the one before first) | 'pass' (Fun Pass level).
|
| 130 |
+
* Never takes a level away: anything up to the highest level already reached stays open, pass or not. */
|
| 131 |
+
export function access(n, prog, pro) {
|
| 132 |
+
if (!Number.isInteger(n) || n < 1 || n > LEVELS.length) return 'progress';
|
| 133 |
+
if (n <= prog.reached) return 'ok';
|
| 134 |
+
if (n > FREE_MAX && !pro) return 'pass';
|
| 135 |
+
if (n > 1 && !(prog.stars[n - 1] > 0)) return 'progress';
|
| 136 |
+
return 'ok';
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
/** Mark level n as started (call only when access() said 'ok'). */
|
| 140 |
+
export function start(prog, n) { return { stars: { ...prog.stars }, reached: Math.max(prog.reached, n) }; }
|
| 141 |
+
|
| 142 |
+
/** Record a finished level: keeps the best stars ever. Clearing does not open a pass level by itself. */
|
| 143 |
+
export function record(prog, n, right, qn) {
|
| 144 |
+
const s = starsFor(right, qn);
|
| 145 |
+
const stars = { ...prog.stars };
|
| 146 |
+
if (s > (stars[n] || 0)) stars[n] = s;
|
| 147 |
+
return { stars, reached: Math.max(prog.reached, n), got: s };
|
| 148 |
+
}
|
| 149 |
+
export const totalStars = (prog) => Object.values(prog.stars).reduce((a, b) => a + b, 0);
|
| 150 |
+
/** The next level to play: the first not yet cleared that is open, else the last open one. */
|
| 151 |
+
export function nextLevel(prog, pro) {
|
| 152 |
+
for (const L of LEVELS) if (!(prog.stars[L.n] > 0)) return access(L.n, prog, pro) === 'ok' ? L.n : Math.max(1, L.n - 1);
|
| 153 |
+
return LEVELS.length;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
/** Daily streak: consecutive local days with a daily read finished. */
|
| 157 |
+
export function streak(prev, iso) {
|
| 158 |
+
const d = dayNumber(iso);
|
| 159 |
+
const p = prev && Number.isInteger(prev.day) && Number.isInteger(prev.n) ? prev : null;
|
| 160 |
+
if (p && p.day === d) return p;
|
| 161 |
+
return { day: d, n: p && p.day === d - 1 ? p.n + 1 : 1, best: Math.max(p?.best || 0, p && p.day === d - 1 ? p.n + 1 : 1) };
|
| 162 |
+
}
|
| 163 |
+
export const streakNow = (s, iso) => (s && Number.isInteger(s.day) && s.day >= dayNumber(iso) - 1 ? s.n : 0);
|
| 164 |
+
|
| 165 |
+
const INT = /^\d{1,6}$/;
|
| 166 |
+
/** Parse "beat my score" links: ?lvl=<1..40>&score=<0..qn> or ?seed=<1..100000>&score=<0..6>. Garbage -> null. */
|
| 167 |
+
export function parseLink(search) {
|
| 168 |
+
let p;
|
| 169 |
+
try { p = new URLSearchParams(search); } catch { return null; }
|
| 170 |
+
const num = (k) => (INT.test(p.get(k) || '') ? Number(p.get(k)) : null);
|
| 171 |
+
const lvl = num('lvl'); const seed = num('seed'); const sc = num('score');
|
| 172 |
+
if (p.has('lvl')) {
|
| 173 |
+
if (lvl === null || lvl < 1 || lvl > LEVELS.length) return null;
|
| 174 |
+
const qn = LEVELS[lvl - 1].qn;
|
| 175 |
+
return { lvl, score: sc !== null && sc <= qn ? sc : null };
|
| 176 |
+
}
|
| 177 |
+
if (p.has('seed')) {
|
| 178 |
+
if (seed === null || seed < 1 || seed > SEED_MAX) return null;
|
| 179 |
+
return { seed, score: sc !== null && sc <= DAILY_LEN ? sc : null };
|
| 180 |
+
}
|
| 181 |
+
return null;
|
| 182 |
+
}
|
| 183 |
+
export function linkQuery(o) {
|
| 184 |
+
if (o.lvl) return `?lvl=${o.lvl}${Number.isInteger(o.score) ? `&score=${o.score}` : ''}`;
|
| 185 |
+
return `?seed=${o.seed}${Number.isInteger(o.score) ? `&score=${o.score}` : ''}`;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
/** Emoji result line for sharing. */
|
| 189 |
+
export const grid = (marks) => marks.map((m) => (m ? '🟩' : '🟥')).join('');
|
kenwren/solo.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Kenwren solo screens: level map, "read a friend" rounds, daily read, streak, share and beat-my-score links.
|
| 2 |
+
// Logic lives in levels.js; sounds/particles in juice.js. (c) 2026 CyberMax.
|
| 3 |
+
import { BY_ID } from './core.js';
|
| 4 |
+
import { count, store, toast, share, today } from './cmx.js';
|
| 5 |
+
import * as pass from './funpass.js';
|
| 6 |
+
import { LEVELS, SOLO_PACKS, FREE_MAX, buildLevel, buildRead, dailySeed, starsFor, cleanProgress, access, start, record, totalStars, nextLevel, streak, streakNow, parseLink, linkQuery, grid } from './levels.js';
|
| 7 |
+
import { sfx, shake, burst, isMuted, setMuted } from './juice.js';
|
| 8 |
+
|
| 9 |
+
const GAME = 'kenwren';
|
| 10 |
+
const $ = (id) => document.getElementById(id);
|
| 11 |
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
| 12 |
+
let show = () => {};
|
| 13 |
+
let PATH = '/games/kenwren/';
|
| 14 |
+
let prog = cleanProgress(store('kenwren:levels'));
|
| 15 |
+
let R = null; // current round: { round, k, marks, beat, busy, timer }
|
| 16 |
+
|
| 17 |
+
const saveProg = (p) => { prog = cleanProgress(p); store('kenwren:levels', prog); };
|
| 18 |
+
const todayIso = () => today();
|
| 19 |
+
const pro = () => pass.isPro();
|
| 20 |
+
|
| 21 |
+
// ------------------------------------------------------------------ home strip (daily + levels + streak)
|
| 22 |
+
export function renderHome() {
|
| 23 |
+
const iso = todayIso(); const n = dailySeed(iso);
|
| 24 |
+
$('daily-n').textContent = n;
|
| 25 |
+
const st = streakNow(store('kenwren:streak'), iso);
|
| 26 |
+
$('streak').hidden = !st; $('streak').querySelector('b').textContent = st;
|
| 27 |
+
$('streak').title = `${st}-day daily streak`;
|
| 28 |
+
const d = store('kenwren:daily');
|
| 29 |
+
$('daily-done').hidden = !(d && d.day === n);
|
| 30 |
+
$('levels-stars').textContent = `★ ${totalStars(prog)}/${LEVELS.length * 3}`;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
// ------------------------------------------------------------------ level map
|
| 34 |
+
function renderMap() {
|
| 35 |
+
$('map-stars').textContent = `★ ${totalStars(prog)}/${LEVELS.length * 3}`;
|
| 36 |
+
const host = $('map'); host.innerHTML = '';
|
| 37 |
+
for (const P of SOLO_PACKS) {
|
| 38 |
+
const sec = document.createElement('section'); sec.className = 'pack';
|
| 39 |
+
const paid = P.from > FREE_MAX;
|
| 40 |
+
sec.innerHTML = `<h3>${esc(P.name)} <small>${P.from}–${P.to}${paid ? ' · <span class="passtag">Fun Pass</span>' : ' · free'}</small></h3><p class="muted small">${esc(P.twist)}</p><div class="nodes"></div>`;
|
| 41 |
+
const nodes = sec.querySelector('.nodes');
|
| 42 |
+
for (let n = P.from; n <= P.to; n++) {
|
| 43 |
+
const L = LEVELS[n - 1]; const a = access(n, prog, pro()); const s = prog.stars[n] || 0;
|
| 44 |
+
const b = document.createElement('button');
|
| 45 |
+
b.type = 'button'; b.className = `node ${a}${s ? ' done' : ''}${L.challenge ? ' boss' : ''}`; b.dataset.lvl = n;
|
| 46 |
+
b.setAttribute('aria-label', `Level ${n}${L.challenge ? ' (challenge)' : ''}: ${a === 'ok' ? `${s} of 3 stars` : a === 'pass' ? 'needs the Fun Pass' : 'locked'}`);
|
| 47 |
+
b.innerHTML = `<span class="nn">${n}</span><span class="em">${a === 'ok' ? L.emoji : a === 'pass' ? '🔒' : '·'}</span><span class="st">${'★'.repeat(s)}${'☆'.repeat(3 - s)}</span>`;
|
| 48 |
+
b.onclick = () => openLevel(n, null, b);
|
| 49 |
+
nodes.appendChild(b);
|
| 50 |
+
}
|
| 51 |
+
host.appendChild(sec);
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
export function showMap() {
|
| 55 |
+
renderMap(); show('s-map');
|
| 56 |
+
const cur = $('map').querySelector(`[data-lvl="${nextLevel(prog, pro())}"]`);
|
| 57 |
+
if (cur) { cur.classList.add('cur'); }
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function openLevel(n, beat = null, from = null, viaLink = false) {
|
| 61 |
+
const a = access(n, prog, pro());
|
| 62 |
+
const free = n <= FREE_MAX || pro();
|
| 63 |
+
if (a === 'pass' || (viaLink && !free && a !== 'ok')) { showMap(); pass.open(`level-${n}`); return; }
|
| 64 |
+
if (a === 'progress' && !viaLink) { sfx.bad(); shake(from); toast(`Clear level ${n - 1} first.`); return; }
|
| 65 |
+
saveProg(start(prog, n));
|
| 66 |
+
play(buildLevel(n), beat);
|
| 67 |
+
}
|
| 68 |
+
function openRead(seed, beat = null) { play(buildRead(seed), beat); }
|
| 69 |
+
|
| 70 |
+
// ------------------------------------------------------------------ a round
|
| 71 |
+
function play(round, beat) {
|
| 72 |
+
clearTimers();
|
| 73 |
+
R = { round, k: 0, marks: [], beat, busy: false, peekT: 0, qT: 0 };
|
| 74 |
+
const { who, emoji, qn, kind, n } = round;
|
| 75 |
+
const pack = kind === 'level' ? SOLO_PACKS.find((p) => p.id === round.pack).name : (n === dailySeed(todayIso()) ? 'Daily read' : 'Challenge read');
|
| 76 |
+
$('so-title').textContent = kind === 'level' ? `Level ${n}${round.challenge ? ' ★ challenge' : ''} · ${pack}` : `${pack} #${n}`;
|
| 77 |
+
const need = Math.ceil(qn * 0.6);
|
| 78 |
+
$('so-goal').innerHTML = `Goal: ${round.peek ? 'memorise the card, then guess' : 'guess'} how <b>${esc(who)}</b> answers${round.peek ? '' : ', using the clue card'}. ${need}/${qn} to clear${beat !== null ? ` · <b class="beat">Beat ${beat}</b>` : ''}`;
|
| 79 |
+
$('so-emoji').textContent = emoji; $('so-who').textContent = who;
|
| 80 |
+
$('so-clues').innerHTML = round.card.map((c, k) => `<li data-k="${k}">${esc(c.text)}</li>`).join('');
|
| 81 |
+
$('so-card').classList.remove('hidden');
|
| 82 |
+
$('so-pips').innerHTML = Array.from({ length: qn }, () => '<i></i>').join('');
|
| 83 |
+
count(GAME, 'play', kind === 'level' ? 'level' : 'daily');
|
| 84 |
+
show('s-solo');
|
| 85 |
+
if (round.peek) {
|
| 86 |
+
$('so-q-wrap').hidden = true; $('so-ready').hidden = false; $('so-peek').hidden = false;
|
| 87 |
+
const bar = $('so-peek').querySelector('i');
|
| 88 |
+
bar.style.transition = 'none'; bar.style.width = '100%'; void bar.offsetWidth;
|
| 89 |
+
bar.style.transition = `width ${round.peek}s linear`; bar.style.width = '0%';
|
| 90 |
+
R.peekT = setTimeout(hideCard, round.peek * 1000);
|
| 91 |
+
} else {
|
| 92 |
+
$('so-ready').hidden = true; $('so-peek').hidden = true; $('so-q-wrap').hidden = false; ask();
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
function hideCard() {
|
| 96 |
+
if (!R) return;
|
| 97 |
+
clearTimeout(R.peekT);
|
| 98 |
+
$('so-card').classList.add('hidden'); $('so-ready').hidden = true; $('so-peek').hidden = true; $('so-q-wrap').hidden = false;
|
| 99 |
+
sfx.tap(); ask();
|
| 100 |
+
}
|
| 101 |
+
function clearTimers() { if (R) { clearTimeout(R.peekT); clearTimeout(R.qT); clearTimeout(R.nextT); } $('so-hand').hidden = true; }
|
| 102 |
+
|
| 103 |
+
function ask() {
|
| 104 |
+
const { round, k } = R; const it = round.items[k]; const q = BY_ID[it.q];
|
| 105 |
+
R.busy = false;
|
| 106 |
+
$('so-q').textContent = q.you.replaceAll('{n}', round.who);
|
| 107 |
+
const box = $('so-opts'); box.innerHTML = '';
|
| 108 |
+
q.opts.forEach((o, j) => {
|
| 109 |
+
const b = document.createElement('button');
|
| 110 |
+
b.type = 'button'; b.className = 'opt'; b.textContent = o;
|
| 111 |
+
b.onclick = () => pick(j, b);
|
| 112 |
+
box.appendChild(b);
|
| 113 |
+
});
|
| 114 |
+
// easy levels (clues in question order, always visible): light up the clue that belongs to this question
|
| 115 |
+
$('so-clues').querySelectorAll('li').forEach((li) => li.classList.remove('hint'));
|
| 116 |
+
$('so-clues').querySelectorAll('li').forEach((li) => li.classList.toggle('now', round.order === 'in' && !round.peek && Number(li.dataset.k) === k));
|
| 117 |
+
if (round.kind === 'level' && round.n === 1 && !(prog.stars[1] > 0)) hint(k, it.a);
|
| 118 |
+
if (round.timer) {
|
| 119 |
+
const t = $('so-timer'); const bar = t.querySelector('i'); t.hidden = false;
|
| 120 |
+
bar.style.transition = 'none'; bar.style.width = '100%'; void bar.offsetWidth;
|
| 121 |
+
bar.style.transition = `width ${round.timer}s linear`; bar.style.width = '0%';
|
| 122 |
+
R.qT = setTimeout(() => pick(-1, null), round.timer * 1000);
|
| 123 |
+
} else $('so-timer').hidden = true;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// No-text tutorial on level 1: a hand slides from the matching clue to the matching answer.
|
| 127 |
+
function hint(k, a) {
|
| 128 |
+
const hand = $('so-hand'); const stage = $('stage');
|
| 129 |
+
const clue = $('so-clues').querySelector(`li[data-k="${k}"]`); const opt = $('so-opts').children[a];
|
| 130 |
+
if (!clue || !opt) return;
|
| 131 |
+
const sr = stage.getBoundingClientRect();
|
| 132 |
+
const pos = (el) => { const r = el.getBoundingClientRect(); return [r.left - sr.left + Math.min(r.width - 30, 60), r.top - sr.top + r.height / 2]; };
|
| 133 |
+
const [x1, y1] = pos(clue); const [x2, y2] = pos(opt);
|
| 134 |
+
hand.hidden = false; opt.classList.add('hint'); clue.classList.add('hint');
|
| 135 |
+
try {
|
| 136 |
+
hand.getAnimations().forEach((an) => an.cancel());
|
| 137 |
+
hand.animate([{ transform: `translate(${x1}px,${y1}px) scale(1)`, opacity: 0 }, { transform: `translate(${x1}px,${y1}px) scale(1)`, opacity: 1, offset: 0.15 },
|
| 138 |
+
{ transform: `translate(${x2}px,${y2}px) scale(1)`, opacity: 1, offset: 0.7 }, { transform: `translate(${x2}px,${y2}px) scale(.85)`, opacity: 1, offset: 0.8 }, { transform: `translate(${x2}px,${y2}px) scale(1)`, opacity: 0 }],
|
| 139 |
+
{ duration: 1800, iterations: Infinity, easing: 'ease-in-out' });
|
| 140 |
+
} catch { hand.style.transform = `translate(${x2}px,${y2}px)`; }
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
function pick(j, btn) {
|
| 144 |
+
if (!R || R.busy) return;
|
| 145 |
+
R.busy = true; clearTimeout(R.qT); $('so-hand').hidden = true;
|
| 146 |
+
const { round, k } = R; const it = round.items[k];
|
| 147 |
+
const ok = j === it.a;
|
| 148 |
+
const opts = $('so-opts').children;
|
| 149 |
+
if (navigator.vibrate) try { navigator.vibrate(ok ? 10 : [20, 40, 20]); } catch { /* */ }
|
| 150 |
+
[...opts].forEach((b, m) => { b.disabled = true; if (m === it.a) b.classList.add('right'); });
|
| 151 |
+
if (btn && !ok) btn.classList.add('wrong');
|
| 152 |
+
R.marks.push(ok);
|
| 153 |
+
const pip = $('so-pips').children[k]; pip.className = ok ? 'ok' : 'no';
|
| 154 |
+
if (ok) { sfx.ok(); burst(btn || opts[it.a], 22); } else { sfx.bad(); shake($('stage')); burst(btn || opts[it.a], 10, true); if (j < 0) toast('Time!', 900); }
|
| 155 |
+
R.nextT = setTimeout(() => { if (R.k + 1 < round.qn) { R.k++; ask(); } else done(); }, ok ? 480 : 950);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
// ------------------------------------------------------------------ result
|
| 159 |
+
function done() {
|
| 160 |
+
const { round, marks, beat } = R; const right = marks.filter(Boolean).length; const qn = round.qn;
|
| 161 |
+
$('so-timer').hidden = true;
|
| 162 |
+
const st = starsFor(right, qn);
|
| 163 |
+
let note = ''; let got = st;
|
| 164 |
+
const iso = todayIso(); const isDaily = round.kind === 'read' && round.n === dailySeed(iso);
|
| 165 |
+
if (round.kind === 'level') {
|
| 166 |
+
const before = prog.stars[round.n] || 0;
|
| 167 |
+
const r = record(prog, round.n, right, qn); got = r.got; saveProg(r);
|
| 168 |
+
if (got) count(GAME, 'win', `level-${round.n}`);
|
| 169 |
+
note = got ? (got > before && before ? 'New best!' : '') : `You need ${Math.ceil(qn * 0.6)} right to clear it. One tap to try again.`;
|
| 170 |
+
} else {
|
| 171 |
+
if (isDaily) {
|
| 172 |
+
const d = store('kenwren:daily');
|
| 173 |
+
if (!(d && d.day === round.n)) { store('kenwren:daily', { day: round.n, right, marks }); store('kenwren:streak', streak(store('kenwren:streak'), iso)); count(GAME, 'win', 'daily'); }
|
| 174 |
+
const s = streakNow(store('kenwren:streak'), iso);
|
| 175 |
+
note = `🔥 ${s}-day streak. A new friend to read tomorrow.`;
|
| 176 |
+
} else note = 'Challenge read: same friend, same clues for everyone with this link.';
|
| 177 |
+
}
|
| 178 |
+
if (beat !== null) note = `${right > beat ? `You beat ${beat}! 🎉` : right === beat ? `Tied at ${beat}. So close.` : `${beat} still wins. Try again?`} ${note}`;
|
| 179 |
+
$('dn-kick').textContent = round.kind === 'level' ? `Level ${round.n}${round.challenge ? ' · challenge' : ''}` : (isDaily ? `Daily read #${round.n}` : `Challenge read #${round.n}`);
|
| 180 |
+
$('dn-title').textContent = got || round.kind !== 'level' ? `You read ${round.who} ${right === qn ? 'perfectly' : 'well'}!` : `${round.who} stumped you`;
|
| 181 |
+
if (round.kind !== 'level' && right < Math.ceil(qn * 0.6)) $('dn-title').textContent = `${round.who} stumped you`;
|
| 182 |
+
$('dn-score').textContent = `${right}/${qn}`;
|
| 183 |
+
$('dn-grid').textContent = grid(marks);
|
| 184 |
+
$('dn-note').textContent = note;
|
| 185 |
+
const stars = $('dn-stars');
|
| 186 |
+
stars.innerHTML = round.kind === 'level' ? [0, 1, 2].map((k) => `<span class="${k < got ? 'on' : ''}">★</span>`).join('') : '';
|
| 187 |
+
// next button
|
| 188 |
+
const nb = $('dn-next');
|
| 189 |
+
if (round.kind === 'level') {
|
| 190 |
+
const nx = round.n + 1;
|
| 191 |
+
if (!got) { nb.textContent = '↻ Try again'; nb.onclick = () => openLevel(round.n, beat); }
|
| 192 |
+
else if (nx > LEVELS.length) { nb.textContent = '🗺 All levels'; nb.onclick = showMap; }
|
| 193 |
+
else if (access(nx, prog, pro()) === 'pass') { nb.textContent = `Level ${nx} 🔒 Fun Pass`; nb.onclick = () => pass.open(`level-${nx}`); }
|
| 194 |
+
else { nb.textContent = `Level ${nx} ▶`; nb.onclick = () => openLevel(nx); }
|
| 195 |
+
} else { nb.textContent = '▶ Solo levels'; nb.onclick = () => openLevel(nextLevel(prog, pro())); }
|
| 196 |
+
$('dn-retry').onclick = () => (round.kind === 'level' ? openLevel(round.n, beat) : openRead(round.n, beat));
|
| 197 |
+
$('dn-share').onclick = () => shareRound(round, right, marks);
|
| 198 |
+
show('s-done');
|
| 199 |
+
if (got || (round.kind !== 'level' && right >= Math.ceil(qn * 0.6))) {
|
| 200 |
+
sfx.win(); burst($('dn-score'), 60);
|
| 201 |
+
[...stars.children].forEach((s, k) => { if (k < got) setTimeout(() => { s.classList.add('pop'); sfx.star(k); }, 250 + k * 220); });
|
| 202 |
+
} else { sfx.lose(); shake($('stage')); }
|
| 203 |
+
if (isDaily) pass.afterWin($('dn-after'), 'daily'); else $('dn-after').innerHTML = '';
|
| 204 |
+
renderHome();
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
export function shareText(round, right, marks, streakN = 0) {
|
| 208 |
+
if (round.kind === 'level') {
|
| 209 |
+
const s = starsFor(right, round.qn);
|
| 210 |
+
return `Kenwren level ${round.n} ${'⭐'.repeat(s) || '💥'} ${right}/${round.qn}\n${grid(marks)}\nCan you read ${round.who} better? Beat ${right}:`;
|
| 211 |
+
}
|
| 212 |
+
return `Kenwren Daily read #${round.n} ${right}/${round.qn}${streakN ? ` 🔥${streakN}` : ''}\n${grid(marks)}\nHow well can you read a stranger? Beat ${right}:`;
|
| 213 |
+
}
|
| 214 |
+
function shareRound(round, right, marks) {
|
| 215 |
+
const q = linkQuery(round.kind === 'level' ? { lvl: round.n, score: right } : { seed: round.n, score: right });
|
| 216 |
+
share(GAME, shareText(round, right, marks, streakNow(store('kenwren:streak'), todayIso())), `${PATH}${q}`);
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
// ------------------------------------------------------------------ wiring
|
| 220 |
+
export function initSolo(opts) {
|
| 221 |
+
show = opts.show; PATH = opts.path || PATH;
|
| 222 |
+
$('daily-btn').onclick = () => {
|
| 223 |
+
const n = dailySeed(todayIso()); const d = store('kenwren:daily');
|
| 224 |
+
if (d && d.day === n && Array.isArray(d.marks)) {
|
| 225 |
+
// already played today: show today's result (first try counts), with share + practice
|
| 226 |
+
R = { round: buildRead(n), marks: d.marks.map(Boolean), beat: null }; R.k = R.marks.length; done();
|
| 227 |
+
toast('You played today. Replays are practice; your first score stands.', 2600);
|
| 228 |
+
return;
|
| 229 |
+
}
|
| 230 |
+
openRead(n);
|
| 231 |
+
};
|
| 232 |
+
$('levels-btn').onclick = () => { sfx.tap(); showMap(); };
|
| 233 |
+
$('so-map').onclick = () => { clearTimers(); R = null; showMap(); };
|
| 234 |
+
$('dn-map').onclick = showMap;
|
| 235 |
+
$('map-home').onclick = () => { renderHome(); show('s-home'); };
|
| 236 |
+
$('so-retry').onclick = () => { if (!R) return; const r = R.round; r.kind === 'level' ? openLevel(r.n, R.beat) : openRead(r.n, R.beat); };
|
| 237 |
+
$('so-ready').onclick = hideCard;
|
| 238 |
+
const mute = $('mute');
|
| 239 |
+
const paint = () => { mute.textContent = isMuted() ? '🔇' : '🔊'; mute.setAttribute('aria-pressed', String(isMuted())); };
|
| 240 |
+
mute.onclick = () => { setMuted(!isMuted()); paint(); if (!isMuted()) sfx.ok(); };
|
| 241 |
+
paint();
|
| 242 |
+
document.addEventListener('keydown', (e) => {
|
| 243 |
+
if ($('s-solo').hidden) return;
|
| 244 |
+
if (/^[1-4]$/.test(e.key) && !$('so-q-wrap').hidden && R && !R.busy) { const b = $('so-opts').children[Number(e.key) - 1]; if (b) pick(Number(e.key) - 1, b); }
|
| 245 |
+
else if (e.key === 'r' || e.key === 'R') $('so-retry').click();
|
| 246 |
+
else if ((e.key === ' ' || e.key === 'Enter') && !$('so-ready').hidden) { e.preventDefault(); hideCard(); }
|
| 247 |
+
});
|
| 248 |
+
pass.onChange(() => { if (!$('s-map').hidden) renderMap(); });
|
| 249 |
+
renderHome();
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
/** Handle ?lvl= / ?seed= beat-my-score links. Returns true when it opened something. */
|
| 253 |
+
export function route(search) {
|
| 254 |
+
const L = parseLink(search);
|
| 255 |
+
if (!L) return false;
|
| 256 |
+
if (L.lvl) openLevel(L.lvl, L.score, null, true); else openRead(L.seed, L.score);
|
| 257 |
+
return true;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
// test/screenshot hook
|
| 261 |
+
window.__kenwrenSolo = { get prog() { return prog; }, get round() { return R && R.round; }, answer: () => R && R.round.items[R.k].a };
|
kenwren/style.css
CHANGED
|
@@ -33,3 +33,45 @@
|
|
| 33 |
.skins button[aria-pressed=true]{border-color:var(--ink)}.skins button.locked::after{content:"🔒";position:absolute;right:-6px;bottom:-6px;font-size:12px}
|
| 34 |
.stage details{margin-top:14px}
|
| 35 |
#packs{flex-wrap:wrap!important;overflow:visible!important;margin:14px 0 0!important;padding:0!important}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
.skins button[aria-pressed=true]{border-color:var(--ink)}.skins button.locked::after{content:"🔒";position:absolute;right:-6px;bottom:-6px;font-size:12px}
|
| 34 |
.stage details{margin-top:14px}
|
| 35 |
#packs{flex-wrap:wrap!important;overflow:visible!important;margin:14px 0 0!important;padding:0!important}
|
| 36 |
+
/* solo mode: map, clue card, pips, stars, juice */
|
| 37 |
+
.stage{position:relative}
|
| 38 |
+
.solo-strip{margin-top:22px;padding-top:16px;border-top:1px solid rgba(255,255,255,.18)}.solo-strip .kick{margin-bottom:8px}.solo-strip p.small{margin:8px 0 0}
|
| 39 |
+
.streak{font-weight:900;font-size:18px;background:rgba(255,255,255,.12);border-radius:999px;padding:6px 12px}
|
| 40 |
+
.hud{display:flex;align-items:center;gap:8px;justify-content:space-between;margin-bottom:6px}.hud b{flex:1;text-align:center;font-size:15px}
|
| 41 |
+
.hud .btn.sm{min-width:44px}
|
| 42 |
+
.goal{margin:0 0 10px;font-size:15px;background:rgba(0,0,0,.18);border-radius:10px;padding:7px 10px}.goal .beat{color:var(--k-hi)}
|
| 43 |
+
:root[data-skin=paper] .goal{background:rgba(0,0,0,.06)}
|
| 44 |
+
.pips{display:flex;gap:6px;margin:0 0 10px}.pips i{flex:1;height:8px;border-radius:9px;background:rgba(255,255,255,.2)}.pips i.ok{background:#2FD3A0}.pips i.no{background:#FF5C5C}
|
| 45 |
+
.clue-card{background:rgba(255,255,255,.1);border:2px solid rgba(255,255,255,.22);border-radius:16px;padding:10px 14px;margin-bottom:10px}
|
| 46 |
+
:root[data-skin=paper] .clue-card{background:#fff;border-color:rgba(0,0,0,.12)}
|
| 47 |
+
.clue-card .who{margin:0 0 4px;font-size:18px}.clue-card #so-emoji{font-size:26px}
|
| 48 |
+
.clue-card ul{margin:0;padding-left:20px}.clue-card li{padding:2px 0;font-size:15px;transition:background .2s}
|
| 49 |
+
.clue-card li.now,.clue-card li.hint{background:rgba(255,209,102,.22);border-radius:6px;outline:2px solid var(--k-hi);outline-offset:1px}
|
| 50 |
+
.clue-card .hid{display:none;margin:0;font-size:28px;text-align:center}
|
| 51 |
+
.clue-card.hidden ul{display:none}.clue-card.hidden .hid{display:block}
|
| 52 |
+
.peek,.tbar{height:6px;border-radius:9px;background:rgba(255,255,255,.2);overflow:hidden;margin:8px 0}.peek i,.tbar i{display:block;height:100%;width:100%;background:var(--k-hi)}
|
| 53 |
+
.tbar i{background:#FF8A5C}
|
| 54 |
+
#so-ready{margin-top:6px}
|
| 55 |
+
#s-solo h2{margin:4px 0 10px;font-size:clamp(19px,4.6vw,24px)}
|
| 56 |
+
.opt.right{background:#2FD3A0!important;color:#11141B!important;border-color:#2FD3A0!important}.opt.wrong{background:#FF5C5C!important;color:#fff!important;border-color:#FF5C5C!important}
|
| 57 |
+
.opt.hint{border-color:var(--k-hi);box-shadow:0 0 0 3px rgba(255,209,102,.4)}
|
| 58 |
+
.opt:disabled{cursor:default}
|
| 59 |
+
.hand{position:absolute;left:0;top:0;font-size:38px;pointer-events:none;z-index:5;margin:-6px 0 0 -12px;filter:drop-shadow(0 3px 4px rgba(0,0,0,.4))}
|
| 60 |
+
.stars{font-size:52px;letter-spacing:6px;line-height:1;margin:4px 0}.stars span{opacity:.25;display:inline-block}.stars span.on{opacity:.25}.stars span.pop{opacity:1;color:var(--k-hi);animation:pop .35s ease-out}
|
| 61 |
+
@keyframes pop{0%{transform:scale(.3)}70%{transform:scale(1.25)}100%{transform:scale(1)}}
|
| 62 |
+
.grid{font-size:22px;letter-spacing:2px;margin:6px 0}
|
| 63 |
+
#s-done .row{margin-top:10px}
|
| 64 |
+
.pack h3{margin:14px 0 2px;font-size:17px}.pack h3 small{font-weight:600;opacity:.75}.passtag{background:linear-gradient(135deg,#FFB547,#FF6A3D);color:#11141B;border-radius:999px;padding:1px 8px;font-size:12px;font-weight:800}
|
| 65 |
+
.pack p.small{margin:0 0 8px}
|
| 66 |
+
.nodes{display:grid;grid-template-columns:repeat(5,1fr);gap:8px}
|
| 67 |
+
.node{display:flex;flex-direction:column;align-items:center;gap:0;padding:6px 2px;border-radius:14px;border:2px solid rgba(255,255,255,.25);background:rgba(255,255,255,.08);color:var(--k-ink);font:inherit;cursor:pointer;min-height:70px;touch-action:manipulation}
|
| 68 |
+
:root[data-skin=paper] .node{background:#fff;border-color:rgba(0,0,0,.12)}
|
| 69 |
+
.node .nn{font-weight:900;font-size:14px}.node .em{font-size:22px;line-height:1.1}.node .st{font-size:11px;color:var(--k-hi);letter-spacing:1px}
|
| 70 |
+
.node.done{border-color:var(--k-hi)}.node.boss{border-style:dashed}.node.progress{opacity:.45}.node.pass{opacity:.8}
|
| 71 |
+
.node.cur{box-shadow:0 0 0 3px var(--k-hi);animation:pulse 1.4s ease-in-out infinite}
|
| 72 |
+
@keyframes pulse{50%{box-shadow:0 0 0 6px rgba(255,209,102,.35)}}
|
| 73 |
+
.shake{animation:shake .38s}
|
| 74 |
+
@keyframes shake{20%{transform:translateX(-8px)}40%{transform:translateX(7px)}60%{transform:translateX(-5px)}80%{transform:translateX(3px)}}
|
| 75 |
+
canvas.fx{position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;z-index:98}
|
| 76 |
+
@media (max-width:420px){.nodes{gap:6px}.node{min-height:64px}.stars{font-size:44px}}
|
| 77 |
+
@media (max-width:420px){.top nav{display:none}.top .icon{padding:8px 7px}}
|
kenwren/sw.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
// CyberMax game service worker: the game works offline after the first visit. Cache-first for the game's own files,
|
| 2 |
// network-first for the page itself (so updates arrive), never caches API calls or other sites. (c) 2026 CyberMax.
|
| 3 |
const GAME = 'kenwren';
|
| 4 |
-
const CACHE = `cmx-${GAME}-
|
| 5 |
-
const FILES = ["index.html", "cmx.js", "core.js", "fun.css", "funpass.js", "game.js", "icon-192.png", "icon-512.png", "icon-maskable.png", "icon.svg", "index.html", "manifest.webmanifest", "style.css"];
|
| 6 |
|
| 7 |
self.addEventListener('install', (e) => {
|
| 8 |
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(FILES)).then(() => self.skipWaiting()));
|
|
|
|
| 1 |
// CyberMax game service worker: the game works offline after the first visit. Cache-first for the game's own files,
|
| 2 |
// network-first for the page itself (so updates arrive), never caches API calls or other sites. (c) 2026 CyberMax.
|
| 3 |
const GAME = 'kenwren';
|
| 4 |
+
const CACHE = `cmx-${GAME}-f18f07a39a`;
|
| 5 |
+
const FILES = ["index.html", "cmx.js", "core.js", "fun.css", "funpass.js", "game.js", "icon-192.png", "icon-512.png", "icon-maskable.png", "icon.svg", "index.html", "juice.js", "levels.js", "manifest.webmanifest", "solo.js", "style.css"];
|
| 6 |
|
| 7 |
self.addEventListener('install', (e) => {
|
| 8 |
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(FILES)).then(() => self.skipWaiting()));
|