Compare commits
3 Commits
edef6bf879
...
fb299d11f9
| Author | SHA1 | Date | |
|---|---|---|---|
| fb299d11f9 | |||
| bbc4cb48ca | |||
| 3f887b2f8a |
13
README.md
13
README.md
@ -126,7 +126,9 @@ Every game's seen/caught tally against its own dex, from its own per-game
|
||||
bucket. Leads with the games you've played (selected at least once, or
|
||||
imported a save for); the rest sit behind a toggle. The National Dex row is
|
||||
the union across all games. **Edit** the "Your games" list to reset a
|
||||
game's caught/seen or drop it from the list.
|
||||
game's caught/seen or drop it from the list; **Reset caught / seen…** wipes
|
||||
every game at once (favorites and notes are kept — only Settings →
|
||||
_Clear tracking data_ removes those).
|
||||
|
||||
### Shiny hunt tracker (`#/shiny`)
|
||||
|
||||
@ -141,9 +143,9 @@ marks the Pokémon caught.
|
||||
bitfields — not an approximation — from:
|
||||
|
||||
| Gen | Games | Format |
|
||||
| --- | -------------------------------------------------- | -------------------------------------------------------- |
|
||||
| --- | -------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| 1 | Red / Blue / Yellow | 32 KB SRAM (`.sav` / `.srm`) |
|
||||
| 2 | Gold / Silver / Crystal | 32 KB SRAM — checksum picks G/S vs Crystal |
|
||||
| 2 | Gold / Silver / Crystal | 32 KB SRAM — checksum picks G/S vs Crystal; also reads the Unown Report |
|
||||
| 3 | Ruby / Sapphire / Emerald, FireRed / LeafGreen | 128 KB — newer slot, rotated sections |
|
||||
| 4 | Diamond / Pearl / Platinum, HeartGold / SoulSilver | 512 KB NDS — active slot, DeSmuME `.dsv` footer stripped |
|
||||
| 5 | Black / White, Black 2 / White 2 | 512 KB NDS — per-form "seen" copies OR'd |
|
||||
@ -154,6 +156,11 @@ species is also seen). A dialog then offers **merge** or **replace** into
|
||||
one game's bucket — the selected game if the save could be it, otherwise
|
||||
the parser's best guess.
|
||||
|
||||
The dex bitfields are per-species, so form sets (Unown letters, Vivillon
|
||||
patterns…) can't be recovered from them — **except** the Gen 2 Unown
|
||||
Report, which records the letters you've caught; a Gen 2 import ticks
|
||||
those in the per-letter Form Dex.
|
||||
|
||||
Full local state (settings, per-game tracking, team, form tracking, shiny
|
||||
hunts, played games) also exports / imports as a single JSON file.
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@ -122,10 +122,14 @@ function tryGen1(bytes) {
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const G2 = {
|
||||
// `unown` is the Unown Report list — the letter forms caught, in
|
||||
// first-caught order, one byte each (1 = A … 26 = Z), zero-terminated. It
|
||||
// sits right after the 32-byte Seen flag array (`unown === seen + 0x20`).
|
||||
gs: {
|
||||
name: 0x200b,
|
||||
owned: 0x2a4c,
|
||||
seen: 0x2a6c,
|
||||
unown: 0x2a8c,
|
||||
ck: 0x2d69,
|
||||
ckFrom: 0x2009,
|
||||
ckTo: 0x2d68,
|
||||
@ -136,6 +140,7 @@ const G2 = {
|
||||
name: 0x2009,
|
||||
owned: 0x2a27,
|
||||
seen: 0x2a47,
|
||||
unown: 0x2a67,
|
||||
ck: 0x2d0d,
|
||||
ckFrom: 0x2009,
|
||||
ckTo: 0x2b82,
|
||||
@ -145,6 +150,32 @@ const G2 = {
|
||||
};
|
||||
const G2_SPECIES = 251;
|
||||
|
||||
// Letter index (1 = A) → the app's cosmetic-form slug. The base "A" form is
|
||||
// tracked under the bare species slug; B…Z are `unown-b` … `unown-z`.
|
||||
const unownSlug = (n) => (n === 1 ? 'unown' : `unown-${String.fromCharCode(96 + n)}`);
|
||||
|
||||
/**
|
||||
* Read the Gen 2 Unown Report. Returns the caught letters as form slugs, or
|
||||
* `null` if the bytes don't look like a valid ordered list (wrong offset,
|
||||
* corruption) — a species-level import shouldn't tick form boxes on a guess.
|
||||
*/
|
||||
function readUnownDex(bytes, offset) {
|
||||
const slugs = [];
|
||||
const seen = new Set();
|
||||
let ended = false;
|
||||
for (let i = 0; i < 26; i++) {
|
||||
const b = bytes[offset + i];
|
||||
if (b === 0) {
|
||||
ended = true;
|
||||
continue;
|
||||
}
|
||||
if (ended || b > 26 || seen.has(b)) return null;
|
||||
seen.add(b);
|
||||
slugs.push(unownSlug(b));
|
||||
}
|
||||
return slugs;
|
||||
}
|
||||
|
||||
function tryGen2Variant(bytes, v) {
|
||||
const caught = bitsToList(bytes, v.owned, G2_SPECIES);
|
||||
const seen = bitsToList(bytes, v.seen, G2_SPECIES);
|
||||
@ -162,6 +193,9 @@ function tryGen2Variant(bytes, v) {
|
||||
trainer: decodeName(bytes.subarray(v.name, v.name + 11), GB_CHARS, 0x50),
|
||||
seen,
|
||||
caught,
|
||||
// Gen 2 is the only mainline dex that records which Unown letters you
|
||||
// have — tick them in the app's per-letter Form Dex.
|
||||
formCaught: readUnownDex(bytes, v.unown) || [],
|
||||
nationalUnlocked: true,
|
||||
checksumOk,
|
||||
};
|
||||
@ -387,7 +421,7 @@ function tryGen5(raw) {
|
||||
|
||||
/**
|
||||
* @param {ArrayBuffer} buffer
|
||||
* @returns {{ gen:number, game:string, versionGroups:string[], trainer:string, seen:number[], caught:number[], nationalUnlocked:boolean }}
|
||||
* @returns {{ gen:number, game:string, versionGroups:string[], trainer:string, seen:number[], caught:number[], formCaught?:string[], nationalUnlocked:boolean }}
|
||||
* @throws {Error} with a user-facing message if it can't be read.
|
||||
*/
|
||||
export function parseSaveDex(buffer) {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -18,3 +18,13 @@ export function toggleFormCaught(slug) {
|
||||
caught: { ...s.caught, [slug]: !s.caught[slug] },
|
||||
}));
|
||||
}
|
||||
|
||||
/** Mark a batch of form slugs caught (e.g. Unown letters from a save import). Never un-marks. */
|
||||
export function markFormsCaught(slugs) {
|
||||
if (!slugs || !slugs.length) return;
|
||||
formTracking.set((s) => {
|
||||
const caught = { ...s.caught };
|
||||
for (const slug of slugs) caught[slug] = true;
|
||||
return { ...s, caught };
|
||||
});
|
||||
}
|
||||
|
||||
@ -149,6 +149,15 @@ export function clearGame(vgKey) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe seen/caught for every game (a "National Dex reset"). Favorites and
|
||||
* notes — the global `pokemon` record — are kept. Use `selection.replace`
|
||||
* for the full wipe that also drops those.
|
||||
*/
|
||||
export function clearAllGames() {
|
||||
selection.set((s) => ({ ...s, games: {} }));
|
||||
}
|
||||
|
||||
/** Aggregate seen/caught over a species list for one game (union when 'all'). */
|
||||
export function stats(speciesIds, vgKey = curGame()) {
|
||||
const get = vgKey === 'all' ? unionEntry : (id) => selection.get().games[vgKey]?.[id] || GBLANK;
|
||||
|
||||
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),
|
||||
}));
|
||||
}
|
||||
@ -3382,6 +3382,13 @@
|
||||
color: var(--text-dim);
|
||||
text-align: right;
|
||||
}
|
||||
.prog__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.prog__legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -3389,6 +3396,19 @@
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.prog__resetall {
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--danger);
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.prog__resetall:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.prog__key {
|
||||
display: inline-block;
|
||||
width: 1.1rem;
|
||||
@ -3715,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);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { el, clear, onTeardown } from '../lib/dom.js';
|
||||
import { loadSnapshot } from '../data/snapshot.js';
|
||||
import { settings } from '../store/settings.js';
|
||||
import { selection, stats, statsNational, clearGame } from '../store/selection.js';
|
||||
import { selection, stats, statsNational, clearGame, clearAllGames } from '../store/selection.js';
|
||||
import { ui } from '../store/ui.js';
|
||||
import { playedGames, isPlayed, unmarkPlayed } from '../store/playedGames.js';
|
||||
import { dexesForVersionGroup, versionGroupsByGeneration } from '../data/pokedex-resolver.js';
|
||||
@ -172,6 +172,23 @@ export async function ProgressView() {
|
||||
}
|
||||
render();
|
||||
|
||||
async function resetEverything() {
|
||||
const nat = statsNational(snap.species.map((s) => s.id));
|
||||
if (!nat.seen && !nat.caught) return;
|
||||
const pick = await chooseDialog({
|
||||
title: 'Reset caught / seen',
|
||||
body: `Clear all ${nat.caught} caught and ${nat.seen} seen, across every game? Favorites and notes are kept.`,
|
||||
choices: [
|
||||
{ key: 'reset', label: 'Reset everything', class: 'button--danger' },
|
||||
{ key: null, label: 'Cancel', class: 'button--ghost' },
|
||||
],
|
||||
});
|
||||
if (pick === 'reset') {
|
||||
clearAllGames();
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
clear(view).append(
|
||||
el(
|
||||
'nav',
|
||||
@ -184,6 +201,9 @@ export async function ProgressView() {
|
||||
el('h1', {}, 'Progress by game'),
|
||||
el('p', {}, 'What you’ve caught in each game. Tap one to switch to it.'),
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'prog__bar' },
|
||||
el(
|
||||
'p',
|
||||
{ class: 'prog__legend' },
|
||||
@ -192,6 +212,12 @@ export async function ProgressView() {
|
||||
el('span', { class: 'prog__key prog__key--seen' }),
|
||||
' seen',
|
||||
),
|
||||
el(
|
||||
'button',
|
||||
{ type: 'button', class: 'prog__resetall', onclick: resetEverything },
|
||||
'Reset caught / seen…',
|
||||
),
|
||||
),
|
||||
list,
|
||||
);
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import { selection, importFlags, replaceFlags, normalizeSelection } from '../sto
|
||||
import { prettify } from '../data/pokedex-resolver.js';
|
||||
import { chooseDialog } from '../lib/dialog.js';
|
||||
import { team } from '../store/team.js';
|
||||
import { formTracking } from '../store/formTracking.js';
|
||||
import { formTracking, markFormsCaught } from '../store/formTracking.js';
|
||||
import { shinyHunts } from '../store/shinyHunts.js';
|
||||
import { playedGames, markPlayed } from '../store/playedGames.js';
|
||||
import { parseSaveDex } from '../lib/savedex.js';
|
||||
@ -180,9 +180,11 @@ export async function SettingsView() {
|
||||
const target = cands.includes(settings.get().versionGroup)
|
||||
? settings.get().versionGroup
|
||||
: cands[0];
|
||||
const unown = dex.formCaught?.length || 0;
|
||||
const unownLine = unown ? ` · ${unown} Unown letter${unown === 1 ? '' : 's'}` : '';
|
||||
const choice = await chooseDialog({
|
||||
title: `${dex.game}${dex.trainer ? ` · ${dex.trainer}` : ''}`,
|
||||
body: `${dex.seen.length} seen · ${dex.caught.length} caught in this save.`,
|
||||
body: `${dex.seen.length} seen · ${dex.caught.length} caught${unownLine} in this save.`,
|
||||
choices: [
|
||||
{ key: 'merge', label: `Add to ${prettify(target)}` },
|
||||
{
|
||||
@ -195,12 +197,14 @@ export async function SettingsView() {
|
||||
});
|
||||
if (choice === 'merge') {
|
||||
importFlags({ seen: dex.seen, caught: dex.caught, game: target });
|
||||
markFormsCaught(dex.formCaught);
|
||||
markPlayed(...cands);
|
||||
saveNote.textContent = `Added ${dex.caught.length} caught / ${dex.seen.length} seen to ${prettify(target)}.`;
|
||||
saveNote.textContent = `Added ${dex.caught.length} caught / ${dex.seen.length} seen${unownLine} to ${prettify(target)}.`;
|
||||
} else if (choice === 'replace') {
|
||||
replaceFlags({ seen: dex.seen, caught: dex.caught, game: target });
|
||||
markFormsCaught(dex.formCaught);
|
||||
markPlayed(...cands);
|
||||
saveNote.textContent = `Set ${prettify(target)}'s Pokédex from ${who}.`;
|
||||
saveNote.textContent = `Set ${prettify(target)}'s Pokédex from ${who}${unownLine ? ` (${unown} Unown letters)` : ''}.`;
|
||||
} else {
|
||||
saveNote.textContent = '';
|
||||
}
|
||||
@ -223,9 +227,17 @@ export async function SettingsView() {
|
||||
if (data.formTracking) formTracking.replace(data.formTracking);
|
||||
if (data.shinyHunts) shinyHunts.replace(data.shinyHunts);
|
||||
if (data.playedGames) playedGames.replace(data.playedGames);
|
||||
alert('Import complete.');
|
||||
await chooseDialog({
|
||||
title: 'Import complete',
|
||||
body: 'Your backup was restored.',
|
||||
choices: [{ key: null, label: 'OK' }],
|
||||
});
|
||||
} catch {
|
||||
alert('That file could not be read as a Pokédex backup.');
|
||||
await chooseDialog({
|
||||
title: 'Couldn’t read that file',
|
||||
body: 'It doesn’t look like a Pocketdex backup.',
|
||||
choices: [{ key: null, label: 'OK' }],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -322,10 +334,16 @@ export async function SettingsView() {
|
||||
{
|
||||
class: 'button button--danger',
|
||||
type: 'button',
|
||||
onclick: () => {
|
||||
if (confirm('Clear all seen/caught/favorite tracking? This cannot be undone.')) {
|
||||
selection.replace({ version: 3, pokemon: {}, games: {} });
|
||||
}
|
||||
onclick: async () => {
|
||||
const pick = await chooseDialog({
|
||||
title: 'Clear all tracking data',
|
||||
body: 'Removes seen/caught for every game plus all favorites and notes, and can’t be undone. To keep favorites and notes, use “Reset caught / seen” on the Progress screen instead.',
|
||||
choices: [
|
||||
{ key: 'clear', label: 'Clear everything', class: 'button--danger' },
|
||||
{ key: null, label: 'Cancel', class: 'button--ghost' },
|
||||
],
|
||||
});
|
||||
if (pick === 'clear') selection.replace({ version: 3, pokemon: {}, games: {} });
|
||||
},
|
||||
},
|
||||
'Clear tracking data',
|
||||
@ -337,12 +355,21 @@ export async function SettingsView() {
|
||||
type: 'button',
|
||||
onclick: async () => {
|
||||
if (!('caches' in window)) return;
|
||||
if (
|
||||
confirm('Clear cached PokéAPI data and sprites? They will re-download when online.')
|
||||
) {
|
||||
const pick = await chooseDialog({
|
||||
title: 'Clear cached data',
|
||||
body: 'Drops cached PokéAPI responses and sprites. They re-download next time you’re online.',
|
||||
choices: [
|
||||
{ key: 'clear', label: 'Clear cache', class: 'button--danger' },
|
||||
{ key: null, label: 'Cancel', class: 'button--ghost' },
|
||||
],
|
||||
});
|
||||
if (pick !== 'clear') return;
|
||||
for (const key of await caches.keys()) await caches.delete(key);
|
||||
alert('Caches cleared.');
|
||||
}
|
||||
await chooseDialog({
|
||||
title: 'Cache cleared',
|
||||
body: '',
|
||||
choices: [{ key: null, label: 'OK' }],
|
||||
});
|
||||
},
|
||||
},
|
||||
'Clear cached API data',
|
||||
|
||||
@ -6,6 +6,7 @@ import { toggle, entry } from '../store/selection.js';
|
||||
import { buzz } from '../lib/haptics.js';
|
||||
import { Sprite } from '../components/Sprite.js';
|
||||
import { openPokemonPicker } from '../components/PokemonPicker.js';
|
||||
import { chooseDialog } from '../lib/dialog.js';
|
||||
|
||||
// Representative full-game odds (as 1/N), with a shiny-charm variant.
|
||||
export const METHODS = [
|
||||
@ -131,8 +132,16 @@ export async function ShinyView() {
|
||||
{
|
||||
type: 'button',
|
||||
class: 'button button--danger',
|
||||
onclick: () => {
|
||||
if (confirm('Delete this hunt?')) {
|
||||
onclick: async () => {
|
||||
const pick = await chooseDialog({
|
||||
title: 'Delete this hunt?',
|
||||
body: `${(sp?.name || 'This hunt').replace(/-/g, ' ')} — ${h.count} checks logged.`,
|
||||
choices: [
|
||||
{ key: 'del', label: 'Delete', class: 'button--danger' },
|
||||
{ key: null, label: 'Cancel', class: 'button--ghost' },
|
||||
],
|
||||
});
|
||||
if (pick === 'del') {
|
||||
removeHunt(h.id);
|
||||
render();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
37
test/formTracking.test.js
Normal file
37
test/formTracking.test.js
Normal file
@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
formTracking,
|
||||
isFormCaught,
|
||||
toggleFormCaught,
|
||||
markFormsCaught,
|
||||
} from '../src/store/formTracking.js';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
formTracking.replace({ caught: {} });
|
||||
});
|
||||
|
||||
describe('formTracking', () => {
|
||||
it('toggle flips a slug', () => {
|
||||
expect(isFormCaught('unown-c')).toBe(false);
|
||||
toggleFormCaught('unown-c');
|
||||
expect(isFormCaught('unown-c')).toBe(true);
|
||||
toggleFormCaught('unown-c');
|
||||
expect(isFormCaught('unown-c')).toBe(false);
|
||||
});
|
||||
|
||||
it('markFormsCaught sets a batch and never un-marks', () => {
|
||||
toggleFormCaught('unown-z'); // already caught
|
||||
markFormsCaught(['unown', 'unown-c', 'unown-z']);
|
||||
expect(isFormCaught('unown')).toBe(true);
|
||||
expect(isFormCaught('unown-c')).toBe(true);
|
||||
expect(isFormCaught('unown-z')).toBe(true); // stays caught
|
||||
expect(isFormCaught('unown-q')).toBe(false); // untouched
|
||||
});
|
||||
|
||||
it('markFormsCaught tolerates empty / missing input', () => {
|
||||
markFormsCaught([]);
|
||||
markFormsCaught(undefined);
|
||||
expect(formTracking.get().caught).toEqual({});
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -59,3 +59,41 @@ describe('parseSaveDex — Gen 1', () => {
|
||||
expect(() => parseSaveDex(new Uint8Array(0x20000).buffer)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Synthetic Gen 2 (Crystal) SRAM ---------------------------------
|
||||
// Offsets mirror src/lib/savedex.js `G2.cr`.
|
||||
const G2 = { owned: 0x2a27, seen: 0x2a47, unown: 0x2a67, ck: 0x2d0d, ckFrom: 0x2009, ckTo: 0x2b82 };
|
||||
|
||||
function makeGen2Save({ caught, seen, unown = [] }) {
|
||||
const bytes = new Uint8Array(0x8000);
|
||||
setBits(bytes, G2.owned, caught);
|
||||
setBits(bytes, G2.seen, seen);
|
||||
unown.forEach((letter, i) => (bytes[G2.unown + i] = letter)); // 1 = A … 26 = Z
|
||||
let sum = 0;
|
||||
for (let i = G2.ckFrom; i <= G2.ckTo; i++) sum = (sum + bytes[i]) & 0xffffffff;
|
||||
bytes[G2.ck] = sum & 0xff;
|
||||
bytes[G2.ck + 1] = (sum >> 8) & 0xff;
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
describe('parseSaveDex — Gen 2 Unown Report', () => {
|
||||
it('turns the caught-letter list into Form Dex slugs', () => {
|
||||
const dex = parseSaveDex(
|
||||
makeGen2Save({ caught: [1, 25], seen: [1, 25, 201], unown: [3, 1, 20] }), // C, A, T
|
||||
);
|
||||
expect(dex.gen).toBe(2);
|
||||
expect(dex.game).toBe('Crystal');
|
||||
expect(dex.checksumOk).toBe(true);
|
||||
expect([...dex.formCaught].sort()).toEqual(['unown', 'unown-c', 'unown-t']);
|
||||
});
|
||||
|
||||
it('no Unown caught → empty list', () => {
|
||||
const dex = parseSaveDex(makeGen2Save({ caught: [1], seen: [1], unown: [] }));
|
||||
expect(dex.formCaught).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores a garbage list rather than ticking wrong boxes', () => {
|
||||
const dex = parseSaveDex(makeGen2Save({ caught: [1], seen: [1], unown: [3, 99, 5] }));
|
||||
expect(dex.formCaught).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user