diff --git a/src/components/Nav.js b/src/components/Nav.js index a5ef22f..d3fd682 100644 --- a/src/components/Nav.js +++ b/src/components/Nav.js @@ -25,6 +25,20 @@ export function Nav() { const nav = el('nav', { class: 'nav', 'aria-label': 'Primary' }); let links = []; + // Easter egg: seven quick taps on the wordmark opens the hidden game + // (the Konami code does the same on a keyboard). + let taps = 0; + let tapTimer = null; + function bumpBrand() { + taps += 1; + clearTimeout(tapTimer); + tapTimer = setTimeout(() => (taps = 0), 1500); + if (taps >= 7) { + taps = 0; + location.hash = '#/whos-that'; + } + } + function build() { const st = settings.get(); const items = ITEMS.filter((it) => !it.optional || st[it.optional]); @@ -41,7 +55,10 @@ export function Nav() { ) : el('a', { class: 'nav__link', href: item.href }, ...inner); }); - nav.replaceChildren(el('span', { class: 'nav__brand' }, 'Pocketdex'), ...links); + nav.replaceChildren( + el('span', { class: 'nav__brand', onclick: bumpBrand }, 'Pocketdex'), + ...links, + ); nav._items = items; sync(); } diff --git a/src/lib/konami.js b/src/lib/konami.js new file mode 100644 index 0000000..0dd6a7b --- /dev/null +++ b/src/lib/konami.js @@ -0,0 +1,42 @@ +/** ↑ ↑ ↓ ↓ ← → ← → B A */ +export const KONAMI_SEQUENCE = [ + 'arrowup', + 'arrowup', + 'arrowdown', + 'arrowdown', + 'arrowleft', + 'arrowright', + 'arrowleft', + 'arrowright', + 'b', + 'a', +]; + +/** + * Returns a `(key) => void` that calls `onUnlock` once the full sequence + * has been entered in order. A wrong key rewinds (but a stray "↑" still + * counts as the start of a fresh attempt). Pure — no DOM. + */ +export function konamiHandler(onUnlock) { + let i = 0; + return (key) => { + const k = String(key).toLowerCase(); + if (k === KONAMI_SEQUENCE[i]) { + i += 1; + if (i === KONAMI_SEQUENCE.length) { + i = 0; + onUnlock(); + } + } else { + i = k === KONAMI_SEQUENCE[0] ? 1 : 0; + } + }; +} + +/** Wire {@link konamiHandler} to window keydowns. Returns an unsubscribe. */ +export function onKonami(onUnlock) { + const step = konamiHandler(onUnlock); + const listener = (e) => step(e.key); + window.addEventListener('keydown', listener); + return () => window.removeEventListener('keydown', listener); +} diff --git a/src/main.js b/src/main.js index 7a678ec..d3d00a4 100644 --- a/src/main.js +++ b/src/main.js @@ -9,6 +9,7 @@ import { settings, applyTheme } from './store/settings.js'; import { ui } from './store/ui.js'; import { markPlayed } from './store/playedGames.js'; import { onInstallChange, promptInstall } from './lib/install.js'; +import { onKonami } from './lib/konami.js'; applyTheme(); @@ -51,6 +52,11 @@ app.append(Nav(), viewHost); initRouter(viewHost); +// --- Easter egg: Konami code jumps to the hidden mini-game -------------- +onKonami(() => { + if ((location.hash || '') !== '#/whos-that') location.hash = '#/whos-that'; +}); + // --- Service worker: prompt to refresh rather than silently swapping ------ const updateSW = registerSW({ onNeedRefresh() { diff --git a/src/router.js b/src/router.js index 7439d89..cf295fa 100644 --- a/src/router.js +++ b/src/router.js @@ -13,6 +13,7 @@ import { CompareView } from './views/CompareView.js'; import { ProgressView } from './views/ProgressView.js'; import { BreedingView } from './views/BreedingView.js'; import { ShinyView } from './views/ShinyView.js'; +import { WhosThatView } from './views/WhosThatView.js'; import { detailSkeleton, lookupSkeleton } from './components/skeletons.js'; import { prefersReducedMotion } from './store/settings.js'; @@ -39,6 +40,7 @@ const routes = [ { pattern: /^#\/shiny$/, view: () => ShinyView() }, { pattern: /^#\/search$/, view: () => SearchView() }, { pattern: /^#\/settings$/, view: () => SettingsView() }, + { pattern: /^#\/whos-that$/, view: () => WhosThatView() }, // hidden — Konami / logo taps ]; function resolve(hash) { diff --git a/src/store/whosThat.js b/src/store/whosThat.js new file mode 100644 index 0000000..e352613 --- /dev/null +++ b/src/store/whosThat.js @@ -0,0 +1,13 @@ +import { createStore } from './createStore.js'; + +/** Score-keeping for the hidden "Who's that Pokémon?" game (#/whos-that). */ +export const whosThat = createStore('pdx.whosThat', { best: 0, plays: 0, wins: 0 }); + +export function recordResult(win, streak) { + whosThat.set((s) => ({ + ...s, + plays: s.plays + 1, + wins: s.wins + (win ? 1 : 0), + best: Math.max(s.best, streak), + })); +} diff --git a/src/styles/layout.css b/src/styles/layout.css index 323035e..c2d518a 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -3735,3 +3735,103 @@ width: 100%; justify-content: center; } + +/* ---- Hidden mini-game: Who's that Pokémon? (#/whos-that) ---------- */ +.wtp { + max-width: 30rem; + margin: 0 auto; +} +.wtp__score { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 4px 0 14px; +} +.wtp__pill { + padding: 4px 10px; + border-radius: 999px; + background: var(--surface-2); + font-size: 0.78rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.wtp__pill--dim { + background: none; + color: var(--text-dim); + font-weight: 600; +} +.wtp__stage { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 22px 16px; + border-radius: var(--radius-lg); + background: + radial-gradient( + 60% 55% at 50% 42%, + color-mix(in srgb, var(--accent) 22%, transparent), + transparent 70% + ), + var(--surface); + border: 1px solid var(--border); +} +.wtp__art { + width: 260px; + max-width: 78vw; + aspect-ratio: 1; + display: grid; + place-items: center; +} +.wtp__art img { + width: 100%; + height: 100%; + object-fit: contain; + filter: brightness(0); + transition: filter 0.5s ease; +} +.wtp__art.is-revealed img { + filter: none; +} +.wtp__caption { + font-weight: 800; + font-size: 1.05rem; + text-align: center; + letter-spacing: 0.01em; +} +.wtp__hint { + min-height: 1.1em; + font-size: 0.82rem; + color: var(--text-dim); +} +.wtp__controls { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} +.wtp__input { + flex: 1 1 12rem; + padding: 10px 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + font: inherit; +} +.wtp__input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} +.wtp__feedback { + min-height: 1.4em; + margin-top: 10px; + font-weight: 700; + text-align: center; +} +.wtp__feedback.is-good { + color: var(--good); +} +.wtp__feedback.is-bad { + color: var(--danger); +} diff --git a/src/views/WhosThatView.js b/src/views/WhosThatView.js new file mode 100644 index 0000000..9816e88 --- /dev/null +++ b/src/views/WhosThatView.js @@ -0,0 +1,157 @@ +import { el, clear, onTeardown } from '../lib/dom.js'; +import { loadSnapshot } from '../data/snapshot.js'; +import { Sprite } from '../components/Sprite.js'; +import { whosThat, recordResult } from '../store/whosThat.js'; +import { prefersReducedMotion } from '../store/settings.js'; + +const loose = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, ''); +const pretty = (name) => name.replace(/-/g, ' '); + +/** + * Hidden mini-game — reachable by the Konami code (↑↑↓↓←→←→BA) or seven + * quick taps on the nav wordmark, and by the bare #/whos-that hash once + * you know it. Guess the Pokémon from its silhouette. + */ +export async function WhosThatView() { + const view = el('section', { class: 'view wtp' }); + const snap = await loadSnapshot(); + const pool = snap.species; + + let current = null; + let revealed = false; + let wrong = 0; + let streak = 0; + + const art = el('div', { class: 'wtp__art' }); + const caption = el('div', { class: 'wtp__caption' }, 'Who’s that Pokémon?'); + const hint = el('div', { class: 'wtp__hint' }); + const feedback = el('div', { class: 'wtp__feedback' }); + const scoreEl = el('div', { class: 'wtp__score' }); + + const input = el('input', { + class: 'wtp__input', + type: 'text', + placeholder: 'Your guess…', + autocomplete: 'off', + autocapitalize: 'off', + autocorrect: 'off', + spellcheck: 'false', + onkeydown: (e) => { + if (e.key === 'Enter') submitGuess(); + }, + }); + + const guessBtn = el( + 'button', + { class: 'button wtp__go', type: 'button', onclick: submitGuess }, + 'Guess', + ); + const revealBtn = el( + 'button', + { class: 'button button--ghost', type: 'button', onclick: () => reveal(false) }, + 'Reveal', + ); + const nextBtn = el('button', { class: 'button', type: 'button', onclick: next }, 'Next →'); + + function renderScore() { + const s = whosThat.get(); + const pct = s.plays ? Math.round((s.wins / s.plays) * 100) : 0; + clear(scoreEl).append( + el('span', { class: 'wtp__pill' }, `Streak ${streak}`), + el('span', { class: 'wtp__pill' }, `Best ${s.best}`), + el('span', { class: 'wtp__pill wtp__pill--dim' }, `${s.wins}/${s.plays} · ${pct}%`), + ); + } + + function syncButtons() { + input.hidden = revealed; + guessBtn.hidden = revealed; + revealBtn.hidden = revealed; + nextBtn.hidden = !revealed; + } + + function next() { + current = pool[Math.floor(Math.random() * pool.length)]; + revealed = false; + wrong = 0; + art.classList.remove('is-revealed'); + clear(art).append(Sprite(current.id, { style: 'official', size: 260, alt: 'Mystery Pokémon' })); + caption.textContent = 'Who’s that Pokémon?'; + hint.textContent = ''; + feedback.textContent = ''; + feedback.className = 'wtp__feedback'; + input.value = ''; + syncButtons(); + input.focus(); + } + + function reveal(win) { + revealed = true; + art.classList.add('is-revealed'); + caption.textContent = `It’s ${pretty(current.name)}! #${String(current.id).padStart(4, '0')}`; + if (win) { + streak += 1; + feedback.textContent = wrong === 0 ? 'Nailed it!' : 'Got there!'; + feedback.className = 'wtp__feedback is-good'; + } else { + streak = 0; + feedback.textContent = 'That one got away.'; + feedback.className = 'wtp__feedback is-bad'; + } + recordResult(win, streak); + renderScore(); + syncButtons(); + if (win && !prefersReducedMotion()) { + art.animate( + [{ transform: 'scale(0.9)' }, { transform: 'scale(1.05)' }, { transform: 'scale(1)' }], + { duration: 380, easing: 'cubic-bezier(.34,1.4,.64,1)' }, + ); + } + nextBtn.focus(); + } + + function submitGuess() { + if (revealed || !input.value.trim()) return; + if (loose(input.value) === loose(current.name)) { + reveal(true); + return; + } + wrong += 1; + input.value = ''; + feedback.className = 'wtp__feedback is-bad'; + if (wrong === 1) { + feedback.textContent = 'Nope — try again.'; + hint.textContent = `${loose(current.name).length} letters`; + } else if (wrong === 2) { + feedback.textContent = 'Last clue…'; + hint.textContent = `Starts with “${current.name[0].toUpperCase()}”, ${loose(current.name).length} letters`; + } else { + reveal(false); // three strikes + return; + } + input.focus(); + } + + view.append( + el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/' }, '‹ Dex')), + el( + 'header', + { class: 'view__header' }, + el('h1', {}, 'Who’s that Pokémon?'), + el('p', {}, 'Name it from the silhouette. Three misses and it’s revealed.'), + ), + scoreEl, + el('div', { class: 'wtp__stage' }, art, caption, hint), + el('div', { class: 'wtp__controls' }, input, guessBtn, revealBtn, nextBtn), + feedback, + ); + + renderScore(); + next(); + + const off = whosThat.subscribe(() => { + if (revealed) renderScore(); + }); + onTeardown(view, off); + return view; +} diff --git a/test/konami.test.js b/test/konami.test.js new file mode 100644 index 0000000..4883667 --- /dev/null +++ b/test/konami.test.js @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { konamiHandler, KONAMI_SEQUENCE } from '../src/lib/konami.js'; + +describe('konamiHandler', () => { + it('fires once the full sequence is entered', () => { + let hits = 0; + const step = konamiHandler(() => hits++); + KONAMI_SEQUENCE.forEach((k) => step(k)); + expect(hits).toBe(1); + }); + + it('is case-insensitive and fires again on a repeat', () => { + let hits = 0; + const step = konamiHandler(() => hits++); + const upper = [ + 'ArrowUp', + 'ArrowUp', + 'ArrowDown', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'ArrowLeft', + 'ArrowRight', + 'B', + 'A', + ]; + upper.forEach((k) => step(k)); + upper.forEach((k) => step(k)); + expect(hits).toBe(2); + }); + + it('a wrong key rewinds, but a stray ↑ starts a fresh attempt', () => { + let hits = 0; + const step = konamiHandler(() => hits++); + step('arrowup'); + step('arrowdown'); // wrong (expected another arrowup) -> reset + step('x'); // noise + KONAMI_SEQUENCE.forEach((k) => step(k)); // clean run + expect(hits).toBe(1); + }); + + it('does not fire on a partial sequence', () => { + let hits = 0; + const step = konamiHandler(() => hits++); + KONAMI_SEQUENCE.slice(0, -1).forEach((k) => step(k)); + expect(hits).toBe(0); + }); +});