Shiny hunt tracker (#/shiny)
New store + view (linked from Settings > Your data): one card per active hunt with a big +1 counter, the method's odds, and the running cumulative chance (1 - (1-p)^count). Nine methods with shiny-charm variants (Full odds, Masuda, SOS, Radar, DexNav, Catch Combo, Dynamax Adventure, Mass/Massive Outbreak). "Found it" marks the Pokémon caught and clears the hunt; per-hunt notes field. Also: JSON export/import now covers team, formTracking and shinyHunts alongside settings/selection; openPokemonPicker gains a `closeAfterPick` option (used by the shiny + breeding single-pick flows). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
parent
48cb471489
commit
b15ee3dd7f
@ -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')}`),
|
||||
|
||||
@ -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() },
|
||||
];
|
||||
|
||||
39
src/store/shinyHunts.js
Normal file
39
src/store/shinyHunts.js
Normal file
@ -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) }));
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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)}`)
|
||||
|
||||
@ -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',
|
||||
|
||||
194
src/views/ShinyView.js
Normal file
194
src/views/ShinyView.js
Normal file
@ -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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user