diff --git a/README.md b/README.md index 200dbe1..2bdabb4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/scripts/build-snapshot.mjs b/scripts/build-snapshot.mjs index 7bee999..743dec2 100644 --- a/scripts/build-snapshot.mjs +++ b/scripts/build-snapshot.mjs @@ -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), diff --git a/src/components/Card.js b/src/components/Card.js index 8c9d3ea..ed83a2b 100644 --- a/src/components/Card.js +++ b/src/components/Card.js @@ -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, ), ); diff --git a/src/styles/layout.css b/src/styles/layout.css index 3212959..10a9518 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -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)); +} diff --git a/src/views/PokemonDetail.js b/src/views/PokemonDetail.js index 7a1587c..c1a375f 100644 --- a/src/views/PokemonDetail.js +++ b/src/views/PokemonDetail.js @@ -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' },