Compare commits
2 Commits
c1ce972ce9
...
83cb89aedd
| Author | SHA1 | Date | |
|---|---|---|---|
| 83cb89aedd | |||
| a70f3c9976 |
@ -9,7 +9,7 @@ framework.
|
||||
| Feature | How it works |
|
||||
| --- | --- |
|
||||
| **Game series selection** | Pick a *game* (PokéAPI version group, e.g. Scarlet & Violet). If it has more than one regional dex (Paldea / Kitakami / Blueberry) a sub-dex switcher appears. The choice drives which species show, their regional numbering, and which version's flavor text the detail page uses. Stored in `localStorage`. |
|
||||
| **Unified Pokémon selection** | One tracking record per Pokémon, keyed by **National Dex id**. Mark Pikachu once and every game's view reflects it — each dex computes its own "Seen / Caught" totals by intersecting its species list with that single map. `#/progress` breaks that down for every game at once. The active Pokémon is also a single deep-linkable route (`#/pokemon/25`). |
|
||||
| **Unified Pokémon selection** | One tracking record per Pokémon, keyed by **National Dex id**. Mark Pikachu once and every game's view reflects it — each dex computes its own "Seen / Caught" totals by intersecting its species list with that single map. `#/progress` breaks that down per game, leading with the ones you've actually played (selected, or imported a save for) and tucking the rest behind a toggle. The active Pokémon is also a single deep-linkable route (`#/pokemon/25`). |
|
||||
|
||||
## Data & storage split
|
||||
|
||||
|
||||
@ -87,6 +87,7 @@ function tryGen1(bytes) {
|
||||
return {
|
||||
gen: 1,
|
||||
game: 'Red / Blue / Yellow',
|
||||
versionGroups: ['red-blue', 'yellow'],
|
||||
trainer: decodeName(bytes.subarray(G1.name, G1.name + 11), GB_CHARS, 0x50),
|
||||
seen,
|
||||
caught,
|
||||
@ -100,8 +101,8 @@ function tryGen1(bytes) {
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const G2 = {
|
||||
gs: { name: 0x200b, owned: 0x2a4c, seen: 0x2a6c, ck: 0x2d69, ckFrom: 0x2009, ckTo: 0x2d68, label: 'Gold / Silver' },
|
||||
cr: { name: 0x2009, owned: 0x2a27, seen: 0x2a47, ck: 0x2d0d, ckFrom: 0x2009, ckTo: 0x2b82, label: 'Crystal' },
|
||||
gs: { name: 0x200b, owned: 0x2a4c, seen: 0x2a6c, ck: 0x2d69, ckFrom: 0x2009, ckTo: 0x2d68, label: 'Gold / Silver', vgs: ['gold-silver'] },
|
||||
cr: { name: 0x2009, owned: 0x2a27, seen: 0x2a47, ck: 0x2d0d, ckFrom: 0x2009, ckTo: 0x2b82, label: 'Crystal', vgs: ['crystal'] },
|
||||
};
|
||||
const G2_SPECIES = 251;
|
||||
|
||||
@ -118,6 +119,7 @@ function tryGen2Variant(bytes, v) {
|
||||
return {
|
||||
gen: 2,
|
||||
game: v.label,
|
||||
versionGroups: v.vgs,
|
||||
trainer: decodeName(bytes.subarray(v.name, v.name + 11), GB_CHARS, 0x50),
|
||||
seen,
|
||||
caught,
|
||||
@ -190,6 +192,7 @@ function tryGen3(bytes) {
|
||||
return {
|
||||
gen: 3,
|
||||
game: magic === 0xb9 ? 'FireRed / LeafGreen' : 'Ruby / Sapphire / Emerald',
|
||||
versionGroups: magic === 0xb9 ? ['firered-leafgreen'] : ['ruby-sapphire', 'emerald'],
|
||||
trainer: decodeName(bytes.subarray(s0, s0 + 7), GBA_CHARS, 0xff),
|
||||
seen,
|
||||
caught,
|
||||
@ -279,6 +282,7 @@ function tryGen4(raw) {
|
||||
return {
|
||||
gen: 4,
|
||||
game: 'Diamond / Pearl / Platinum · HeartGold / SoulSilver',
|
||||
versionGroups: ['diamond-pearl', 'platinum', 'heartgold-soulsilver'],
|
||||
trainer: '',
|
||||
seen: best.seen,
|
||||
caught: best.caught,
|
||||
@ -294,7 +298,7 @@ function readGen5Slot(bytes, slotBase, v) {
|
||||
const caught = bitsToList(bytes, p + GEN5.caught, GEN5.species);
|
||||
const seen = orSeen(bytes, p, GEN5.seen, GEN5.size, GEN5.species);
|
||||
if (!seen.length || caught.some((n) => !seen.includes(n))) return null;
|
||||
return { seen, caught, label: v.label };
|
||||
return { seen, caught, label: v.label, vg: v.dex === GEN5.bw.dex ? 'black-white' : 'black-2-white-2' };
|
||||
}
|
||||
|
||||
function tryGen5(raw) {
|
||||
@ -312,6 +316,7 @@ function tryGen5(raw) {
|
||||
return {
|
||||
gen: 5,
|
||||
game: best.label,
|
||||
versionGroups: [best.vg],
|
||||
trainer: '',
|
||||
seen: best.seen,
|
||||
caught: best.caught,
|
||||
@ -326,7 +331,7 @@ function tryGen5(raw) {
|
||||
|
||||
/**
|
||||
* @param {ArrayBuffer} buffer
|
||||
* @returns {{ gen:number, game:string, trainer:string, seen:number[], caught:number[], nationalUnlocked:boolean }}
|
||||
* @returns {{ gen:number, game:string, versionGroups:string[], trainer:string, seen:number[], caught:number[], nationalUnlocked:boolean }}
|
||||
* @throws {Error} with a user-facing message if it can't be read.
|
||||
*/
|
||||
export function parseSaveDex(buffer) {
|
||||
|
||||
@ -7,6 +7,7 @@ import { initRouter, rerender } from './router.js';
|
||||
import { Nav } from './components/Nav.js';
|
||||
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';
|
||||
|
||||
applyTheme();
|
||||
@ -30,10 +31,13 @@ if (!location.hash) {
|
||||
// game changes so its data (flavour text, learnset, matchups, evolution,
|
||||
// abilities, locations, era sprites) matches the new game.
|
||||
let lastVersionGroup = settings.get().versionGroup;
|
||||
// Selecting a game counts as having played it (see store/playedGames.js).
|
||||
markPlayed(lastVersionGroup);
|
||||
settings.subscribe((s) => {
|
||||
applyTheme();
|
||||
if (s.versionGroup !== lastVersionGroup) {
|
||||
lastVersionGroup = s.versionGroup;
|
||||
markPlayed(s.versionGroup);
|
||||
if ((location.hash || '').startsWith('#/pokemon/')) rerender();
|
||||
}
|
||||
});
|
||||
|
||||
27
src/store/playedGames.js
Normal file
27
src/store/playedGames.js
Normal file
@ -0,0 +1,27 @@
|
||||
import { createStore } from './createStore.js';
|
||||
|
||||
/**
|
||||
* The set of games (PokéAPI version-group keys) the user has actually
|
||||
* played — imported a save for, or opened the dex of at least once.
|
||||
*
|
||||
* The tracking model is one unified caught record per Pokémon, so every
|
||||
* game technically shows a caught total the moment you mark anything. That
|
||||
* over-reports games you've never touched. `#/progress` uses this set to
|
||||
* lead with the games you've played and tuck the rest away.
|
||||
*/
|
||||
export const playedGames = createStore('pdx.playedGames', { keys: [] });
|
||||
|
||||
export function isPlayed(key) {
|
||||
return playedGames.get().keys.includes(key);
|
||||
}
|
||||
|
||||
export function markPlayed(...keys) {
|
||||
const have = new Set(playedGames.get().keys);
|
||||
const add = keys.filter((k) => k && k !== 'all' && !have.has(k));
|
||||
if (!add.length) return;
|
||||
playedGames.set((s) => ({ ...s, keys: [...s.keys, ...add] }));
|
||||
}
|
||||
|
||||
export function unmarkPlayed(key) {
|
||||
playedGames.set((s) => ({ ...s, keys: s.keys.filter((k) => k !== key) }));
|
||||
}
|
||||
@ -34,4 +34,5 @@ export const ui = createStore('pdx.ui', {
|
||||
itCat: '',
|
||||
itSort: 'name',
|
||||
abSort: 'name',
|
||||
progShowAll: false,
|
||||
});
|
||||
|
||||
@ -112,6 +112,47 @@
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Row of shortcut chips under a view header (Team → Natures / Type chart / …) */
|
||||
.toollinks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: -6px 0 18px;
|
||||
}
|
||||
.toollink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 13px 7px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
line-height: 1;
|
||||
transition: transform 0.12s ease, border-color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
.toollink:hover {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||
}
|
||||
.toollink:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.toollink__ico {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
color: var(--accent);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
@ -2888,7 +2929,7 @@
|
||||
}
|
||||
.formdex {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(68px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
@ -2897,32 +2938,37 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 8px 4px 6px;
|
||||
gap: 4px;
|
||||
padding: 10px 4px 8px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
background: var(--surface-2);
|
||||
cursor: pointer;
|
||||
filter: grayscale(1);
|
||||
opacity: 0.55;
|
||||
transition: filter 0.15s, opacity 0.15s, background 0.15s;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.formdex__tile.is-caught {
|
||||
filter: none;
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, var(--type-main) 16%, var(--surface-2));
|
||||
}
|
||||
/* Dim only the artwork until caught — the label stays fully legible. */
|
||||
.formdex__sprite {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
filter: grayscale(1);
|
||||
opacity: 0.6;
|
||||
transition: filter 0.15s, opacity 0.15s;
|
||||
}
|
||||
.formdex__tile.is-caught .formdex__sprite {
|
||||
filter: none;
|
||||
opacity: 1;
|
||||
}
|
||||
.formdex__label {
|
||||
font-size: 0.62rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
line-height: 1.15;
|
||||
color: var(--text);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@ -3104,6 +3150,32 @@
|
||||
cursor: default;
|
||||
font-weight: 700;
|
||||
}
|
||||
.prog__row--played {
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
.prog__toggle {
|
||||
align-self: flex-start;
|
||||
margin-top: 8px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
color: var(--text-dim);
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.prog__toggle:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
.prog__rest {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.prog__name {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
|
||||
@ -511,8 +511,12 @@ export async function PokemonDetail(nationalId) {
|
||||
// because you switched your active game to browse something else.
|
||||
let formDexPanel = null;
|
||||
if (cosmeticForms.length > 1) {
|
||||
// For letter/pattern sets the base sprite *is* the first entry (Unown's
|
||||
// default artwork is the "A" shape), so give it that name rather than a
|
||||
// generic "Default".
|
||||
const DEFAULT_SLOT_NAME = { unown: 'A' };
|
||||
const slots = [
|
||||
{ slug: snapSpecies.name, id: nationalId, name: 'Default', sprite: null },
|
||||
{ slug: snapSpecies.name, id: nationalId, name: DEFAULT_SLOT_NAME[snapSpecies.name] || 'Default', sprite: null },
|
||||
...cosmeticForms,
|
||||
];
|
||||
const countEl = el('span', { class: 'formdex__count' });
|
||||
|
||||
@ -2,6 +2,8 @@ import { el, clear, onTeardown } from '../lib/dom.js';
|
||||
import { loadSnapshot } from '../data/snapshot.js';
|
||||
import { settings } from '../store/settings.js';
|
||||
import { selection, stats } from '../store/selection.js';
|
||||
import { ui } from '../store/ui.js';
|
||||
import { playedGames, isPlayed } from '../store/playedGames.js';
|
||||
import { dexesForVersionGroup, versionGroupsByGeneration } from '../data/pokedex-resolver.js';
|
||||
|
||||
function bar(seen, caught, total) {
|
||||
@ -28,11 +30,33 @@ export async function ProgressView() {
|
||||
return [...ids];
|
||||
};
|
||||
|
||||
function gameRow(vg, { played }) {
|
||||
const s = stats(speciesIdsFor(vg.key));
|
||||
return el(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: `prog__row${played ? ' prog__row--played' : ''}`,
|
||||
onclick: () => {
|
||||
settings.set({ versionGroup: vg.key, pokedex: null });
|
||||
location.hash = '#/';
|
||||
},
|
||||
},
|
||||
el('span', { class: 'prog__name' }, vg.name),
|
||||
bar(s.seen, s.caught, s.total),
|
||||
el('span', { class: 'prog__count' }, `${s.caught} / ${s.total}`),
|
||||
);
|
||||
}
|
||||
|
||||
const list = el('div', { class: 'prog' });
|
||||
function render() {
|
||||
clear(list);
|
||||
|
||||
// National total
|
||||
const gens = versionGroupsByGeneration(snap);
|
||||
const anyPlayed = gens.some(({ versionGroups }) => versionGroups.some((vg) => isPlayed(vg.key)));
|
||||
const showAll = ui.get().progShowAll || !anyPlayed;
|
||||
|
||||
// National total — always shown.
|
||||
const nat = stats(snap.species.map((s) => s.id));
|
||||
list.append(
|
||||
el(
|
||||
@ -44,27 +68,48 @@ export async function ProgressView() {
|
||||
),
|
||||
);
|
||||
|
||||
for (const { generation, versionGroups } of versionGroupsByGeneration(snap)) {
|
||||
list.append(el('h3', { class: 'prog__gen' }, `Gen ${generation.id} · ${generation.name}`));
|
||||
// Games you've played (imported a save for, or opened at least once).
|
||||
if (anyPlayed) {
|
||||
list.append(el('h3', { class: 'prog__gen' }, 'Your games'));
|
||||
for (const { versionGroups } of gens) {
|
||||
for (const vg of versionGroups) {
|
||||
const s = stats(speciesIdsFor(vg.key));
|
||||
if (isPlayed(vg.key)) list.append(gameRow(vg, { played: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Everything else — behind a toggle, since a single unified caught
|
||||
// record makes every game report a total whether or not you've touched it.
|
||||
const rest = [];
|
||||
let restCount = 0;
|
||||
for (const { generation, versionGroups } of gens) {
|
||||
const unplayed = versionGroups.filter((vg) => !isPlayed(vg.key));
|
||||
if (!unplayed.length) continue;
|
||||
rest.push(el('h3', { class: 'prog__gen' }, `Gen ${generation.id} · ${generation.name}`));
|
||||
for (const vg of unplayed) {
|
||||
rest.push(gameRow(vg, { played: false }));
|
||||
restCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (restCount) {
|
||||
if (anyPlayed) {
|
||||
list.append(
|
||||
el(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'prog__row',
|
||||
class: 'prog__toggle',
|
||||
onclick: () => {
|
||||
settings.set({ versionGroup: vg.key, pokedex: null });
|
||||
location.hash = '#/';
|
||||
ui.set({ progShowAll: !ui.get().progShowAll });
|
||||
render();
|
||||
},
|
||||
},
|
||||
el('span', { class: 'prog__name' }, vg.name),
|
||||
bar(s.seen, s.caught, s.total),
|
||||
el('span', { class: 'prog__count' }, `${s.caught} / ${s.total}`),
|
||||
showAll ? 'Hide other games' : `Show all other games (${restCount})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (showAll) list.append(el('div', { class: 'prog__rest' }, ...rest));
|
||||
}
|
||||
}
|
||||
render();
|
||||
@ -75,7 +120,7 @@ export async function ProgressView() {
|
||||
'header',
|
||||
{ class: 'view__header' },
|
||||
el('h1', {}, 'Progress by game'),
|
||||
el('p', {}, 'Your one unified caught record, counted against each game’s dex. Tap a game to switch to it.'),
|
||||
el('p', {}, 'Your one unified caught record, counted against each game’s dex. Games you haven’t played are tucked away. Tap a game to switch to it.'),
|
||||
),
|
||||
el(
|
||||
'p',
|
||||
@ -88,7 +133,11 @@ export async function ProgressView() {
|
||||
list,
|
||||
);
|
||||
|
||||
const off = selection.subscribe(render);
|
||||
onTeardown(view, () => off());
|
||||
const offSel = selection.subscribe(render);
|
||||
const offPlayed = playedGames.subscribe(render);
|
||||
onTeardown(view, () => {
|
||||
offSel();
|
||||
offPlayed();
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import { chooseDialog } from '../lib/dialog.js';
|
||||
import { team } from '../store/team.js';
|
||||
import { formTracking } from '../store/formTracking.js';
|
||||
import { shinyHunts } from '../store/shinyHunts.js';
|
||||
import { playedGames, markPlayed } from '../store/playedGames.js';
|
||||
import { parseSaveDex } from '../lib/savedex.js';
|
||||
import { canInstall, onInstallChange, promptInstall } from '../lib/install.js';
|
||||
|
||||
@ -154,9 +155,11 @@ export async function SettingsView() {
|
||||
});
|
||||
if (choice === 'merge') {
|
||||
importFlags({ seen: dex.seen, caught: dex.caught });
|
||||
markPlayed(...(dex.versionGroups || []));
|
||||
saveNote.textContent = `Added ${dex.caught.length} caught / ${dex.seen.length} seen from ${who}.`;
|
||||
} else if (choice === 'replace') {
|
||||
replaceFlags({ seen: dex.seen, caught: dex.caught });
|
||||
markPlayed(...(dex.versionGroups || []));
|
||||
saveNote.textContent = `Replaced tracking with ${who}'s Pokédex (favourites and notes kept).`;
|
||||
} else {
|
||||
saveNote.textContent = '';
|
||||
@ -179,6 +182,7 @@ export async function SettingsView() {
|
||||
if (data.team) team.replace(data.team);
|
||||
if (data.formTracking) formTracking.replace(data.formTracking);
|
||||
if (data.shinyHunts) shinyHunts.replace(data.shinyHunts);
|
||||
if (data.playedGames) playedGames.replace(data.playedGames);
|
||||
alert('Import complete.');
|
||||
} catch {
|
||||
alert('That file could not be read as a Pokédex backup.');
|
||||
@ -314,6 +318,7 @@ function exportData() {
|
||||
team: team.get(),
|
||||
formTracking: formTracking.get(),
|
||||
shinyHunts: shinyHunts.get(),
|
||||
playedGames: playedGames.get(),
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], {
|
||||
type: 'application/json',
|
||||
|
||||
@ -66,17 +66,14 @@ export async function TeamView() {
|
||||
'header',
|
||||
{ class: 'view__header' },
|
||||
el('h1', {}, 'Team'),
|
||||
el('p', {}, `Up to ${MAX_TEAM} Pokémon — check weaknesses, coverage and stats.`),
|
||||
el(
|
||||
'p',
|
||||
{},
|
||||
`Up to ${MAX_TEAM} Pokémon — check weaknesses, coverage and stats. `,
|
||||
el('a', { class: 'link', href: '#/natures' }, 'Natures'),
|
||||
' · ',
|
||||
el('a', { class: 'link', href: '#/types' }, 'Type chart'),
|
||||
' · ',
|
||||
el('a', { class: 'link', href: '#/compare' }, 'Compare'),
|
||||
' · ',
|
||||
el('a', { class: 'link', href: '#/breeding' }, 'Breeding'),
|
||||
'nav',
|
||||
{ class: 'toollinks' },
|
||||
el('a', { class: 'toollink', href: '#/natures' }, el('span', { class: 'toollink__ico', 'aria-hidden': 'true' }, '✦'), 'Natures'),
|
||||
el('a', { class: 'toollink', href: '#/types' }, el('span', { class: 'toollink__ico', 'aria-hidden': 'true' }, '▦'), 'Type chart'),
|
||||
el('a', { class: 'toollink', href: '#/compare' }, el('span', { class: 'toollink__ico', 'aria-hidden': 'true' }, '⇄'), 'Compare'),
|
||||
el('a', { class: 'toollink', href: '#/breeding' }, el('span', { class: 'toollink__ico', 'aria-hidden': 'true' }, '⬡'), 'Breeding'),
|
||||
),
|
||||
),
|
||||
seg,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user