diff --git a/README.md b/README.md index 2bdabb4..b6976ce 100644 --- a/README.md +++ b/README.md @@ -71,10 +71,14 @@ 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. +- **Forms** — Megas, Gigantamax, regional forms and alternate formes in the + snapshot; a pill switcher on the detail page rebuilds types / stats / + matchups / abilities / learnset / artwork for the chosen form. Purely + cosmetic variants (Unown's 27 letters, Vivillon's 19 patterns, Furfrou + trims, seasonal Deerling/Sawsbuck, Flabébé line colours, Alcremie's 62 + decorations, Pikachu caps — 278 across 50 species) get a separate compact + "Appearance" dropdown that only swaps the sprite. 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 6645359..c2dff0a 100644 --- a/scripts/build-snapshot.mjs +++ b/scripts/build-snapshot.mjs @@ -211,6 +211,13 @@ async function main() { .filter((v) => !v.is_default) .map((v) => v.pokemon.name); const forms = []; + // Sprite-only variants (Pikachu caps, Alcremie decorations, Gastrodon + // east/west, Unown letters, Vivillon patterns, Furfrou trims, seasonal + // Deerling/Sawsbuck, Flabébé/Floette/Florges colours…). Share the base + // Pokémon's typing, stats, abilities and learnset — only the art differs, + // so they get a lightweight { slug, id, name, sprite } record and a + // separate, compact picker in the UI. + const cosmeticForms = []; for (const slug of variantSlugs) { let fp; try { @@ -232,19 +239,54 @@ async function main() { height: fp.height, weight: fp.weight, }; - // Drop purely-cosmetic forms: same typing, stats AND abilities as the - // base, and not a named category (mega/gmax/regional/primal). + // Purely-cosmetic forms: same typing, stats AND abilities as the base, + // and not a named category (mega/gmax/regional/primal). const cosmetic = cat === 'other' && ftypes.join() === baseTypes.join() && statKey(fstats) === baseStatKey && abilKey(fp) === baseAbil; - if (cosmetic) continue; + if (cosmetic) { + cosmeticForms.push({ + slug, + id: fp.id, + name: formLabel(slug, sp.name), + sprite: fp.sprites?.front_default || null, + }); + continue; + } forms.push(entry); } + // Cosmetic forms that live on /pokemon-form rather than as varieties + // (Unown, Vivillon, Furfrou, Deerling, Sawsbuck, Flabébé line…). + if ((pk.forms || []).length > 1) { + const known = new Set([...forms, ...cosmeticForms].map((f) => f.slug)); + for (const fref of pk.forms) { + if (fref.name === pk.name || known.has(fref.name)) continue; + let fm; + try { + fm = await api(fref.url); + } catch { + continue; + } + if (fm.is_default || fm.is_battle_only || fm.is_mega) continue; + const rest = fm.name.startsWith(`${sp.name}-`) + ? fm.name.slice(sp.name.length + 1) + : fm.form_name || fm.name; + cosmeticForms.push({ + slug: fm.name, + id: fm.id, + name: pretty(rest || fm.form_name || fm.name), + sprite: fm.sprites?.front_default || null, + }); + known.add(fm.name); + } + } + return { forms, + ...(cosmeticForms.length ? { cosmeticForms } : {}), id, name: sp.name, generation: idFromUrl(sp.generation.url), diff --git a/src/components/Card.js b/src/components/Card.js index ed83a2b..480c5d6 100644 --- a/src/components/Card.js +++ b/src/components/Card.js @@ -61,9 +61,10 @@ 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, + (() => { + const n = (species.forms?.length || 0) + (species.cosmeticForms?.length || 0); + return n ? el('span', { class: 'card__forms' }, `+${n} form${n > 1 ? 's' : ''}`) : null; + })(), metric ? el('span', { class: 'card__metric' }, metric) : null, ), ); diff --git a/src/styles/layout.css b/src/styles/layout.css index 10a9518..2c9b83f 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -829,6 +829,9 @@ height: 100%; object-fit: contain; } +.phero__pixelart { + image-rendering: pixelated; +} .phero__art--boxed { width: min(180px, 46vw); height: min(180px, 46vw); @@ -2424,6 +2427,35 @@ border-color: #fff; color: var(--type-main); } +.formbar--cosmetic { + align-items: center; + gap: 8px; +} +.formbar__caption { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + opacity: 0.8; +} +.formbar__select { + appearance: none; + border: 1.5px solid rgba(255, 255, 255, 0.35); + background: rgba(255, 255, 255, 0.14) + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%23fff' d='M1 1l5 5 5-5'/%3E%3C/svg%3E") + no-repeat right 12px center; + color: var(--ink); + font: inherit; + font-size: 0.78rem; + font-weight: 600; + padding: 6px 32px 6px 12px; + border-radius: 999px; + cursor: pointer; + max-width: 15rem; +} +.formbar__select option { + color: initial; +} .card__forms { margin-top: 4px; font-size: 0.62rem; diff --git a/src/views/PokemonDetail.js b/src/views/PokemonDetail.js index c1a375f..fe48958 100644 --- a/src/views/PokemonDetail.js +++ b/src/views/PokemonDetail.js @@ -7,7 +7,7 @@ import { settings } from '../store/settings.js'; import { ui } from '../store/ui.js'; import { entry, toggle } from '../store/selection.js'; import { team, addToTeam, removeFromTeam, inTeam, MAX_TEAM } from '../store/team.js'; -import { Sprite } from '../components/Sprite.js'; +import { Sprite, spriteUrl } from '../components/Sprite.js'; import { TypeChip } from '../components/TypeChip.js'; import { StatBar } from '../components/StatBar.js'; import { EvolutionChain } from '../components/EvolutionChain.js'; @@ -129,7 +129,11 @@ export async function PokemonDetail(nationalId) { // whose full /pokemon data lives in `pk`. const snapSpecies = snap.speciesById.get(nationalId); const forms = (snapSpecies && snapSpecies.forms) || []; + const cosmeticForms = (snapSpecies && snapSpecies.cosmeticForms) || []; let activeForm = null; + // Sprite-only variant chosen from the "Appearance" picker (Unown letter, + // Vivillon pattern, Furfrou trim…). Only swaps the artwork + label. + let activeCosmetic = null; let pk = pokemon; let types = typesForGeneration(pk, vgGen); @@ -181,15 +185,36 @@ export async function PokemonDetail(nationalId) { ) : null; function paintArt() { - artHolder.replaceChildren( - Sprite(activeForm ? activeForm.id : nationalId, { + let node; + if (activeCosmetic) { + // Cosmetic variants rarely have official artwork — use the pixel sprite + // PokéAPI ships for the exact form, degrading to the base sprite. + node = el('img', { + class: 'sprite phero__pixelart', + loading: 'lazy', + decoding: 'async', + width: 320, + height: 320, + alt: `${species.name} (${activeCosmetic.name})`, + src: activeCosmetic.sprite || spriteUrl(nationalId, 'default'), + }); + node.addEventListener( + 'error', + () => { + node.src = spriteUrl(nationalId, 'default'); + }, + { once: true }, + ); + } else { + node = Sprite(activeForm ? activeForm.id : nationalId, { style: artStyle, shiny, versionGroup: st.versionGroup, alt: species.name, size: 320, - }), - ); + }); + } + artHolder.replaceChildren(node); if (shinyBtn) { shinyBtn.textContent = shiny ? '✦ shiny' : '✧ shiny'; shinyBtn.classList.toggle('is-on', shiny); @@ -458,29 +483,39 @@ export async function PokemonDetail(nationalId) { ); 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. + // Form switcher: Base + each mechanical form (Mega / regional / alt forme). + // Selecting one re-fetches that form's /pokemon and rebuilds the + // type-dependent parts of the page. let formBar = null; + let formPills = []; + let formOpts = []; if (forms.length) { - const opts = [{ slug: null, name: 'Base' }, ...forms]; - const pills = opts.map((o) => + formOpts = [{ slug: null, name: 'Base' }, ...forms]; + formPills = formOpts.map((o) => el( 'button', { class: 'formbar__pill', type: 'button', - onclick: () => switchForm(o.slug ? o : null, pills, opts), + onclick: () => switchForm(o.slug ? o : null), }, o.name, ), ); - pills[0].classList.add('is-active'); - formBar = el('div', { class: 'formbar' }, ...pills); + formPills[0].classList.add('is-active'); + formBar = el('div', { class: 'formbar' }, ...formPills); } - async function switchForm(form, pills, opts) { + function syncFormPills() { + const slug = activeForm ? activeForm.slug : null; + formPills.forEach((b, i) => b.classList.toggle('is-active', formOpts[i].slug === slug)); + } + + async function switchForm(form) { activeForm = form; - pills.forEach((b, i) => b.classList.toggle('is-active', opts[i].slug === (form ? form.slug : null))); + activeCosmetic = null; + if (cosmeticSelect) cosmeticSelect.value = ''; + syncFormPills(); heroForm.textContent = form ? form.name : ''; try { pk = form ? await getPokemon(form.slug) : pokemon; @@ -498,6 +533,47 @@ export async function PokemonDetail(nationalId) { if (ui.get().detailTab === 'stats') animateStats(); } + // Appearance picker: purely-cosmetic variants (Unown letters, Vivillon + // patterns, Furfrou trims…). Can be 60+ per species, so one compact + // dropdown rather than a row of pills. Only swaps art + label. + let cosmeticBar = null; + let cosmeticSelect = null; + if (cosmeticForms.length) { + cosmeticSelect = el( + 'select', + { + class: 'formbar__select', + 'aria-label': 'Appearance', + onchange: (e) => { + const slug = e.target.value; + activeCosmetic = slug ? cosmeticForms.find((f) => f.slug === slug) : null; + if (activeCosmetic && activeForm) { + // A cosmetic look only applies to the base Pokémon; drop the + // mechanical form first, then re-assert the cosmetic choice. + const keep = activeCosmetic; + switchForm(null); + activeCosmetic = keep; + cosmeticSelect.value = slug; + } + heroForm.textContent = activeCosmetic + ? activeCosmetic.name + : activeForm + ? activeForm.name + : ''; + paintArt(); + }, + }, + el('option', { value: '' }, `Default (${cosmeticForms.length + 1})`), + ...cosmeticForms.map((f) => el('option', { value: f.slug }, f.name)), + ); + cosmeticBar = el( + 'label', + { class: 'formbar formbar--cosmetic' }, + el('span', { class: 'formbar__caption' }, 'Appearance'), + cosmeticSelect, + ); + } + clear(view).append( el( 'div', @@ -519,6 +595,7 @@ export async function PokemonDetail(nationalId) { heroTypes, ), formBar, + cosmeticBar, el( 'div', { class: 'phero__stage' },