Add a per-species form dex (Unown letters, Vivillon patterns, ...)
New tab on the detail page — "<Species> Dex" — for any species with more than one cosmetic form: a checklist grid (sprite + label + tap-to-toggle) tracking which appearances you've caught, separate from the species' own caught flag. Persisted in a new pdx.formTracking store keyed by form slug. Only offered when the species is actually in the currently selected game's dex (reuses the page's existing prev/next row lookup), so Unown gets its dex in HeartGold/SoulSilver but not in Sword/Shield, where it was never released. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
parent
fc2128dabe
commit
1ed8fcb709
20
src/store/formTracking.js
Normal file
20
src/store/formTracking.js
Normal file
@ -0,0 +1,20 @@
|
||||
import { createStore } from './createStore.js';
|
||||
|
||||
/**
|
||||
* Caught-tracking for purely-cosmetic form sets (Unown's letters, Vivillon's
|
||||
* patterns, Alcremie's decorations…). Keyed by form slug rather than
|
||||
* national dex id, since these are separate `pdx.selection`-style
|
||||
* collectibles that all share one species entry.
|
||||
*/
|
||||
export const formTracking = createStore('pdx.formTracking', { caught: {} });
|
||||
|
||||
export function isFormCaught(slug) {
|
||||
return !!formTracking.get().caught[slug];
|
||||
}
|
||||
|
||||
export function toggleFormCaught(slug) {
|
||||
formTracking.set((s) => ({
|
||||
...s,
|
||||
caught: { ...s.caught, [slug]: !s.caught[slug] },
|
||||
}));
|
||||
}
|
||||
@ -2566,6 +2566,80 @@
|
||||
.formbar__select option {
|
||||
color: initial;
|
||||
}
|
||||
.formdex__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.formdex__count {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.formdex {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(68px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.formdex__tile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 8px 4px 6px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
background: var(--surface-2);
|
||||
cursor: pointer;
|
||||
filter: grayscale(1);
|
||||
opacity: 0.55;
|
||||
transition: filter 0.15s, opacity 0.15s, background 0.15s;
|
||||
}
|
||||
.formdex__tile.is-caught {
|
||||
filter: none;
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, var(--type-main) 16%, var(--surface-2));
|
||||
}
|
||||
.formdex__sprite {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.formdex__label {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
line-height: 1.15;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.formdex__check {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--good);
|
||||
color: #fff;
|
||||
font-size: 0.6rem;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transform: scale(0.6);
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
.formdex__tile.is-caught .formdex__check {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
.card__forms {
|
||||
margin-top: 4px;
|
||||
font-size: 0.62rem;
|
||||
|
||||
@ -7,6 +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 { isFormCaught, toggleFormCaught } from '../store/formTracking.js';
|
||||
import { Sprite, spriteUrl } from '../components/Sprite.js';
|
||||
import { TypeChip } from '../components/TypeChip.js';
|
||||
import { StatBar } from '../components/StatBar.js';
|
||||
@ -424,14 +425,86 @@ export async function PokemonDetail(nationalId) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Form dex (Unown letters, Vivillon patterns, Alcremie decorations…) ----
|
||||
// A checklist of purely-cosmetic appearances, tracked separately from the
|
||||
// species' own caught flag. Only offered when this Pokémon is actually in
|
||||
// the currently selected game's dex (`pos >= 0`) — no Vivillon checklist
|
||||
// for a game it was never released in.
|
||||
let formDexPanel = null;
|
||||
if (cosmeticForms.length > 1 && pos >= 0) {
|
||||
const slots = [
|
||||
{ slug: snapSpecies.name, id: nationalId, name: 'Default', sprite: null },
|
||||
...cosmeticForms,
|
||||
];
|
||||
const countEl = el('span', { class: 'formdex__count' });
|
||||
const syncCount = () => {
|
||||
const n = slots.filter((f) => isFormCaught(f.slug)).length;
|
||||
countEl.textContent = `${n} / ${slots.length} caught`;
|
||||
};
|
||||
const grid = el(
|
||||
'div',
|
||||
{ class: 'formdex' },
|
||||
...slots.map((f) => {
|
||||
const img = el('img', {
|
||||
class: 'sprite formdex__sprite',
|
||||
loading: 'lazy',
|
||||
decoding: 'async',
|
||||
alt: f.name,
|
||||
src: f.sprite || spriteUrl(f.id, 'default'),
|
||||
});
|
||||
img.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
img.src = spriteUrl(nationalId, 'default');
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
const tile = el(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: `formdex__tile${isFormCaught(f.slug) ? ' is-caught' : ''}`,
|
||||
onclick: () => {
|
||||
toggleFormCaught(f.slug);
|
||||
tile.classList.toggle('is-caught', isFormCaught(f.slug));
|
||||
syncCount();
|
||||
},
|
||||
},
|
||||
img,
|
||||
el('span', { class: 'formdex__label' }, f.name),
|
||||
el('span', { class: 'formdex__check', 'aria-hidden': 'true' }, '✓'),
|
||||
);
|
||||
return tile;
|
||||
}),
|
||||
);
|
||||
syncCount();
|
||||
formDexPanel = el(
|
||||
'div',
|
||||
{ class: 'ppanel' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'formdex__head' },
|
||||
el('h2', { class: 'ppanel__sub' }, `${prettify(snapSpecies.name)} dex`),
|
||||
countEl,
|
||||
),
|
||||
el(
|
||||
'p',
|
||||
{ class: 'detail__muted' },
|
||||
"Track which appearances you've caught — separate from the Pokémon's own caught flag.",
|
||||
),
|
||||
grid,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Tabs -------------------------------------------
|
||||
const TABS = [
|
||||
{ id: 'about', label: 'About', node: aboutPanel },
|
||||
formDexPanel ? { id: 'formdex', label: `${prettify(snapSpecies.name)} Dex`, node: formDexPanel } : null,
|
||||
{ id: 'stats', label: 'Stats', node: statsPanel },
|
||||
{ id: 'evo', label: 'Evolution', node: evoPanel },
|
||||
{ id: 'moves', label: 'Moves', node: movesPanel },
|
||||
{ id: 'loc', label: `Locations`, node: locPanel },
|
||||
];
|
||||
].filter(Boolean);
|
||||
const body = el('div', { class: 'psheet__body' });
|
||||
const tabButtons = TABS.map((t) =>
|
||||
el(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user