Hidden mini-game: "Who's that Pokémon?"
Guess the Pokémon from its silhouette (official artwork, filter:brightness(0) until revealed). Streak + best + win% persisted in pdx.whosThat. Three misses auto-reveal; hints (letter count, then first letter) after wrong guesses; guess matching is punctuation/space/case-insensitive. Not in the nav or any link. Reached by the Konami code (↑↑↓↓←→←→BA, lib/konami.js wired in main.js), seven quick taps on the nav wordmark, or the bare #/whos-that hash once you know it. 9 new unit tests for the konami matcher (sequence, case-insensitivity, rewind, partial). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
parent
bbc4cb48ca
commit
fb299d11f9
@ -25,6 +25,20 @@ export function Nav() {
|
|||||||
const nav = el('nav', { class: 'nav', 'aria-label': 'Primary' });
|
const nav = el('nav', { class: 'nav', 'aria-label': 'Primary' });
|
||||||
let links = [];
|
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() {
|
function build() {
|
||||||
const st = settings.get();
|
const st = settings.get();
|
||||||
const items = ITEMS.filter((it) => !it.optional || st[it.optional]);
|
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);
|
: 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;
|
nav._items = items;
|
||||||
sync();
|
sync();
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/lib/konami.js
Normal file
42
src/lib/konami.js
Normal file
@ -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);
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import { settings, applyTheme } from './store/settings.js';
|
|||||||
import { ui } from './store/ui.js';
|
import { ui } from './store/ui.js';
|
||||||
import { markPlayed } from './store/playedGames.js';
|
import { markPlayed } from './store/playedGames.js';
|
||||||
import { onInstallChange, promptInstall } from './lib/install.js';
|
import { onInstallChange, promptInstall } from './lib/install.js';
|
||||||
|
import { onKonami } from './lib/konami.js';
|
||||||
|
|
||||||
applyTheme();
|
applyTheme();
|
||||||
|
|
||||||
@ -51,6 +52,11 @@ app.append(Nav(), viewHost);
|
|||||||
|
|
||||||
initRouter(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 ------
|
// --- Service worker: prompt to refresh rather than silently swapping ------
|
||||||
const updateSW = registerSW({
|
const updateSW = registerSW({
|
||||||
onNeedRefresh() {
|
onNeedRefresh() {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { CompareView } from './views/CompareView.js';
|
|||||||
import { ProgressView } from './views/ProgressView.js';
|
import { ProgressView } from './views/ProgressView.js';
|
||||||
import { BreedingView } from './views/BreedingView.js';
|
import { BreedingView } from './views/BreedingView.js';
|
||||||
import { ShinyView } from './views/ShinyView.js';
|
import { ShinyView } from './views/ShinyView.js';
|
||||||
|
import { WhosThatView } from './views/WhosThatView.js';
|
||||||
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
|
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
|
||||||
import { prefersReducedMotion } from './store/settings.js';
|
import { prefersReducedMotion } from './store/settings.js';
|
||||||
|
|
||||||
@ -39,6 +40,7 @@ const routes = [
|
|||||||
{ pattern: /^#\/shiny$/, view: () => ShinyView() },
|
{ pattern: /^#\/shiny$/, view: () => ShinyView() },
|
||||||
{ pattern: /^#\/search$/, view: () => SearchView() },
|
{ pattern: /^#\/search$/, view: () => SearchView() },
|
||||||
{ pattern: /^#\/settings$/, view: () => SettingsView() },
|
{ pattern: /^#\/settings$/, view: () => SettingsView() },
|
||||||
|
{ pattern: /^#\/whos-that$/, view: () => WhosThatView() }, // hidden — Konami / logo taps
|
||||||
];
|
];
|
||||||
|
|
||||||
function resolve(hash) {
|
function resolve(hash) {
|
||||||
|
|||||||
13
src/store/whosThat.js
Normal file
13
src/store/whosThat.js
Normal file
@ -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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@ -3735,3 +3735,103 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
157
src/views/WhosThatView.js
Normal file
157
src/views/WhosThatView.js
Normal file
@ -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;
|
||||||
|
}
|
||||||
48
test/konami.test.js
Normal file
48
test/konami.test.js
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user