Compare commits
3 Commits
a437f539fc
...
78df1a32e8
| Author | SHA1 | Date | |
|---|---|---|---|
| 78df1a32e8 | |||
| 5bce0f93f3 | |||
| 17e8e30f9b |
@ -19,7 +19,12 @@ framework.
|
|||||||
- **Sprites** → cache-first with a capped LRU.
|
- **Sprites** → cache-first with a capped LRU.
|
||||||
|
|
||||||
Export / import of all local state (settings, tracking, team, form
|
Export / import of all local state (settings, tracking, team, form
|
||||||
tracking, shiny hunts) as JSON lives in **Settings**.
|
tracking, shiny hunts) as JSON lives in **Settings**, which also imports
|
||||||
|
the Pokédex straight out of a **game save file** (`.sav` / `.srm`):
|
||||||
|
Gen 1 (R/B/Y), Gen 2 (G/S/C) and Gen 3 (R/S/E, FR/LG). It reads the
|
||||||
|
game's own seen/owned bitfields — checksum-picking Gold/Silver vs
|
||||||
|
Crystal, resolving Gen 3's newer slot and rotated sections — and merges
|
||||||
|
them in without removing anything.
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
} from '../data/pokedex-resolver.js';
|
} from '../data/pokedex-resolver.js';
|
||||||
import { settings } from '../store/settings.js';
|
import { settings } from '../store/settings.js';
|
||||||
import { gameColors } from '../data/game-colors.js';
|
import { gameColors } from '../data/game-colors.js';
|
||||||
|
import { dragToClose } from '../lib/sheet-drag.js';
|
||||||
|
|
||||||
let openInstance = null;
|
let openInstance = null;
|
||||||
|
|
||||||
@ -134,20 +135,18 @@ export async function openGameSheet() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
panel.append(
|
const head = el(
|
||||||
|
'div',
|
||||||
|
{ class: 'gsheet__head' },
|
||||||
|
el('h2', {}, 'Choose your game'),
|
||||||
el(
|
el(
|
||||||
'div',
|
'button',
|
||||||
{ class: 'gsheet__head' },
|
{ class: 'gsheet__close', type: 'button', 'aria-label': 'Close', onclick: close },
|
||||||
el('h2', {}, 'Choose your game'),
|
'✕',
|
||||||
el(
|
|
||||||
'button',
|
|
||||||
{ class: 'gsheet__close', type: 'button', 'aria-label': 'Close', onclick: close },
|
|
||||||
'✕',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
subdex,
|
|
||||||
list,
|
|
||||||
);
|
);
|
||||||
|
dragToClose(panel, head, close);
|
||||||
|
panel.append(head, subdex, list);
|
||||||
backdrop.append(panel);
|
backdrop.append(panel);
|
||||||
document.body.append(backdrop);
|
document.body.append(backdrop);
|
||||||
document.addEventListener('keydown', onKey);
|
document.addEventListener('keydown', onKey);
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { loadSnapshot } from '../data/snapshot.js';
|
|||||||
import { settings } from '../store/settings.js';
|
import { settings } from '../store/settings.js';
|
||||||
import { Sprite } from './Sprite.js';
|
import { Sprite } from './Sprite.js';
|
||||||
import { TypeChip } from './TypeChip.js';
|
import { TypeChip } from './TypeChip.js';
|
||||||
|
import { dragToClose } from '../lib/sheet-drag.js';
|
||||||
|
|
||||||
let openInstance = null;
|
let openInstance = null;
|
||||||
|
|
||||||
@ -83,16 +84,14 @@ export async function openPokemonPicker(onPick, { closeAfterPick = false } = {})
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
panel.append(
|
const head = el(
|
||||||
el(
|
'div',
|
||||||
'div',
|
{ class: 'gsheet__head' },
|
||||||
{ class: 'gsheet__head' },
|
el('h2', {}, 'Add a Pokémon'),
|
||||||
el('h2', {}, 'Add a Pokémon'),
|
el('button', { class: 'gsheet__close', type: 'button', 'aria-label': 'Close', onclick: close }, '✕'),
|
||||||
el('button', { class: 'gsheet__close', type: 'button', 'aria-label': 'Close', onclick: close }, '✕'),
|
|
||||||
),
|
|
||||||
input,
|
|
||||||
results,
|
|
||||||
);
|
);
|
||||||
|
dragToClose(panel, head, close);
|
||||||
|
panel.append(head, input, results);
|
||||||
backdrop.append(panel);
|
backdrop.append(panel);
|
||||||
document.body.append(backdrop);
|
document.body.append(backdrop);
|
||||||
document.addEventListener('keydown', onKey);
|
document.addEventListener('keydown', onKey);
|
||||||
|
|||||||
@ -30,19 +30,28 @@ export function countUp(node, to, { from = 0, duration = 550, decimals = 0, pref
|
|||||||
* reduced-motion users just get an instant jump.
|
* reduced-motion users just get an instant jump.
|
||||||
*/
|
*/
|
||||||
export function segThumb(seg, _count, active = 0) {
|
export function segThumb(seg, _count, active = 0) {
|
||||||
|
seg.classList.add('seg--thumbed');
|
||||||
const thumb = document.createElement('div');
|
const thumb = document.createElement('div');
|
||||||
thumb.className = 'seg__thumb';
|
thumb.className = 'seg__thumb';
|
||||||
seg.prepend(thumb);
|
seg.prepend(thumb);
|
||||||
let current = active;
|
let current = active;
|
||||||
const place = (i) => {
|
const place = (i, tries = 0) => {
|
||||||
current = i;
|
current = i;
|
||||||
const b = seg.querySelectorAll('.seg__btn')[i];
|
const b = seg.querySelectorAll('.seg__btn')[i];
|
||||||
if (!b || !b.offsetWidth) return;
|
if (b && b.offsetWidth) {
|
||||||
thumb.style.width = `${b.offsetWidth}px`;
|
thumb.style.width = `${b.offsetWidth}px`;
|
||||||
thumb.style.transform = `translateX(${b.offsetLeft - 3}px)`;
|
thumb.style.transform = `translateX(${b.offsetLeft - 3}px)`;
|
||||||
|
} else if (tries < 20) {
|
||||||
|
// Not laid out yet (hidden tab, webfont pending) — try again.
|
||||||
|
requestAnimationFrame(() => place(i, tries + 1));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
requestAnimationFrame(() => place(active));
|
requestAnimationFrame(() => place(active));
|
||||||
if (document.fonts?.ready) document.fonts.ready.then(() => place(current));
|
if (document.fonts?.ready) document.fonts.ready.then(() => place(current));
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
const ro = new ResizeObserver(() => place(current));
|
||||||
|
ro.observe(seg);
|
||||||
|
}
|
||||||
return place;
|
return place;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
233
src/lib/savedex.js
Normal file
233
src/lib/savedex.js
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* Read the Pokédex (seen / owned) out of a Game Boy / GBA save file —
|
||||||
|
* Generations I, II and III, as dumped by emulators (`.sav`) or RetroArch
|
||||||
|
* (`.srm`). These read the game's own dex bitfields, not an approximation.
|
||||||
|
*
|
||||||
|
* Refs: Bulbapedia "Save data structure (Generation I / II / III)", and
|
||||||
|
* the pret disassemblies (pokered / pokecrystal / pokeemerald).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Shared helpers
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const bitsToList = (bytes, start, count) => {
|
||||||
|
const list = [];
|
||||||
|
for (let n = 1; n <= count; n++) {
|
||||||
|
if (bytes[start + ((n - 1) >> 3)] & (1 << ((n - 1) & 7))) list.push(n);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
};
|
||||||
|
|
||||||
|
// GB (Gen 1 & 2) in-game text -> ASCII, enough for trainer names.
|
||||||
|
const GB_CHARS = (() => {
|
||||||
|
const t = { 0x7f: ' ', 0x50: '\0' };
|
||||||
|
const up = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
const lo = 'abcdefghijklmnopqrstuvwxyz';
|
||||||
|
for (let i = 0; i < 26; i++) {
|
||||||
|
t[0x80 + i] = up[i];
|
||||||
|
t[0xa0 + i] = lo[i];
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 10; i++) t[0xf6 + i] = String(i);
|
||||||
|
Object.assign(t, { 0xe8: '.', 0xe3: '-', 0xf3: '/', 0xf4: ',', 0x9a: '(', 0x9b: ')', 0xe6: '?', 0xe7: '!' });
|
||||||
|
return t;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// GBA (Gen 3) in-game text -> ASCII.
|
||||||
|
const GBA_CHARS = (() => {
|
||||||
|
const t = { 0x00: ' ' };
|
||||||
|
for (let i = 0; i < 10; i++) t[0xa1 + i] = String(i);
|
||||||
|
const up = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
const lo = 'abcdefghijklmnopqrstuvwxyz';
|
||||||
|
for (let i = 0; i < 26; i++) {
|
||||||
|
t[0xbb + i] = up[i];
|
||||||
|
t[0xd5 + i] = lo[i];
|
||||||
|
}
|
||||||
|
Object.assign(t, {
|
||||||
|
0xad: '.', 0xae: '-', 0xba: '/', 0xac: ',', 0xb8: '&',
|
||||||
|
});
|
||||||
|
return t;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function decodeName(bytes, table, terminator) {
|
||||||
|
let out = '';
|
||||||
|
for (const b of bytes) {
|
||||||
|
if (b === terminator) break;
|
||||||
|
const c = table[b];
|
||||||
|
if (c === '\0' || c === undefined) {
|
||||||
|
if (c === '\0') break;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out += c;
|
||||||
|
}
|
||||||
|
return out.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sumBytes = (bytes, from, to) => {
|
||||||
|
let s = 0;
|
||||||
|
for (let i = from; i <= to; i++) s = (s + bytes[i]) & 0xffffffff;
|
||||||
|
return s >>> 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Generation I — Red / Blue / Yellow (International, 32 KB SRAM)
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const G1 = { name: 0x2598, owned: 0x25a3, seen: 0x25b6, species: 151, ckByte: 0x3523, ckFrom: 0x2598, ckTo: 0x3522 };
|
||||||
|
|
||||||
|
function tryGen1(bytes) {
|
||||||
|
if (bytes.length < 0x8000) return null;
|
||||||
|
const caught = bitsToList(bytes, G1.owned, G1.species);
|
||||||
|
const seen = bitsToList(bytes, G1.seen, G1.species);
|
||||||
|
const checksumOk = ((~sumBytes(bytes, G1.ckFrom, G1.ckTo)) & 0xff) === bytes[G1.ckByte];
|
||||||
|
if (!seen.length && !caught.length) return null;
|
||||||
|
return {
|
||||||
|
gen: 1,
|
||||||
|
game: 'Red / Blue / Yellow',
|
||||||
|
trainer: decodeName(bytes.subarray(G1.name, G1.name + 11), GB_CHARS, 0x50),
|
||||||
|
seen,
|
||||||
|
caught,
|
||||||
|
nationalUnlocked: true,
|
||||||
|
checksumOk,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Generation II — Gold / Silver / Crystal (International, 32 KB SRAM)
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const G2 = {
|
||||||
|
gs: { name: 0x200b, owned: 0x2a4c, seen: 0x2a6c, ck: 0x2d69, ckFrom: 0x2009, ckTo: 0x2d68, label: 'Gold / Silver' },
|
||||||
|
cr: { name: 0x2009, owned: 0x2a27, seen: 0x2a47, ck: 0x2d0d, ckFrom: 0x2009, ckTo: 0x2b82, label: 'Crystal' },
|
||||||
|
};
|
||||||
|
const G2_SPECIES = 251;
|
||||||
|
|
||||||
|
function tryGen2Variant(bytes, v) {
|
||||||
|
const caught = bitsToList(bytes, v.owned, G2_SPECIES);
|
||||||
|
const seen = bitsToList(bytes, v.seen, G2_SPECIES);
|
||||||
|
if (!seen.length && !caught.length) return null;
|
||||||
|
// No species may be "caught" but not "seen", and the last used byte must
|
||||||
|
// not spill past species 251 — cheap sanity checks for the right offsets.
|
||||||
|
if (caught.some((n) => !seen.includes(n))) return null;
|
||||||
|
if (bytes[v.owned + 31] & 0xf8 || bytes[v.seen + 31] & 0xf8) return null;
|
||||||
|
const stored = bytes[v.ck] | (bytes[v.ck + 1] << 8);
|
||||||
|
const checksumOk = (sumBytes(bytes, v.ckFrom, v.ckTo) & 0xffff) === stored;
|
||||||
|
return {
|
||||||
|
gen: 2,
|
||||||
|
game: v.label,
|
||||||
|
trainer: decodeName(bytes.subarray(v.name, v.name + 11), GB_CHARS, 0x50),
|
||||||
|
seen,
|
||||||
|
caught,
|
||||||
|
nationalUnlocked: true,
|
||||||
|
checksumOk,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryGen2(bytes) {
|
||||||
|
if (bytes.length < 0x8000) return null;
|
||||||
|
const gs = tryGen2Variant(bytes, G2.gs);
|
||||||
|
const cr = tryGen2Variant(bytes, G2.cr);
|
||||||
|
if (gs?.checksumOk) return gs;
|
||||||
|
if (cr?.checksumOk) return cr;
|
||||||
|
return gs || cr || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Generation III — RSE / FRLG (128 KB, two slots, rotated sections)
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const SECTION_SIZE = 4096;
|
||||||
|
const SECTIONS_PER_SLOT = 14;
|
||||||
|
const SLOT_SIZE = SECTION_SIZE * SECTIONS_PER_SLOT; // 57344
|
||||||
|
const SIGNATURE = 0x08012025;
|
||||||
|
const SECTION_DATA_SIZE = [3884, 3968, 3968, 3968, 3848, 3968, 3968, 3968, 3968, 3968, 3968, 3968, 3968, 2000];
|
||||||
|
const DEX_OFFSET = 0x18;
|
||||||
|
const DEX_MAGIC = DEX_OFFSET + 2; // 0xDA = RSE national, 0xB9 = FRLG national
|
||||||
|
const G3_OWNED = DEX_OFFSET + 16;
|
||||||
|
const G3_SEEN = DEX_OFFSET + 68;
|
||||||
|
const G3_SPECIES = 386;
|
||||||
|
|
||||||
|
function readSlot(view, base) {
|
||||||
|
const sections = {};
|
||||||
|
let counter = -1;
|
||||||
|
let valid = 0;
|
||||||
|
for (let i = 0; i < SECTIONS_PER_SLOT; i++) {
|
||||||
|
const off = base + i * SECTION_SIZE;
|
||||||
|
if (view.getUint32(off + 0x0ffc, true) !== SIGNATURE) continue;
|
||||||
|
const id = view.getUint16(off + 0x0ff4, true);
|
||||||
|
if (id > 13) continue;
|
||||||
|
sections[id] = off;
|
||||||
|
counter = Math.max(counter, view.getUint32(off + 0x0ff8, true));
|
||||||
|
valid++;
|
||||||
|
}
|
||||||
|
return { sections, counter, valid };
|
||||||
|
}
|
||||||
|
|
||||||
|
function g3ChecksumOk(view, sectionOff, id) {
|
||||||
|
const size = SECTION_DATA_SIZE[id] ?? 3968;
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < size; i += 4) sum = (sum + view.getUint32(sectionOff + i, true)) >>> 0;
|
||||||
|
return (((sum >>> 16) + (sum & 0xffff)) & 0xffff) === view.getUint16(sectionOff + 0x0ff6, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryGen3(bytes) {
|
||||||
|
if (bytes.length < SLOT_SIZE + SECTION_SIZE) return null;
|
||||||
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
|
const a = readSlot(view, 0);
|
||||||
|
const b = bytes.length >= 2 * SLOT_SIZE ? readSlot(view, SLOT_SIZE) : { sections: {}, counter: -1, valid: 0 };
|
||||||
|
if (a.valid < 3 && b.valid < 3) return null;
|
||||||
|
const slot = b.counter > a.counter ? b : a;
|
||||||
|
const s0 = slot.sections[0];
|
||||||
|
if (s0 == null) return null;
|
||||||
|
|
||||||
|
const magic = bytes[s0 + DEX_MAGIC];
|
||||||
|
const seen = bitsToList(bytes, s0 + G3_SEEN, G3_SPECIES);
|
||||||
|
const caught = bitsToList(bytes, s0 + G3_OWNED, G3_SPECIES);
|
||||||
|
if (!seen.length && !caught.length) return null;
|
||||||
|
return {
|
||||||
|
gen: 3,
|
||||||
|
game: magic === 0xb9 ? 'FireRed / LeafGreen' : 'Ruby / Sapphire / Emerald',
|
||||||
|
trainer: decodeName(bytes.subarray(s0, s0 + 7), GBA_CHARS, 0xff),
|
||||||
|
seen,
|
||||||
|
caught,
|
||||||
|
nationalUnlocked: magic === 0xda || magic === 0xb9,
|
||||||
|
checksumOk: g3ChecksumOk(view, s0, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Public: sniff the file and parse whatever generation it is.
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ArrayBuffer} buffer
|
||||||
|
* @returns {{ gen:number, game:string, trainer:string, seen:number[], caught:number[], nationalUnlocked:boolean }}
|
||||||
|
* @throws {Error} with a user-facing message if it can't be read.
|
||||||
|
*/
|
||||||
|
export function parseSaveDex(buffer) {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
if (bytes.length < 0x8000) {
|
||||||
|
throw new Error('That file is too small to be a Pokémon save.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 128 KB → Gen 3. 32–64 KB → Gen 1 or 2 (checksum picks the winner).
|
||||||
|
const candidates =
|
||||||
|
bytes.length >= SLOT_SIZE + SECTION_SIZE
|
||||||
|
? [tryGen3(bytes), tryGen2(bytes), tryGen1(bytes)]
|
||||||
|
: [tryGen2(bytes), tryGen1(bytes)];
|
||||||
|
|
||||||
|
const hits = candidates.filter(Boolean);
|
||||||
|
if (!hits.length) {
|
||||||
|
throw new Error(
|
||||||
|
"Couldn't find a Pokédex in that file — it may be a game this doesn't support yet, a corrupted save, or a Japanese cartridge.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const best = hits.find((h) => h.checksumOk) || hits[0];
|
||||||
|
if (!best.checksumOk) {
|
||||||
|
console.warn('savedex: no checksum matched — importing the most likely reading anyway.');
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back-compat: earlier code imported this name.
|
||||||
|
export const parseGen3Dex = parseSaveDex;
|
||||||
43
src/lib/sheet-drag.js
Normal file
43
src/lib/sheet-drag.js
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Drag a bottom sheet down by its handle to dismiss it. `panel` is the
|
||||||
|
* `.gsheet`, `handle` the grab area (its header), `close` the dismiss fn.
|
||||||
|
* Past ~110px (or a quick flick) it closes; otherwise it springs back.
|
||||||
|
*/
|
||||||
|
export function dragToClose(panel, handle, close) {
|
||||||
|
let y0 = 0;
|
||||||
|
let dy = 0;
|
||||||
|
let t0 = 0;
|
||||||
|
let dragging = false;
|
||||||
|
|
||||||
|
const start = (e) => {
|
||||||
|
if (e.touches.length !== 1) return;
|
||||||
|
y0 = e.touches[0].clientY;
|
||||||
|
dy = 0;
|
||||||
|
t0 = Date.now();
|
||||||
|
dragging = true;
|
||||||
|
panel.style.transition = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
const move = (e) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
dy = e.touches[0].clientY - y0;
|
||||||
|
// Rubber-band an upward pull; follow a downward one.
|
||||||
|
const shown = dy < 0 ? dy * 0.25 : dy;
|
||||||
|
panel.style.transform = `translateY(${shown}px)`;
|
||||||
|
if (dy > 0) e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const end = () => {
|
||||||
|
if (!dragging) return;
|
||||||
|
dragging = false;
|
||||||
|
panel.style.transition = '';
|
||||||
|
panel.style.transform = '';
|
||||||
|
const flick = dy > 40 && Date.now() - t0 < 300;
|
||||||
|
if (dy > 110 || flick) close();
|
||||||
|
};
|
||||||
|
|
||||||
|
handle.addEventListener('touchstart', start, { passive: true });
|
||||||
|
handle.addEventListener('touchmove', move, { passive: false });
|
||||||
|
handle.addEventListener('touchend', end, { passive: true });
|
||||||
|
handle.addEventListener('touchcancel', end, { passive: true });
|
||||||
|
}
|
||||||
@ -14,6 +14,24 @@ export function onSwipe(target, { onLeft, onRight, threshold = 60 } = {}) {
|
|||||||
let t0 = 0;
|
let t0 = 0;
|
||||||
let active = false;
|
let active = false;
|
||||||
let claimed = false;
|
let claimed = false;
|
||||||
|
let startNode = null;
|
||||||
|
|
||||||
|
// Would a horizontal drag from `node` scroll a nested element instead of
|
||||||
|
// the page? (moves list, tab strip, coverage grid…) `dir` < 0 means the
|
||||||
|
// finger is moving left — that scrolls such an element to the right.
|
||||||
|
const scrollsInside = (node, dir) => {
|
||||||
|
for (let n = node; n && n !== document.body && n !== target.parentElement; n = n.parentElement) {
|
||||||
|
if (n.scrollWidth - n.clientWidth > 2) {
|
||||||
|
const ox = getComputedStyle(n).overflowX;
|
||||||
|
if (ox === 'auto' || ox === 'scroll') {
|
||||||
|
const max = n.scrollWidth - n.clientWidth;
|
||||||
|
if (dir < 0 && n.scrollLeft < max - 1) return true;
|
||||||
|
if (dir > 0 && n.scrollLeft > 1) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const start = (e) => {
|
const start = (e) => {
|
||||||
if (e.touches.length !== 1) {
|
if (e.touches.length !== 1) {
|
||||||
@ -25,6 +43,7 @@ export function onSwipe(target, { onLeft, onRight, threshold = 60 } = {}) {
|
|||||||
t0 = Date.now();
|
t0 = Date.now();
|
||||||
active = true;
|
active = true;
|
||||||
claimed = false;
|
claimed = false;
|
||||||
|
startNode = e.target;
|
||||||
};
|
};
|
||||||
|
|
||||||
const move = (e) => {
|
const move = (e) => {
|
||||||
@ -43,8 +62,15 @@ export function onSwipe(target, { onLeft, onRight, threshold = 60 } = {}) {
|
|||||||
active = false;
|
active = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Only claim a horizontal page-swipe once the drag is unambiguous.
|
// Only claim a horizontal page-swipe once the drag is unambiguous —
|
||||||
if (adx > 20 && adx > ady * 2) claimed = true;
|
// and not when it should scroll something nested instead.
|
||||||
|
if (adx > 20 && adx > ady * 2) {
|
||||||
|
if (scrollsInside(startNode, dx)) {
|
||||||
|
active = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
claimed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (claimed) e.preventDefault();
|
if (claimed) e.preventDefault();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -46,6 +46,20 @@ export function setNote(id, note) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Merge in seen/caught species lists (e.g. from a save-file import). Never clears. */
|
||||||
|
export function importFlags({ seen = [], caught = [] } = {}) {
|
||||||
|
selection.set((s) => {
|
||||||
|
const pokemon = { ...s.pokemon };
|
||||||
|
const stamp = new Date().toISOString();
|
||||||
|
const bump = (id, patch) => {
|
||||||
|
pokemon[id] = { ...(pokemon[id] || BLANK), ...patch, updatedAt: stamp };
|
||||||
|
};
|
||||||
|
for (const id of seen) bump(id, { seen: true });
|
||||||
|
for (const id of caught) bump(id, { seen: true, caught: true });
|
||||||
|
return { ...s, pokemon };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Aggregate seen/caught counts over an arbitrary list of national ids. */
|
/** Aggregate seen/caught counts over an arbitrary list of national ids. */
|
||||||
export function stats(speciesIds) {
|
export function stats(speciesIds) {
|
||||||
const p = selection.get().pokemon;
|
const p = selection.get().pokemon;
|
||||||
|
|||||||
@ -1725,6 +1725,11 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 16px 0 8px;
|
padding: 16px 0 8px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.gsheet__head:active {
|
||||||
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
.gsheet__head::before {
|
.gsheet__head::before {
|
||||||
content: "";
|
content: "";
|
||||||
@ -3347,7 +3352,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
.seg:has(.seg__thumb) .seg__btn.is-active {
|
.seg--thumbed .seg__btn.is-active {
|
||||||
background: none;
|
background: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { el, onTeardown } from '../lib/dom.js';
|
import { el, onTeardown } from '../lib/dom.js';
|
||||||
import { settings, applyTheme } from '../store/settings.js';
|
import { settings, applyTheme } from '../store/settings.js';
|
||||||
import { selection } from '../store/selection.js';
|
import { selection, importFlags } from '../store/selection.js';
|
||||||
import { team } from '../store/team.js';
|
import { team } from '../store/team.js';
|
||||||
import { formTracking } from '../store/formTracking.js';
|
import { formTracking } from '../store/formTracking.js';
|
||||||
import { shinyHunts } from '../store/shinyHunts.js';
|
import { shinyHunts } from '../store/shinyHunts.js';
|
||||||
|
import { parseSaveDex } from '../lib/savedex.js';
|
||||||
import { canInstall, onInstallChange, promptInstall } from '../lib/install.js';
|
import { canInstall, onInstallChange, promptInstall } from '../lib/install.js';
|
||||||
|
|
||||||
export async function SettingsView() {
|
export async function SettingsView() {
|
||||||
@ -122,6 +123,36 @@ export async function SettingsView() {
|
|||||||
storageNote.textContent = 'Storage estimate not available in this browser.';
|
storageNote.textContent = 'Storage estimate not available in this browser.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Import the Pokédex out of a Gen 3 GBA save (.sav / .srm).
|
||||||
|
const saveNote = el('p', { class: 'settings__note' });
|
||||||
|
const saveInput = el('input', {
|
||||||
|
type: 'file',
|
||||||
|
accept: '.sav,.srm,.sa1,.fla,application/octet-stream',
|
||||||
|
style: 'display:none',
|
||||||
|
onchange: async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
saveNote.textContent = 'Reading…';
|
||||||
|
try {
|
||||||
|
const dex = parseSaveDex(await file.arrayBuffer());
|
||||||
|
const who = dex.trainer || dex.game;
|
||||||
|
if (
|
||||||
|
confirm(
|
||||||
|
`${dex.game}${dex.trainer ? ` — ${dex.trainer}` : ''}\n${dex.seen.length} seen, ${dex.caught.length} caught.\n\nMerge into your Pokédex? Nothing is removed.`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
importFlags({ seen: dex.seen, caught: dex.caught });
|
||||||
|
saveNote.textContent = `Merged ${dex.caught.length} caught / ${dex.seen.length} seen from ${who}.`;
|
||||||
|
} else {
|
||||||
|
saveNote.textContent = '';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
saveNote.textContent = err.message || 'Could not read that save file.';
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const importInput = el('input', {
|
const importInput = el('input', {
|
||||||
type: 'file',
|
type: 'file',
|
||||||
accept: 'application/json',
|
accept: 'application/json',
|
||||||
@ -195,6 +226,14 @@ export async function SettingsView() {
|
|||||||
' · ',
|
' · ',
|
||||||
el('a', { class: 'link', href: '#/shiny' }, 'Shiny hunts'),
|
el('a', { class: 'link', href: '#/shiny' }, 'Shiny hunts'),
|
||||||
),
|
),
|
||||||
|
el(
|
||||||
|
'p',
|
||||||
|
{ class: 'settings__note' },
|
||||||
|
el('button', { class: 'button', type: 'button', onclick: () => saveInput.click() }, 'Import from a game save'),
|
||||||
|
' — a .sav / .srm from Gen 1 (R/B/Y), Gen 2 (G/S/C) or Gen 3 (R/S/E, FR/LG). Reads the game’s own Pokédex; merges seen/caught, removes nothing.',
|
||||||
|
),
|
||||||
|
saveInput,
|
||||||
|
saveNote,
|
||||||
storageNote,
|
storageNote,
|
||||||
el('div', { class: 'settings__actions' },
|
el('div', { class: 'settings__actions' },
|
||||||
el('button', {
|
el('button', {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user