diff --git a/src/components/PokemonPicker.js b/src/components/PokemonPicker.js index eb3ec23..ecd3a65 100644 --- a/src/components/PokemonPicker.js +++ b/src/components/PokemonPicker.js @@ -11,7 +11,7 @@ let openInstance = null; * `onPick(id)` fires per selection; the sheet stays open so you can add * several. Dismiss with the backdrop, ✕, or Escape. */ -export async function openPokemonPicker(onPick) { +export async function openPokemonPicker(onPick, { closeAfterPick = false } = {}) { if (openInstance) return; const snap = await loadSnapshot(); @@ -69,7 +69,10 @@ export async function openPokemonPicker(onPick) { { class: 'picker__row', type: 'button', - onclick: () => onPick(s.id), + onclick: () => { + onPick(s.id); + if (closeAfterPick) close(); + }, }, Sprite(s.id, { style, alt: s.name, size: 44 }), el('span', { class: 'search__num' }, `#${String(s.id).padStart(4, '0')}`), diff --git a/src/router.js b/src/router.js index 8758a6b..18e4b7b 100644 --- a/src/router.js +++ b/src/router.js @@ -12,6 +12,7 @@ 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 { ShinyView } from './views/ShinyView.js'; import { detailSkeleton, lookupSkeleton } from './components/skeletons.js'; import { prefersReducedMotion } from './store/settings.js'; @@ -27,6 +28,7 @@ const routes = [ { pattern: /^#\/compare$/, view: () => CompareView() }, { pattern: /^#\/progress$/, view: () => ProgressView() }, { pattern: /^#\/breeding$/, view: () => BreedingView() }, + { pattern: /^#\/shiny$/, view: () => ShinyView() }, { pattern: /^#\/search$/, view: () => SearchView() }, { pattern: /^#\/settings$/, view: () => SettingsView() }, ]; diff --git a/src/store/shinyHunts.js b/src/store/shinyHunts.js new file mode 100644 index 0000000..6d6c6f1 --- /dev/null +++ b/src/store/shinyHunts.js @@ -0,0 +1,39 @@ +import { createStore } from './createStore.js'; + +/** + * Shiny hunts: one entry per Pokémon you're actively hunting, with an + * encounter counter and the method's odds. Lives in localStorage next to + * the other tracking data; rides along in the JSON export. + */ +export const shinyHunts = createStore('pdx.shinyHunts', { hunts: [] }); + +let seq = Date.now(); +const uid = () => `h${(seq++).toString(36)}`; + +export function addHunt({ speciesId, method, charm }) { + shinyHunts.set((s) => ({ + ...s, + hunts: [ + { id: uid(), speciesId, method, charm: !!charm, count: 0, notes: '', startedAt: new Date().toISOString() }, + ...s.hunts, + ], + })); +} + +export function updateHunt(id, patch) { + shinyHunts.set((s) => ({ + ...s, + hunts: s.hunts.map((h) => (h.id === id ? { ...h, ...patch } : h)), + })); +} + +export function bumpHunt(id, delta) { + shinyHunts.set((s) => ({ + ...s, + hunts: s.hunts.map((h) => (h.id === id ? { ...h, count: Math.max(0, h.count + delta) } : h)), + })); +} + +export function removeHunt(id) { + shinyHunts.set((s) => ({ ...s, hunts: s.hunts.filter((h) => h.id !== id) })); +} diff --git a/src/styles/layout.css b/src/styles/layout.css index 921071b..c6ac1c3 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -3162,3 +3162,106 @@ margin-top: 6px; text-transform: capitalize; } + +/* ---- Shiny hunts ------------------------------------------- */ +.shiny { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 14px; +} +.shiny__card { + display: grid; + grid-template-columns: 1fr auto; + grid-template-areas: + 'mon counter' + 'chance chance' + 'notes notes' + 'actions actions'; + gap: 8px 10px; + padding: 12px; + border-radius: 14px; + background: var(--surface-2); +} +.shiny__mon { + grid-area: mon; + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + color: inherit; +} +.shiny__mon .sprite { + width: 56px; + height: 56px; + object-fit: contain; +} +.shiny__name { + display: block; + font-weight: 700; + text-transform: capitalize; +} +.shiny__method { + display: block; + font-size: 0.74rem; + color: var(--text-dim); +} +.shiny__counter { + grid-area: counter; + display: flex; + align-items: center; + gap: 8px; +} +.shiny__step { + width: 34px; + height: 34px; + border: 1.5px solid var(--border); + border-radius: 50%; + background: var(--surface); + color: var(--text); + font: inherit; + font-weight: 800; + cursor: pointer; +} +.shiny__step--add { + width: 48px; + border-radius: 999px; + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.shiny__count { + min-width: 3ch; + text-align: center; + font-size: 1.3rem; + font-weight: 800; + font-variant-numeric: tabular-nums; +} +.shiny__chance { + grid-area: chance; + font-size: 0.8rem; + font-weight: 600; + color: var(--text-dim); +} +.shiny__notes { + grid-area: notes; + width: 100%; + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); + color: var(--text); + font: inherit; + font-size: 0.82rem; +} +.shiny__actions { + grid-area: actions; + display: flex; + gap: 8px; +} +.shiny__actions .button { + flex: 1; +} +.shiny__new { + align-self: flex-start; +} diff --git a/src/views/BreedingView.js b/src/views/BreedingView.js index 6f66b3d..cdfb629 100644 --- a/src/views/BreedingView.js +++ b/src/views/BreedingView.js @@ -109,11 +109,14 @@ export async function BreedingView() { type: 'button', class: 'breed__pick', onclick: () => - openPokemonPicker((id) => { - target = snap.speciesById.get(id); - ui.set({ breedTarget: id }); - render(); - }), + openPokemonPicker( + (id) => { + target = snap.speciesById.get(id); + ui.set({ breedTarget: id }); + render(); + }, + { closeAfterPick: true }, + ), }, target ? el('span', {}, Sprite(target.id, { style, size: 40, alt: target.name }), ` ${prettify(target.name)}`) diff --git a/src/views/SettingsView.js b/src/views/SettingsView.js index fa1153f..1fe7307 100644 --- a/src/views/SettingsView.js +++ b/src/views/SettingsView.js @@ -1,6 +1,9 @@ import { el, onTeardown } from '../lib/dom.js'; import { settings, applyTheme } from '../store/settings.js'; import { selection } from '../store/selection.js'; +import { team } from '../store/team.js'; +import { formTracking } from '../store/formTracking.js'; +import { shinyHunts } from '../store/shinyHunts.js'; import { canInstall, onInstallChange, promptInstall } from '../lib/install.js'; export async function SettingsView() { @@ -130,6 +133,9 @@ export async function SettingsView() { const data = JSON.parse(await file.text()); if (data.settings) settings.replace(data.settings); if (data.selection) selection.replace(data.selection); + if (data.team) team.replace(data.team); + if (data.formTracking) formTracking.replace(data.formTracking); + if (data.shinyHunts) shinyHunts.replace(data.shinyHunts); alert('Import complete.'); } catch { alert('That file could not be read as a Pokédex backup.'); @@ -186,7 +192,8 @@ export async function SettingsView() { 'p', { class: 'settings__note' }, el('a', { class: 'link', href: '#/progress' }, 'Progress by game'), - ' — caught totals against every game’s dex.', + ' · ', + el('a', { class: 'link', href: '#/shiny' }, 'Shiny hunts'), ), storageNote, el('div', { class: 'settings__actions' }, @@ -253,6 +260,9 @@ function exportData() { exportedAt: new Date().toISOString(), settings: settings.get(), selection: selection.get(), + team: team.get(), + formTracking: formTracking.get(), + shinyHunts: shinyHunts.get(), }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json', diff --git a/src/views/ShinyView.js b/src/views/ShinyView.js new file mode 100644 index 0000000..f5baa1e --- /dev/null +++ b/src/views/ShinyView.js @@ -0,0 +1,194 @@ +import { el, clear, onTeardown } from '../lib/dom.js'; +import { loadSnapshot } from '../data/snapshot.js'; +import { settings } from '../store/settings.js'; +import { shinyHunts, addHunt, updateHunt, bumpHunt, removeHunt } from '../store/shinyHunts.js'; +import { toggle, entry } from '../store/selection.js'; +import { buzz } from '../lib/haptics.js'; +import { Sprite } from '../components/Sprite.js'; +import { openPokemonPicker } from '../components/PokemonPicker.js'; + +// Representative full-game odds (as 1/N), with a shiny-charm variant. +export const METHODS = [ + { id: 'full', label: 'Full odds', base: 4096, charm: 1365 }, + { id: 'masuda', label: 'Masuda Method', base: 683, charm: 512 }, + { id: 'sos', label: 'SOS chaining (31+)', base: 1365, charm: 1024 }, + { id: 'radar', label: 'Poké Radar (chain 40)', base: 99, charm: 99 }, + { id: 'dexnav', label: 'DexNav (high search level)', base: 1365, charm: 1024 }, + { id: 'combo', label: 'Catch Combo (31+)', base: 1024, charm: 819 }, + { id: 'dmax', label: 'Dynamax Adventure', base: 300, charm: 100 }, + { id: 'outbreak', label: 'Mass Outbreak (SV, 60+)', base: 512, charm: 205 }, + { id: 'massive', label: 'Massive Outbreak (Legends)', base: 158, charm: 128 }, +]; +const methodById = (id) => METHODS.find((m) => m.id === id) || METHODS[0]; + +function oddsFor(h) { + if (h.customOdds) return Math.max(1, h.customOdds); + const m = methodById(h.method); + return h.charm ? m.charm : m.base; +} +const pct = (n) => (n < 0.1 ? n.toFixed(2) : n < 10 ? n.toFixed(1) : Math.round(n)); + +export async function ShinyView() { + const view = el('section', { class: 'view lookup shinyview' }); + const snap = await loadSnapshot(); + const style = settings.get().spriteStyle; + + const list = el('div', { class: 'shiny' }); + + function huntCard(h) { + const sp = snap.speciesById.get(h.speciesId); + const odds = oddsFor(h); + + const cumulative = (n) => 1 - Math.pow(1 - 1 / odds, n); + const count = el('span', { class: 'shiny__count' }, String(h.count)); + const chanceEl = el( + 'span', + { class: 'shiny__chance' }, + `${pct(cumulative(h.count) * 100)}% by now · 1/${odds}`, + ); + const refresh = () => { + const n = shinyHunts.get().hunts.find((x) => x.id === h.id)?.count ?? 0; + count.textContent = String(n); + chanceEl.textContent = `${pct(cumulative(n) * 100)}% by now · 1/${odds}`; + }; + + const notes = el('input', { + type: 'text', + class: 'shiny__notes', + value: h.notes || '', + placeholder: 'Notes — location, den, phase count…', + onchange: (e) => updateHunt(h.id, { notes: e.target.value }), + }); + + return el( + 'div', + { class: 'shiny__card' }, + el( + 'a', + { class: 'shiny__mon', href: `#/pokemon/${h.speciesId}` }, + Sprite(h.speciesId, { style, shiny: true, alt: sp?.name, size: 56 }), + el( + 'span', + {}, + el('span', { class: 'shiny__name' }, (sp?.name || `#${h.speciesId}`).replace(/-/g, ' ')), + el('span', { class: 'shiny__method' }, methodById(h.method).label + (h.charm ? ' · charm' : '')), + ), + ), + el( + 'div', + { class: 'shiny__counter' }, + el('button', { type: 'button', class: 'shiny__step', onclick: () => { bumpHunt(h.id, -1); refresh(); } }, '−'), + count, + el( + 'button', + { + type: 'button', + class: 'shiny__step shiny__step--add', + onclick: () => { + bumpHunt(h.id, 1); + buzz(10); + refresh(); + }, + }, + '+1', + ), + ), + chanceEl, + notes, + el( + 'div', + { class: 'shiny__actions' }, + el( + 'button', + { + type: 'button', + class: 'button', + onclick: () => { + if (!entry(h.speciesId).caught) toggle(h.speciesId, 'caught'); + removeHunt(h.id); + render(); + }, + }, + '✨ Found it', + ), + el( + 'button', + { + type: 'button', + class: 'button button--danger', + onclick: () => { + if (confirm('Delete this hunt?')) { + removeHunt(h.id); + render(); + } + }, + }, + 'Delete', + ), + ), + ); + } + + function newHunt() { + openPokemonPicker( + (id) => chooseMethod((method, charm) => addHunt({ speciesId: id, method, charm })), + { closeAfterPick: true }, + ); + } + + function chooseMethod(done) { + const backdrop = el('div', { class: 'sheet-backdrop is-open', onclick: (e) => e.target === backdrop && backdrop.remove() }); + const methodSel = el('select', { class: 'search__input' }, ...METHODS.map((m) => el('option', { value: m.id }, m.label))); + const charmChk = el('input', { type: 'checkbox' }); + const panel = el( + 'div', + { class: 'gsheet' }, + el('div', { class: 'gsheet__head' }, el('h2', {}, 'Hunt method'), el('button', { class: 'gsheet__close', onclick: () => backdrop.remove() }, '✕')), + methodSel, + el('label', { class: 'lookup__check', style: 'margin-top:12px' }, charmChk, 'Shiny Charm'), + el( + 'button', + { + class: 'button', + style: 'margin-top:16px', + onclick: () => { + done(methodSel.value, charmChk.checked); + backdrop.remove(); + render(); + }, + }, + 'Start hunt', + ), + ); + backdrop.append(panel); + document.body.append(backdrop); + } + + function render() { + clear(list); + const hunts = shinyHunts.get().hunts; + if (!hunts.length) { + list.append(el('p', { class: 'detail__muted' }, 'No active hunts. Start one below.')); + } else { + for (const h of hunts) list.append(huntCard(h)); + } + list.append( + el('button', { type: 'button', class: 'lineup__add shiny__new', onclick: newHunt }, '+', el('span', {}, 'New hunt')), + ); + } + + clear(view).append( + el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/settings' }, '‹ Settings')), + el( + 'header', + { class: 'view__header' }, + el('h1', {}, 'Shiny hunts'), + el('p', {}, 'Count encounters and watch the cumulative odds climb. “Found it” marks the Pokémon caught.'), + ), + list, + ); + render(); + + onTeardown(view, () => {}); + return view; +}