Breeding helper (#/breeding)

Two modes, linked from the Team header:
- Egg groups — browse every species in each of the 14 breedable egg
  groups (offline, from the snapshot's eggGroups).
- Can breed with — pick a Pokémon and get its compatible partners
  (shared egg group; Ditto pairs with everything; genderless pairs only
  with Ditto; Undiscovered can't breed), plus its family's egg moves
  (walks evolves-from to the base stage — Pikachu shows Pichu's), each
  linking to the move page (which already lists who can learn it).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
chris 2026-09-09 19:01:24 -04:00
parent 53f4f4381d
commit 48cb471489
5 changed files with 296 additions and 0 deletions

View File

@ -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() },
];

View File

@ -23,6 +23,9 @@ export const ui = createStore('pdx.ui', {
compareIds: [],
toolsOpen: false,
teamMode: 'coverage',
breedMode: 'groups',
breedGroup: '',
breedTarget: 0,
mvType: '',
mvClass: '',
mvSort: 'name',

View File

@ -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;
}

226
src/views/BreedingView.js Normal file
View File

@ -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 cant 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;
}

View File

@ -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,