Forms & variants
- Snapshot: each species gains a forms[] list (slug, form id, label, category, types, stats, BST, height, weight) built from its non-default varieties — 264 forms across 203 species (Megas, G-max, regional, alt formes; cosmetic-only forms filtered out). Snapshot ~914 KB. - Detail page: a form switcher (Base + each form). Selecting a form re-fetches its /pokemon and rebuilds the type-dependent parts — hero name/types/artwork, --type theming, stat bars + total, defensive matchups, STAB coverage, abilities, EV yield, held items, and learnset. Species-level data (flavour, breeding, evolution) stays put. - Grid cards show a '+N forms' badge. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
415cf3edec
commit
40667cbdb6
@ -71,6 +71,10 @@ Working app with a type-themed UI:
|
||||
|
||||
- **Dex grid** — type-tinted cards (official artwork, spotlight, ghost number),
|
||||
per-dex progress, name/number filter, caught/favorite filters.
|
||||
- **Forms** — Megas, Gigantamax, regional forms and alternate formes (264
|
||||
across 203 species) in the snapshot; a form switcher on the detail page
|
||||
rebuilds types / stats / matchups / abilities / learnset / artwork for
|
||||
the chosen form. Cards show a "+N forms" badge.
|
||||
- **Detail** — type-gradient hero + tabbed sheet (About / Stats / Evolution /
|
||||
Moves / Locations); coloured animated stat bars; defensive type matchups;
|
||||
evolution chain re-parented to the selected game's generation; learnset with
|
||||
|
||||
@ -30,6 +30,34 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
|
||||
const pretty = (k) => k.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
const FORM_LABELS = {
|
||||
mega: 'Mega',
|
||||
'mega-x': 'Mega X',
|
||||
'mega-y': 'Mega Y',
|
||||
gmax: 'Gigantamax',
|
||||
alola: 'Alolan',
|
||||
galar: 'Galarian',
|
||||
hisui: 'Hisuian',
|
||||
paldea: 'Paldean',
|
||||
primal: 'Primal',
|
||||
origin: 'Origin',
|
||||
altered: 'Altered',
|
||||
incarnate: 'Incarnate',
|
||||
therian: 'Therian',
|
||||
};
|
||||
function formLabel(slug, speciesName) {
|
||||
const rest = slug.startsWith(`${speciesName}-`) ? slug.slice(speciesName.length + 1) : slug;
|
||||
return FORM_LABELS[rest] || pretty(rest);
|
||||
}
|
||||
function formCategory(slug, speciesName) {
|
||||
const rest = slug.startsWith(`${speciesName}-`) ? slug.slice(speciesName.length + 1) : slug;
|
||||
if (rest.startsWith('mega')) return 'mega';
|
||||
if (rest === 'gmax') return 'gmax';
|
||||
if (['alola', 'galar', 'hisui', 'paldea'].includes(rest)) return 'regional';
|
||||
if (rest === 'primal') return 'primal';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
const _cache = new Map();
|
||||
async function api(path) {
|
||||
if (_cache.has(path)) return _cache.get(path);
|
||||
@ -152,14 +180,9 @@ async function main() {
|
||||
.map((r) => idFromUrl(r.url))
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
console.log(`Fetching ${ids.length} species (stats, typings, flags) …`);
|
||||
const species = await mapLimit(ids, CONCURRENCY, async (id) => {
|
||||
const [pk, sp] = await Promise.all([
|
||||
api(`pokemon/${id}`),
|
||||
api(`pokemon-species/${id}`),
|
||||
]);
|
||||
const statMap = (pk) => {
|
||||
const s = Object.fromEntries(pk.stats.map((x) => [x.stat.name, x.base_stat]));
|
||||
const stats = {
|
||||
return {
|
||||
hp: s.hp ?? 0,
|
||||
atk: s.attack ?? 0,
|
||||
def: s.defense ?? 0,
|
||||
@ -167,7 +190,56 @@ async function main() {
|
||||
spd: s['special-defense'] ?? 0,
|
||||
spe: s.speed ?? 0,
|
||||
};
|
||||
};
|
||||
const bstOf = (st) => st.hp + st.atk + st.def + st.spa + st.spd + st.spe;
|
||||
const sameShape = (a, b) =>
|
||||
a.types.join() === b.types.join() && a.bst === b.bst &&
|
||||
a.stats.hp === b.stats.hp && a.stats.spe === b.stats.spe && a.stats.atk === b.stats.atk;
|
||||
|
||||
console.log(`Fetching ${ids.length} species (stats, typings, flags, forms) …`);
|
||||
const species = await mapLimit(ids, CONCURRENCY, async (id) => {
|
||||
const [pk, sp] = await Promise.all([
|
||||
api(`pokemon/${id}`),
|
||||
api(`pokemon-species/${id}`),
|
||||
]);
|
||||
const stats = statMap(pk);
|
||||
const baseTypes = pk.types.slice().sort((a, b) => a.slot - b.slot).map((t) => t.type.name);
|
||||
|
||||
// Non-default varieties = forms (megas, G-max, regional, alt formes).
|
||||
const variantSlugs = (sp.varieties || [])
|
||||
.filter((v) => !v.is_default)
|
||||
.map((v) => v.pokemon.name);
|
||||
const forms = [];
|
||||
for (const slug of variantSlugs) {
|
||||
let fp;
|
||||
try {
|
||||
fp = await api(`pokemon/${slug}`);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const fstats = statMap(fp);
|
||||
const ftypes = fp.types.slice().sort((a, b) => a.slot - b.slot).map((t) => t.type.name);
|
||||
const cat = formCategory(slug, sp.name);
|
||||
const entry = {
|
||||
slug,
|
||||
id: fp.id,
|
||||
name: formLabel(slug, sp.name),
|
||||
category: cat,
|
||||
types: ftypes,
|
||||
stats: fstats,
|
||||
bst: bstOf(fstats),
|
||||
height: fp.height,
|
||||
weight: fp.weight,
|
||||
};
|
||||
// Drop purely-cosmetic forms (identical stats & typing, no named category).
|
||||
if (cat === 'other' && sameShape(entry, { types: baseTypes, bst: bstOf(stats), stats })) {
|
||||
continue;
|
||||
}
|
||||
forms.push(entry);
|
||||
}
|
||||
|
||||
return {
|
||||
forms,
|
||||
id,
|
||||
name: sp.name,
|
||||
generation: idFromUrl(sp.generation.url),
|
||||
|
||||
@ -61,6 +61,9 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup,
|
||||
el('span', { class: 'card__num' }, `#${num}`),
|
||||
el('span', { class: 'card__name' }, species.name.replace(/-/g, ' ')),
|
||||
el('span', { class: 'card__types' }, ...types.map(TypeChip)),
|
||||
species.forms?.length
|
||||
? el('span', { class: 'card__forms' }, `+${species.forms.length} form${species.forms.length > 1 ? 's' : ''}`)
|
||||
: null,
|
||||
metric ? el('span', { class: 'card__metric' }, metric) : null,
|
||||
),
|
||||
);
|
||||
|
||||
@ -2390,3 +2390,43 @@
|
||||
color: var(--text-dim);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ---- Forms ------------------------------------------------- */
|
||||
.phero__form {
|
||||
font-weight: 500;
|
||||
font-size: 0.62em;
|
||||
opacity: 0.85;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.phero__form:empty {
|
||||
display: none;
|
||||
}
|
||||
.formbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.formbar__pill {
|
||||
appearance: none;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.35);
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.formbar__pill.is-active {
|
||||
background: #fff;
|
||||
border-color: #fff;
|
||||
color: var(--type-main);
|
||||
}
|
||||
.card__forms {
|
||||
margin-top: 4px;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
color: color-mix(in srgb, var(--type-main) 55%, var(--text-dim));
|
||||
}
|
||||
|
||||
@ -125,11 +125,21 @@ export async function PokemonDetail(nationalId) {
|
||||
recent: [nationalId, ...(s.recent || []).filter((x) => x !== nationalId)].slice(0, 12),
|
||||
}));
|
||||
|
||||
const types = typesForGeneration(pokemon, vgGen);
|
||||
const mainType = types[0];
|
||||
view.style.setProperty('--type-main', typeHex(mainType));
|
||||
view.style.setProperty('--type-2', typeHex(types[1] || mainType));
|
||||
view.dataset.type = mainType;
|
||||
// Form state: null = the base Pokémon; otherwise a snapshot form entry
|
||||
// whose full /pokemon data lives in `pk`.
|
||||
const snapSpecies = snap.speciesById.get(nationalId);
|
||||
const forms = (snapSpecies && snapSpecies.forms) || [];
|
||||
let activeForm = null;
|
||||
let pk = pokemon;
|
||||
|
||||
let types = typesForGeneration(pk, vgGen);
|
||||
let mainType = types[0];
|
||||
function applyThemeVars() {
|
||||
view.style.setProperty('--type-main', typeHex(mainType));
|
||||
view.style.setProperty('--type-2', typeHex(types[1] || mainType));
|
||||
view.dataset.type = mainType;
|
||||
}
|
||||
applyThemeVars();
|
||||
|
||||
// ---- Artwork + shiny --------------------------------------------
|
||||
// Shiny Pokémon were introduced in Gen 2 — no toggle for Gen 1 games.
|
||||
@ -172,7 +182,7 @@ export async function PokemonDetail(nationalId) {
|
||||
: null;
|
||||
function paintArt() {
|
||||
artHolder.replaceChildren(
|
||||
Sprite(nationalId, {
|
||||
Sprite(activeForm ? activeForm.id : nationalId, {
|
||||
style: artStyle,
|
||||
shiny,
|
||||
versionGroup: st.versionGroup,
|
||||
@ -241,65 +251,108 @@ export async function PokemonDetail(nationalId) {
|
||||
}
|
||||
syncTrack();
|
||||
|
||||
// ---- About panel -----------------------------------------------
|
||||
const abilityList = pokemon.abilities.filter((a) => !a.is_hidden || vgGen >= 5);
|
||||
const abilitiesNode = el(
|
||||
'span',
|
||||
{},
|
||||
...abilityList.flatMap((a, i) => {
|
||||
const link = el(
|
||||
'a',
|
||||
{ class: 'link', href: `#/ability/${idFromUrl(a.ability.url)}` },
|
||||
prettify(a.ability.name) + (a.is_hidden ? ' (hidden)' : ''),
|
||||
);
|
||||
return i === 0 ? [link] : [', ', link];
|
||||
}),
|
||||
);
|
||||
const allGames = st.versionGroup === 'all';
|
||||
let statBars = [];
|
||||
|
||||
const evYield =
|
||||
pokemon.stats
|
||||
.filter((s) => s.effort > 0)
|
||||
.map((s) => `${s.effort} ${STAT_LABEL[s.stat.name] || s.stat.name}`)
|
||||
.join(', ') || '—';
|
||||
const heldItems =
|
||||
pokemon.held_items?.map((h) => prettify(h.item.name)).join(', ') || '—';
|
||||
// ---- Form-dependent content (rebuilt when a form is selected) -----
|
||||
function formFactsNode(p) {
|
||||
const abilityList = p.abilities.filter((a) => !a.is_hidden || vgGen >= 5);
|
||||
const abilitiesNode = el(
|
||||
'span',
|
||||
{},
|
||||
...abilityList.flatMap((a, i) => {
|
||||
const link = el(
|
||||
'a',
|
||||
{ class: 'link', href: `#/ability/${idFromUrl(a.ability.url)}` },
|
||||
prettify(a.ability.name) + (a.is_hidden ? ' (hidden)' : ''),
|
||||
);
|
||||
return i === 0 ? [link] : [', ', link];
|
||||
}),
|
||||
);
|
||||
const evYield =
|
||||
p.stats.filter((s) => s.effort > 0).map((s) => `${s.effort} ${STAT_LABEL[s.stat.name] || s.stat.name}`).join(', ') ||
|
||||
'—';
|
||||
const heldItems = p.held_items?.map((h) => prettify(h.item.name)).join(', ') || '—';
|
||||
return el(
|
||||
'div',
|
||||
{},
|
||||
el(
|
||||
'dl',
|
||||
{ class: 'pfacts' },
|
||||
fact('Height', `${(p.height / 10).toFixed(1)} m`),
|
||||
fact('Weight', `${(p.weight / 10).toFixed(1)} kg`),
|
||||
abilityList.length && vgGen >= 3 ? fact('Abilities', abilitiesNode) : null,
|
||||
fact('Introduced', prettify(species.generation.name)),
|
||||
species.genera?.length
|
||||
? fact('Category', (species.genera.find((g) => g.language.name === 'en') || {}).genus || '—')
|
||||
: null,
|
||||
),
|
||||
el('h3', { class: 'ppanel__sub' }, 'Training'),
|
||||
el(
|
||||
'dl',
|
||||
{ class: 'pfacts' },
|
||||
fact('EV yield', evYield),
|
||||
fact('Base EXP', p.base_experience ?? '—'),
|
||||
fact(
|
||||
'Catch rate',
|
||||
species.capture_rate != null
|
||||
? `${species.capture_rate} / 255 (~${Math.round((species.capture_rate / 255) * 100)}% max)`
|
||||
: '—',
|
||||
),
|
||||
fact('Base friendship', species.base_happiness ?? '—'),
|
||||
fact(
|
||||
'Growth rate',
|
||||
species.growth_rate?.name
|
||||
? `${prettify(species.growth_rate.name)}${
|
||||
GROWTH_EXP[species.growth_rate.name]
|
||||
? ` · ${GROWTH_EXP[species.growth_rate.name].toLocaleString()} EXP`
|
||||
: ''
|
||||
}`
|
||||
: '—',
|
||||
),
|
||||
fact('Held items', heldItems),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function statsContent(p) {
|
||||
const stat = (name) => p.stats.find((s) => s.stat.name === name)?.base_stat ?? 0;
|
||||
const total = STAT_NAMES.reduce((sum, n) => sum + stat(n), 0);
|
||||
statBars = STAT_NAMES.map((n) => StatBar(n, stat(n)));
|
||||
return [
|
||||
el('div', { class: 'pstats' }, ...statBars),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'statbar statbar--total' },
|
||||
el('span', { class: 'statbar__label' }, 'Total'),
|
||||
el('span', { class: 'statbar__value' }, String(total)),
|
||||
el('div', { class: 'statbar__track' }),
|
||||
),
|
||||
el('h3', { class: 'ppanel__sub' }, 'Type matchups (defending)'),
|
||||
TypeMatchups(types, vgGen),
|
||||
offensiveBlock(types, vgGen),
|
||||
];
|
||||
}
|
||||
|
||||
function movesContent(p) {
|
||||
return allGames
|
||||
? el('p', { class: 'detail__muted' }, 'Pick a game to see its learnset.')
|
||||
: MovesList(p.moves, {
|
||||
versionGroupKey: st.versionGroup,
|
||||
gen: vgGen,
|
||||
genOfVg: (name) => snap.versionGroupByKey.get(name)?.generation ?? 9,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- About panel (FlavorText + form facts + species breeding) ----
|
||||
const eggGroups = species.egg_groups.map((g) => prettify(g.name));
|
||||
const hatch = species.hatch_counter;
|
||||
const growth = species.growth_rate?.name;
|
||||
const rate = species.capture_rate;
|
||||
|
||||
const factsHolder = el('div', {}, formFactsNode(pk));
|
||||
const aboutPanel = el(
|
||||
'div',
|
||||
{ class: 'ppanel' },
|
||||
FlavorText(species, vg),
|
||||
el(
|
||||
'dl',
|
||||
{ class: 'pfacts' },
|
||||
fact('Height', `${(pokemon.height / 10).toFixed(1)} m`),
|
||||
fact('Weight', `${(pokemon.weight / 10).toFixed(1)} kg`),
|
||||
abilityList.length && vgGen >= 3 ? fact('Abilities', abilitiesNode) : null,
|
||||
fact('Introduced', prettify(species.generation.name)),
|
||||
species.genera?.length
|
||||
? fact(
|
||||
'Category',
|
||||
(species.genera.find((g) => g.language.name === 'en') || {}).genus || '—',
|
||||
)
|
||||
: null,
|
||||
),
|
||||
el('h3', { class: 'ppanel__sub' }, 'Training'),
|
||||
el(
|
||||
'dl',
|
||||
{ class: 'pfacts' },
|
||||
fact('EV yield', evYield),
|
||||
fact('Base EXP', pokemon.base_experience ?? '—'),
|
||||
fact('Catch rate', rate != null ? `${rate} / 255 (~${Math.round((rate / 255) * 100)}% max)` : '—'),
|
||||
fact('Base friendship', species.base_happiness ?? '—'),
|
||||
fact(
|
||||
'Growth rate',
|
||||
growth ? `${prettify(growth)}${GROWTH_EXP[growth] ? ` · ${GROWTH_EXP[growth].toLocaleString()} EXP` : ''}` : '—',
|
||||
),
|
||||
fact('Held items', heldItems),
|
||||
),
|
||||
factsHolder,
|
||||
el('h3', { class: 'ppanel__sub' }, 'Breeding'),
|
||||
el(
|
||||
'dl',
|
||||
@ -316,45 +369,14 @@ export async function PokemonDetail(nationalId) {
|
||||
),
|
||||
);
|
||||
|
||||
// ---- Stats panel ---------------------------------------------
|
||||
const stat = (name) =>
|
||||
pokemon.stats.find((s) => s.stat.name === name)?.base_stat ?? 0;
|
||||
const statTotal = STAT_NAMES.reduce((sum, n) => sum + stat(n), 0);
|
||||
const statBars = STAT_NAMES.map((n) => StatBar(n, stat(n)));
|
||||
const statsPanel = el(
|
||||
'div',
|
||||
{ class: 'ppanel' },
|
||||
el('div', { class: 'pstats' }, ...statBars),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'statbar statbar--total' },
|
||||
el('span', { class: 'statbar__label' }, 'Total'),
|
||||
el('span', { class: 'statbar__value' }, String(statTotal)),
|
||||
el('div', { class: 'statbar__track' }),
|
||||
),
|
||||
el('h3', { class: 'ppanel__sub' }, 'Type matchups (defending)'),
|
||||
TypeMatchups(types, vgGen),
|
||||
offensiveBlock(types, vgGen),
|
||||
);
|
||||
const statsPanel = el('div', { class: 'ppanel' }, ...statsContent(pk));
|
||||
|
||||
// ---- Evolution panel --------------------------------------
|
||||
const evoPanel = el('div', { class: 'ppanel' }, el('p', { class: 'detail__muted' }, 'Loading…'));
|
||||
loadEvolution(species, st, snap, vgGen).then((node) => evoPanel.replaceChildren(node));
|
||||
|
||||
const allGames = st.versionGroup === 'all';
|
||||
|
||||
// ---- Moves panel ----------------------------------------
|
||||
const movesPanel = el(
|
||||
'div',
|
||||
{ class: 'ppanel' },
|
||||
allGames
|
||||
? el('p', { class: 'detail__muted' }, 'Pick a game to see its learnset.')
|
||||
: MovesList(pokemon.moves, {
|
||||
versionGroupKey: st.versionGroup,
|
||||
gen: vgGen,
|
||||
genOfVg: (name) => snap.versionGroupByKey.get(name)?.generation ?? 9,
|
||||
}),
|
||||
);
|
||||
const movesPanel = el('div', { class: 'ppanel' }, movesContent(pk));
|
||||
|
||||
// ---- Locations panel ----------------------------------
|
||||
const locPanel = el('div', { class: 'ppanel' }, el('p', { class: 'detail__muted' }, 'Loading…'));
|
||||
@ -427,6 +449,55 @@ export async function PokemonDetail(nationalId) {
|
||||
? `#${String(regionalNumber).padStart(3, '0')} · ${prettify(dex.name || dex.key)}`
|
||||
: `#${String(nationalId).padStart(4, '0')} · National`;
|
||||
|
||||
const heroForm = el('span', { class: 'phero__form' });
|
||||
const heroName = el(
|
||||
'h1',
|
||||
{ class: 'phero__name' },
|
||||
el('span', {}, species.name.replace(/-/g, ' ')),
|
||||
heroForm,
|
||||
);
|
||||
const heroTypes = el('div', { class: 'phero__types' }, ...types.map(TypeChip));
|
||||
|
||||
// Form switcher: Base + each snapshot form. Selecting one re-fetches that
|
||||
// form's /pokemon and rebuilds the type-dependent parts of the page.
|
||||
let formBar = null;
|
||||
if (forms.length) {
|
||||
const opts = [{ slug: null, name: 'Base' }, ...forms];
|
||||
const pills = opts.map((o) =>
|
||||
el(
|
||||
'button',
|
||||
{
|
||||
class: 'formbar__pill',
|
||||
type: 'button',
|
||||
onclick: () => switchForm(o.slug ? o : null, pills, opts),
|
||||
},
|
||||
o.name,
|
||||
),
|
||||
);
|
||||
pills[0].classList.add('is-active');
|
||||
formBar = el('div', { class: 'formbar' }, ...pills);
|
||||
}
|
||||
|
||||
async function switchForm(form, pills, opts) {
|
||||
activeForm = form;
|
||||
pills.forEach((b, i) => b.classList.toggle('is-active', opts[i].slug === (form ? form.slug : null)));
|
||||
heroForm.textContent = form ? form.name : '';
|
||||
try {
|
||||
pk = form ? await getPokemon(form.slug) : pokemon;
|
||||
} catch {
|
||||
pk = pokemon;
|
||||
}
|
||||
types = typesForGeneration(pk, vgGen);
|
||||
mainType = types[0];
|
||||
applyThemeVars();
|
||||
paintArt();
|
||||
heroTypes.replaceChildren(...types.map(TypeChip));
|
||||
factsHolder.replaceChildren(formFactsNode(pk));
|
||||
statsPanel.replaceChildren(...statsContent(pk));
|
||||
movesPanel.replaceChildren(movesContent(pk));
|
||||
if (ui.get().detailTab === 'stats') animateStats();
|
||||
}
|
||||
|
||||
clear(view).append(
|
||||
el(
|
||||
'div',
|
||||
@ -444,9 +515,10 @@ export async function PokemonDetail(nationalId) {
|
||||
el(
|
||||
'div',
|
||||
{ class: 'phero__head' },
|
||||
el('h1', { class: 'phero__name' }, species.name.replace(/-/g, ' ')),
|
||||
el('div', { class: 'phero__types' }, ...types.map(TypeChip)),
|
||||
heroName,
|
||||
heroTypes,
|
||||
),
|
||||
formBar,
|
||||
el(
|
||||
'div',
|
||||
{ class: 'phero__stage' },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user