diff --git a/src/router.js b/src/router.js index 1d77ff7..8758a6b 100644 --- a/src/router.js +++ b/src/router.js @@ -11,6 +11,7 @@ import { NaturesView } from './views/NaturesView.js'; import { TypeChartView } from './views/TypeChartView.js'; import { CompareView } from './views/CompareView.js'; import { ProgressView } from './views/ProgressView.js'; +import { BreedingView } from './views/BreedingView.js'; import { detailSkeleton, lookupSkeleton } from './components/skeletons.js'; import { prefersReducedMotion } from './store/settings.js'; @@ -25,6 +26,7 @@ const routes = [ { pattern: /^#\/types$/, view: () => TypeChartView() }, { pattern: /^#\/compare$/, view: () => CompareView() }, { pattern: /^#\/progress$/, view: () => ProgressView() }, + { pattern: /^#\/breeding$/, view: () => BreedingView() }, { pattern: /^#\/search$/, view: () => SearchView() }, { pattern: /^#\/settings$/, view: () => SettingsView() }, ]; diff --git a/src/store/ui.js b/src/store/ui.js index c282577..01c1513 100644 --- a/src/store/ui.js +++ b/src/store/ui.js @@ -23,6 +23,9 @@ export const ui = createStore('pdx.ui', { compareIds: [], toolsOpen: false, teamMode: 'coverage', + breedMode: 'groups', + breedGroup: '', + breedTarget: 0, mvType: '', mvClass: '', mvSort: 'name', diff --git a/src/styles/layout.css b/src/styles/layout.css index 373dd19..921071b 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -3099,3 +3099,66 @@ } .prog__key--caught { background: var(--good); } .prog__key--seen { background: color-mix(in srgb, var(--accent) 35%, transparent); } + +/* ---- Breeding helper --------------------------------------- */ +.breedview__body { + margin-top: 14px; +} +.breed__chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 12px; +} +.breed__chip { + border: 1.5px solid var(--border); + background: var(--surface); + color: var(--text); + font: inherit; + font-size: 0.78rem; + font-weight: 600; + padding: 5px 12px; + border-radius: 999px; + cursor: pointer; +} +.breed__chip.is-on { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.breed__pick { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 12px; + border: 1.5px solid var(--border); + border-radius: 14px; + background: var(--surface-2); + color: inherit; + font: inherit; + font-weight: 700; + font-size: 0.9rem; + cursor: pointer; + margin-bottom: 12px; +} +.breed__pick span { + display: flex; + align-items: center; + gap: 8px; +} +.breed__note { + font-size: 0.82rem; + color: var(--text-dim); + margin-bottom: 10px; +} +.breed__eggmoves { + margin: 8px 0 16px; +} +.breed__eggmove-list { + display: flex; + flex-wrap: wrap; + gap: 6px 14px; + margin-top: 6px; + text-transform: capitalize; +} diff --git a/src/views/BreedingView.js b/src/views/BreedingView.js new file mode 100644 index 0000000..6f66b3d --- /dev/null +++ b/src/views/BreedingView.js @@ -0,0 +1,226 @@ +import { el, clear, onTeardown } from '../lib/dom.js'; +import { loadSnapshot } from '../data/snapshot.js'; +import { settings } from '../store/settings.js'; +import { ui } from '../store/ui.js'; +import { getPokemon } from '../data/api.js'; +import { prettify } from '../data/pokedex-resolver.js'; +import { Sprite } from '../components/Sprite.js'; +import { openPokemonPicker } from '../components/PokemonPicker.js'; + +const EGG_LABEL = { + water1: 'Water 1', water2: 'Water 2', water3: 'Water 3', + ground: 'Field', humanshape: 'Human-Like', indeterminate: 'Amorphous', + plant: 'Grass', 'no-eggs': 'Undiscovered', ditto: 'Ditto', +}; +const eggLabel = (g) => EGG_LABEL[g] || prettify(g); +const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop()); + +export async function BreedingView() { + const view = el('section', { class: 'view lookup breedview' }); + const snap = await loadSnapshot(); + const style = settings.get().spriteStyle; + const vg = settings.get().versionGroup; + + const groups = [...new Set(snap.species.flatMap((s) => s.eggGroups || []))] + .filter((g) => g !== 'no-eggs') + .sort((a, b) => eggLabel(a).localeCompare(eggLabel(b))); + + let mode = ui.get().breedMode === 'compat' ? 'compat' : 'groups'; + let group = groups.includes(ui.get().breedGroup) ? ui.get().breedGroup : groups[0]; + let target = snap.speciesById.get(ui.get().breedTarget) || null; + + const seg = el( + 'div', + { class: 'seg' }, + ...[ + ['groups', 'Egg groups'], + ['compat', 'Can breed with'], + ].map(([id, label]) => + el( + 'button', + { + type: 'button', + class: `seg__btn${id === mode ? ' is-active' : ''}`, + onclick: () => { + mode = id; + ui.set({ breedMode: id }); + [...seg.children].forEach((b, i) => b.classList.toggle('is-active', ['groups', 'compat'][i] === id)); + render(); + }, + }, + label, + ), + ), + ); + + const body = el('div', { class: 'breedview__body' }); + + function monGrid(list) { + return list.length + ? el( + 'div', + { class: 'ab-mons' }, + ...list.map((sp) => + el( + 'a', + { class: 'ab-mon', href: `#/pokemon/${sp.id}` }, + Sprite(sp.id, { style, alt: sp.name, size: 48 }), + el('span', {}, sp.name.replace(/-/g, ' ')), + ), + ), + ) + : el('p', { class: 'detail__muted' }, 'None.'); + } + + function renderGroups() { + const chips = el( + 'div', + { class: 'breed__chips' }, + ...groups.map((g) => + el( + 'button', + { + type: 'button', + class: `breed__chip${g === group ? ' is-on' : ''}`, + onclick: () => { + group = g; + ui.set({ breedGroup: g }); + render(); + }, + }, + eggLabel(g), + ), + ), + ); + const list = snap.species + .filter((s) => (s.eggGroups || []).includes(group)) + .sort((a, b) => a.id - b.id); + body.append( + chips, + el('h2', { class: 'ppanel__sub' }, `${eggLabel(group)} (${list.length})`), + monGrid(list), + ); + } + + function renderCompat() { + const slot = el( + 'button', + { + type: 'button', + class: 'breed__pick', + onclick: () => + openPokemonPicker((id) => { + target = snap.speciesById.get(id); + ui.set({ breedTarget: id }); + render(); + }), + }, + target + ? el('span', {}, Sprite(target.id, { style, size: 40, alt: target.name }), ` ${prettify(target.name)}`) + : 'Choose a Pokémon…', + ); + body.append(slot); + if (!target) return; + + const tGroups = new Set(target.eggGroups || []); + if (tGroups.has('no-eggs')) { + body.append(el('p', { class: 'detail__muted' }, `${prettify(target.name)} is in the Undiscovered egg group — it can’t be bred.`)); + return; + } + const isDitto = tGroups.has('ditto'); + const genderless = target.genderRate === -1; + + let list; + if (isDitto) { + list = snap.species.filter((s) => !s.eggGroups.includes('no-eggs') && !s.eggGroups.includes('ditto')); + } else if (genderless) { + list = snap.species.filter((s) => (s.eggGroups || []).includes('ditto')); + } else { + list = snap.species.filter((s) => { + if (s.id === target.id) return false; + if ((s.eggGroups || []).includes('ditto')) return true; + if ((s.eggGroups || []).includes('no-eggs')) return false; + return (s.eggGroups || []).some((g) => tGroups.has(g) && g !== 'ditto'); + }); + } + list.sort((a, b) => a.id - b.id); + + const note = genderless + ? 'Genderless — can only breed with Ditto.' + : isDitto + ? 'Ditto breeds with any Pokémon that can breed.' + : `Egg groups: ${[...tGroups].map(eggLabel).join(', ')}. Needs an opposite-gender partner (or Ditto).`; + + // Egg moves belong to the family's lowest breeding stage (Pichu, not + // Pikachu) — walk down the evolves-from chain. + let base = target; + while (base.evolvesFromId) { + const prev = snap.speciesById.get(base.evolvesFromId); + if (!prev) break; + base = prev; + } + const eggMovesBox = el('div', { class: 'breed__eggmoves' }, el('p', { class: 'detail__muted' }, 'Loading egg moves…')); + getPokemon(base.id) + .then((pk) => { + const eggMoves = pk.moves.filter((m) => + m.version_group_details.some( + (d) => d.move_learn_method.name === 'egg' && (vg === 'all' || d.version_group.name === vg), + ), + ); + clear(eggMovesBox); + if (!eggMoves.length) { + eggMovesBox.append(el('p', { class: 'detail__muted' }, 'No egg moves for this game.')); + return; + } + eggMovesBox.append( + el( + 'h3', + { class: 'ppanel__sub' }, + `Egg moves (${eggMoves.length})${base.id !== target.id ? ` — hatches as ${prettify(base.name)}` : ''}`, + ), + el( + 'p', + { class: 'detail__muted' }, + 'A parent that knows one of these passes it down. Open a move to see which Pokémon can learn it.', + ), + el( + 'div', + { class: 'breed__eggmove-list' }, + ...eggMoves.map((m) => + el('a', { class: 'link', href: `#/move/${idFromUrl(m.move.url)}` }, m.move.name.replace(/-/g, ' ')), + ), + ), + ); + }) + .catch(() => clear(eggMovesBox).append(el('p', { class: 'detail__muted' }, 'Egg moves unavailable offline.'))); + + body.append( + el('p', { class: 'breed__note' }, note), + eggMovesBox, + el('h2', { class: 'ppanel__sub' }, `Compatible partners (${list.length})`), + monGrid(list), + ); + } + + function render() { + clear(body); + if (mode === 'groups') renderGroups(); + else renderCompat(); + } + + clear(view).append( + el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/team' }, '‹ Team')), + el( + 'header', + { class: 'view__header' }, + el('h1', {}, 'Breeding'), + el('p', {}, 'Egg-group members and who a Pokémon can produce eggs with.'), + ), + seg, + body, + ); + render(); + + onTeardown(view, () => {}); + return view; +} diff --git a/src/views/TeamView.js b/src/views/TeamView.js index 1ba1e3d..0a9a63f 100644 --- a/src/views/TeamView.js +++ b/src/views/TeamView.js @@ -70,6 +70,8 @@ export async function TeamView() { el('a', { class: 'link', href: '#/types' }, 'Type chart'), ' · ', el('a', { class: 'link', href: '#/compare' }, 'Compare'), + ' · ', + el('a', { class: 'link', href: '#/breeding' }, 'Breeding'), ), ), seg,