Compare commits

..

4 Commits

Author SHA1 Message Date
bdc21fc4e6 Team builder: add a damage calculator
New "Calc" mode alongside Coverage/Compare. Pick (or quick-pick from your
current team) an attacker, a move, and a defender; get a damage range, %
of the defender's HP, hits-to-KO, and STAB/effectiveness badges.

- src/lib/damage-calc.js: the standard Gen 3+ stat and damage formulas
  (statAt, calcDamage), evaluated across the 85–100% roll.
- Move data is era-accurate: picking a move fetches it live for
  past_values (power/type/class changes), reusing MovesList's
  forGeneration (now exported) via a snapshot-shaped shim so the instant
  preview and the refined post-fetch result use the same code path.
- Level/nature/EV inputs per side (defaults: level 100, neutral nature,
  0 EV, 31 IV assumed) plus a crit toggle.
- Explicitly out of scope and called out in the UI: items, abilities,
  weather, terrain — this is a base-stats estimate, not a battle-exact
  number.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-04 11:58:51 -04:00
1adc06d11f Un-gate the form dex from the selected game
Second thought on the previous commit: gating "<Species> Dex" to games
that include the species contradicted the app's own model. Caught/seen
tracking here is unified across games by design, and there's no
game-dependent data on this tab (unlike Locations/Moves, which really do
vary by game and are rightly gated) — it's the same fixed set of
letters/patterns/decorations regardless of which game is selected. Hiding
it just because you switched games to browse something else would make
already-tracked progress look lost. Show it whenever the species has
cosmetic forms, full stop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-04 11:45:54 -04:00
1ed8fcb709 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
2026-09-04 11:35:03 -04:00
fc2128dabe Fix swipe eating scroll gestures on the detail page
onSwipe judged horizontal-vs-vertical from a single touchmove sample
compared against the start point. A fast scroll flick's first sample is
often slightly diagonal (thumb motion), so it could get claimed as a
page-swipe and every subsequent touchmove got preventDefault()'d — the
rest of the scroll silently died, so trying to reach off-screen content
in About/Stats/Evolution/etc. would instead flip to the next Pokémon.

Now vertical intent wins by default (bails, permanently, as soon as the
drag is even mildly more vertical than horizontal) and a horizontal
page-swipe only gets claimed once the drag is unambiguous
(dx > 20 && dx > 2*dy). Deliberate left/right swipes still work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-04 11:35:03 -04:00
8 changed files with 715 additions and 16 deletions

View File

@ -97,9 +97,14 @@ Working app with a type-themed UI:
summary (types that hit 2+ members, or that someone's weak to and nobody
resists), then a full matchup grid with a diverging resist◀│▶weak bar per
type; plus offensive STAB gaps and at-a-glance stats. A Compare table
stacks the six side by side. All matchup maths follow the selected game —
its generation's type chart and era-accurate typings (pre-Gen-6 Clefairy
is Normal, etc.). Toggleable in the nav from Settings.
stacks the six side by side. A third Calc mode is a damage calculator —
pick (or quick-pick from your team) an attacker + move + defender, and
get a damage range, % of HP, hits-to-KO, STAB/effectiveness badges, with
level/nature/EV controls; it's a base-stats estimate (31 IVs assumed,
no items/abilities/weather/terrain). All three follow the selected
game — its generation's type chart, era-accurate typings (pre-Gen-6
Clefairy is Normal, etc.), and era move data. Toggleable in the nav
from Settings.
- **Settings** — theme (System / Light / Dark / Black / Sepia) + accent
colour, sprite style, JSON export/import.
- **PWA** — Workbox SW: precache shell + snapshot, SWR for API JSON,

View File

@ -47,7 +47,7 @@ const PHYSICAL_TYPES = new Set([
'normal', 'fighting', 'poison', 'ground', 'flying', 'bug', 'rock', 'ghost', 'steel',
]);
function forGeneration(d, gen, genOfVg) {
export function forGeneration(d, gen, genOfVg) {
const v = {
power: d.power,
accuracy: d.accuracy,

46
src/lib/damage-calc.js Normal file
View File

@ -0,0 +1,46 @@
import { NATURES } from '../data/natures.js';
import { multiplier } from '../data/type-chart.js';
/** ±10%, or 1 for the five neutral natures. Unaffected stat -> 1. */
export function natureMultiplier(natureName, statKey) {
const n = NATURES.find((x) => x.name === natureName);
if (!n || n.up === n.down) return 1;
if (n.up === statKey) return 1.1;
if (n.down === statKey) return 0.9;
return 1;
}
/**
* A real in-game stat from a base stat, level, IVs/EVs and nature the
* standard Gen 3+ formula (also close enough for earlier gens, which this
* calculator otherwise already treats generically).
*/
export function statAt(base, level, { iv = 31, ev = 0, nature = 'Hardy', statKey } = {}) {
if (statKey === 'hp') {
if (base <= 1) return 1; // Shedinja
return Math.floor(((2 * base + iv + Math.floor(ev / 4)) * level) / 100) + level + 10;
}
const raw = Math.floor(((2 * base + iv + Math.floor(ev / 4)) * level) / 100 + 5);
return Math.floor(raw * natureMultiplier(nature, statKey));
}
/**
* The standard damage formula, evaluated across the 85100% random roll.
* Ignores items, abilities, weather, terrain and spread-move reduction
* none of those are part of this app's data model, so treat the result as
* a base-stats estimate rather than a battle-exact number.
*
* Returns null for status moves or 0-power moves.
*/
export function calcDamage({ level, power, moveType, atkTypes, defTypes, atkStat, defStat, gen, crit = false }) {
if (!power) return null;
const base = Math.floor((Math.floor((2 * level) / 5 + 2) * power * (atkStat / defStat)) / 50) + 2;
const stab = atkTypes.includes(moveType) ? 1.5 : 1;
const eff = multiplier(moveType, defTypes, gen);
const critMult = crit ? (gen <= 5 ? 2 : 1.5) : 1;
if (eff === 0) return { min: 0, max: 0, stab: stab > 1, eff: 0 };
const modifier = stab * eff * critMult;
const min = Math.max(1, Math.floor(Math.floor((base * 85) / 100) * modifier));
const max = Math.max(1, Math.floor(base * modifier));
return { min, max, stab: stab > 1, eff };
}

View File

@ -32,11 +32,19 @@ export function onSwipe(target, { onLeft, onRight, threshold = 60 } = {}) {
const dx = e.touches[0].clientX - x0;
const dy = e.touches[0].clientY - y0;
if (!claimed) {
if (Math.abs(dy) > 12 && Math.abs(dy) > Math.abs(dx)) {
active = false; // it's a vertical scroll — let it be
const adx = Math.abs(dx);
const ady = Math.abs(dy);
// Scroll is the default: bail out — permanently — at the first sign of
// vertical intent, even a mildly diagonal one. A page full of scrolling
// content (tab panels, lists) means a fast flick's first sample is
// often diagonal; without this bias it gets misread as a page-swipe
// and the rest of the scroll gets eaten by preventDefault().
if (ady > 10 && ady > adx * 0.7) {
active = false;
return;
}
if (Math.abs(dx) > 10 && Math.abs(dx) > Math.abs(dy)) claimed = true;
// Only claim a horizontal page-swipe once the drag is unambiguous.
if (adx > 20 && adx > ady * 2) claimed = true;
}
if (claimed) e.preventDefault();
};

20
src/store/formTracking.js Normal file
View 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] },
}));
}

View File

@ -2347,6 +2347,180 @@
background: var(--good);
}
/* ---- Damage calculator ---------------------------------------- */
.calc__sides {
display: flex;
align-items: center;
gap: 10px;
}
.calc__side {
flex: 1;
min-width: 0;
}
.calc__vs {
flex: none;
font-size: 1.1rem;
color: var(--text-dim);
margin-top: 18px;
}
.calc__slot {
margin-top: 4px;
}
.calc__pick {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 14px;
background: var(--surface-2);
font: inherit;
font-weight: 700;
font-size: 0.85rem;
text-align: left;
cursor: pointer;
}
.calc__pick--empty {
color: var(--text-dim);
font-weight: 600;
justify-content: center;
}
.calc__quick {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.calc__quick-btn {
width: 30px;
height: 30px;
padding: 2px;
border: none;
border-radius: 8px;
background: var(--surface-2);
display: grid;
place-items: center;
cursor: pointer;
}
.calc__movebar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 14px;
}
.calc__move-input {
flex: 1;
min-width: 0;
padding: 9px 12px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--ink, inherit);
font: inherit;
font-size: 0.85rem;
}
.calc__move-note {
font-size: 0.72rem;
font-weight: 600;
color: var(--text-dim);
white-space: nowrap;
}
.calc__controls {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
margin-top: 14px;
}
.calc__field {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 0.68rem;
font-weight: 700;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.calc__field input[type='number'],
.calc__field select {
width: 92px;
padding: 6px 8px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--ink, inherit);
font: inherit;
font-size: 0.8rem;
text-transform: none;
letter-spacing: normal;
}
.calc__field--check {
flex-direction: row;
align-items: center;
gap: 6px;
align-self: flex-end;
}
.calc__result {
margin-top: 16px;
padding: 14px 16px;
border-radius: 16px;
background: color-mix(in srgb, var(--type-main, var(--accent)) 12%, var(--surface-2));
}
.calc__headline {
font-size: 1.15rem;
font-weight: 800;
}
.calc__pct {
font-weight: 700;
color: var(--text-dim);
}
.calc__sub {
margin-top: 2px;
font-size: 0.82rem;
color: var(--text-dim);
font-weight: 600;
}
.calc__badges {
display: flex;
gap: 6px;
margin-top: 8px;
}
.calc__badge {
font-size: 0.68rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 3px 8px;
border-radius: 999px;
background: var(--surface-2);
}
.calc__badge--stab {
background: color-mix(in srgb, var(--accent) 30%, var(--surface-2));
}
.calc__badge--up {
background: color-mix(in srgb, var(--danger) 25%, var(--surface-2));
color: var(--danger);
}
.calc__badge--down {
background: color-mix(in srgb, var(--good) 25%, var(--surface-2));
color: var(--good);
}
.calc__caveat {
margin-top: 10px;
font-size: 0.7rem;
color: var(--text-dim);
}
@media (max-width: 420px) {
.calc__sides {
flex-direction: column;
}
.calc__vs {
margin-top: 0;
transform: rotate(90deg);
}
}
/* ---- Picker + team button ---------------------------------- */
.picker__list {
overflow-y: auto;
@ -2566,6 +2740,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;

View File

@ -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,88 @@ 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. Unlike Locations/Moves this isn't gated to the
// selected game: it's the same fixed set of letters/patterns/decorations
// regardless of game, and — like the rest of the app's tracking — your
// progress on it is global, not per-game, so it shouldn't vanish just
// because you switched your active game to browse something else.
let formDexPanel = null;
if (cosmeticForms.length > 1) {
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(

View File

@ -5,11 +5,16 @@ import { ui } from '../store/ui.js';
import { team, addToTeam, removeFromTeam, clearTeam, MAX_TEAM } from '../store/team.js';
import { TYPES, defenseVector, multiplier } from '../data/type-chart.js';
import { typesForGen } from '../lib/type-resolve.js';
import { forGeneration } from '../components/MovesList.js';
import { getMove } from '../data/api.js';
import { statAt, calcDamage } from '../lib/damage-calc.js';
import { NATURES } from '../data/natures.js';
import { Sprite } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js';
import { openPokemonPicker } from '../components/PokemonPicker.js';
const prettify = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const loose = (s) => s.toLowerCase().replace(/[-\s]+/g, ' ').trim();
const STAT_ROWS = [
['hp', 'HP'],
['atk', 'Attack'],
@ -21,19 +26,23 @@ const STAT_ROWS = [
const multClass = (m) =>
m === 0 ? 'm0' : m === 0.25 ? 'm025' : m === 0.5 ? 'm05' : m === 2 ? 'm2' : m === 4 ? 'm4' : 'm1';
const multText = (m) => (m === 0.25 ? '¼' : m === 0.5 ? '½' : m === 1 ? '' : `${m}×`);
const effWord = (eff) =>
eff === 0 ? 'No effect' : eff >= 4 ? '4× effective' : eff >= 2 ? 'Super effective' : eff <= 0.25 ? 'Barely effective' : eff <= 0.5 ? 'Not very effective' : null;
export async function TeamView() {
const view = el('section', { class: 'view teamview' });
const snap = await loadSnapshot();
let mode = ui.get().teamMode === 'compare' ? 'compare' : 'coverage';
const MODES = [
['coverage', 'Coverage'],
['compare', 'Compare'],
['calc', 'Calc'],
];
let mode = MODES.some(([id]) => id === ui.get().teamMode) ? ui.get().teamMode : 'coverage';
const seg = el(
'div',
{ class: 'seg' },
...[
['coverage', 'Coverage'],
['compare', 'Compare'],
].map(([id, label]) =>
...MODES.map(([id, label]) =>
el(
'button',
{
@ -42,9 +51,7 @@ export async function TeamView() {
onclick: () => {
mode = id;
ui.set({ teamMode: id });
[...seg.children].forEach((b, i) =>
b.classList.toggle('is-active', ['coverage', 'compare'][i] === id),
);
[...seg.children].forEach((b, i) => b.classList.toggle('is-active', MODES[i][0] === id));
render();
},
},
@ -136,6 +143,10 @@ export async function TeamView() {
renderLineup();
const mem = members();
clear(body);
if (mode === 'calc') {
body.append(calcTool(mem));
return;
}
if (!mem.length) {
body.append(el('p', { class: 'detail__muted' }, 'Add some Pokémon to get started.'));
return;
@ -367,6 +378,292 @@ export async function TeamView() {
);
}
// ---- Damage calculator ------------------------------------------
// Attacker + move vs. defender -> a damage range, using the standard
// formula off base stats/level/nature/EVs. No items, abilities, weather
// or terrain — those aren't part of this app's data model, so it's a
// base-stats estimate rather than a battle-exact number.
function calcTool(mem) {
const st = settings.get();
const vg = snap.versionGroupByKey.get(st.versionGroup);
const gen = vg ? vg.generation : 9;
const genOfVg = (name) => snap.versionGroupByKey.get(name)?.generation ?? 9;
const style = st.spriteStyle;
const state = {
atk: mem[0] || null,
def: mem[1] || mem[0] || null,
move: null, // { id, name, ...current-gen snapshot fields }
moveFull: null, // live /move/:id once fetched (adds past_values)
level: 100,
atkNature: 'Hardy',
defNature: 'Hardy',
atkEv: 0,
defEv: 0,
hpEv: 0,
crit: false,
};
const natureOptions = NATURES.map((n) => [n.name, n.name]);
function numField(label, value, { min = 0, max = 999 } = {}, onChange) {
return el(
'label',
{ class: 'calc__field' },
el('span', {}, label),
el('input', {
type: 'number',
min: String(min),
max: String(max),
value: String(value),
onchange: (e) => {
const n = Math.max(min, Math.min(max, Number(e.target.value) || 0));
e.target.value = String(n);
onChange(n);
recalc();
},
}),
);
}
function natureField(label, value, onChange) {
return el(
'label',
{ class: 'calc__field' },
el('span', {}, label),
el(
'select',
{
onchange: (e) => {
onChange(e.target.value);
recalc();
},
},
...natureOptions.map(([v, t]) => el('option', { value: v, selected: v === value }, t)),
),
);
}
function slot(which, sideLabel) {
const holder = el('div', { class: 'calc__slot' });
function paint() {
const sp = state[which];
clear(holder).append(
sp
? el(
'button',
{ type: 'button', class: 'calc__pick', onclick: () => pick() },
Sprite(sp.id, { style, size: 48, alt: sp.name }),
el('span', {}, prettify(sp.name)),
)
: el('button', { type: 'button', class: 'calc__pick calc__pick--empty', onclick: () => pick() }, `Choose ${sideLabel}`),
);
}
function pick() {
openPokemonPicker((id) => {
state[which] = snap.speciesById.get(id);
paint();
recalc();
});
}
paint();
const quick = mem.length
? el(
'div',
{ class: 'calc__quick' },
...mem.map((sp) =>
el(
'button',
{
type: 'button',
class: 'calc__quick-btn',
title: prettify(sp.name),
onclick: () => {
state[which] = sp;
paint();
recalc();
},
},
Sprite(sp.id, { style, size: 26, alt: sp.name }),
),
),
)
: null;
return el('div', { class: 'calc__side' }, el('h3', { class: 'ppanel__sub' }, sideLabel), holder, quick);
}
const atkSlot = slot('atk', 'Attacker');
const defSlot = slot('def', 'Defender');
const moveNote = el('span', { class: 'calc__move-note' });
const moveDatalist = el(
'datalist',
{ id: 'calc-movelist' },
...snap.moves.map((m) => el('option', { value: m.name.replace(/-/g, ' ') })),
);
const moveInput = el('input', {
type: 'text',
list: 'calc-movelist',
class: 'calc__move-input',
placeholder: 'Move…',
onchange: (e) => {
const q = loose(e.target.value);
const hit = snap.moves.find((m) => loose(m.name) === q);
state.move = hit || null;
state.moveFull = null;
e.target.value = hit ? hit.name.replace(/-/g, ' ') : '';
recalc();
if (hit) {
getMove(hit.id)
.then((full) => {
if (state.move?.id !== hit.id) return; // superseded
state.moveFull = full;
recalc();
})
.catch(() => {});
}
},
});
const critField = el(
'label',
{ class: 'calc__field calc__field--check' },
el('input', {
type: 'checkbox',
onchange: (e) => {
state.crit = e.target.checked;
recalc();
},
}),
'Critical hit',
);
const result = el('div', { class: 'calc__result' });
function recalc() {
const { atk, def } = state;
moveNote.textContent = '';
if (!state.move) {
clear(result).append(el('p', { class: 'detail__muted' }, 'Pick a move.'));
return;
}
const raw = state.moveFull || {
power: state.move.power,
accuracy: state.move.accuracy,
type: { name: state.move.type },
damage_class: state.move.damageClass ? { name: state.move.damageClass } : null,
past_values: [],
};
const v = forGeneration(raw, gen, genOfVg);
moveNote.textContent = `${prettify(v.type)} · ${v.damage_class ? prettify(v.damage_class) : 'Status'}${v.power ? ` · ${v.power} power` : ''}`;
if (!atk || !def) {
clear(result).append(el('p', { class: 'detail__muted' }, 'Choose an attacker and a defender.'));
return;
}
if (!v.damage_class || v.damage_class === 'status' || !v.power) {
clear(result).append(
el('p', { class: 'detail__muted' }, `${prettify(state.move.name)} doesn't deal direct damage.`),
);
return;
}
const statKey = v.damage_class === 'special' ? 'spa' : 'atk';
const defKey = v.damage_class === 'special' ? 'spd' : 'def';
const atkTypes = typesForGen(atk, gen);
const defTypes = typesForGen(def, gen);
const atkStat = statAt(atk.stats?.[statKey] ?? 0, state.level, {
ev: state.atkEv,
nature: state.atkNature,
statKey,
});
const defStat = statAt(def.stats?.[defKey] ?? 0, state.level, {
ev: state.defEv,
nature: state.defNature,
statKey: defKey,
});
const defHp = statAt(def.stats?.hp ?? 0, state.level, { ev: state.hpEv, statKey: 'hp' });
const dmg = calcDamage({
level: state.level,
power: v.power,
moveType: v.type,
atkTypes,
defTypes,
atkStat,
defStat,
gen,
crit: state.crit,
});
clear(result);
if (!dmg) {
result.append(el('p', { class: 'detail__muted' }, 'No power data for this move.'));
return;
}
if (dmg.eff === 0) {
result.append(
el('p', { class: 'calc__headline' }, `${prettify(def.name)} is immune — no effect.`),
);
return;
}
const pctMin = (dmg.min / defHp) * 100;
const pctMax = (dmg.max / defHp) * 100;
const hitsMin = Math.ceil(100 / pctMax);
const hitsMax = Math.ceil(100 / pctMin);
const word = effWord(dmg.eff);
result.append(
el(
'p',
{ class: 'calc__headline' },
`${dmg.min}${dmg.max} dmg`,
el('span', { class: 'calc__pct' }, ` · ${pctMin.toFixed(1)}${pctMax.toFixed(1)}% HP`),
),
el(
'p',
{ class: 'calc__sub' },
hitsMin === hitsMax ? `${hitsMin} hit${hitsMin > 1 ? 's' : ''} to KO` : `${hitsMin}${hitsMax} hits to KO`,
),
el(
'div',
{ class: 'calc__badges' },
dmg.stab ? el('span', { class: 'calc__badge calc__badge--stab' }, 'STAB') : null,
word ? el('span', { class: `calc__badge${dmg.eff >= 2 || dmg.eff === 4 ? ' calc__badge--up' : ' calc__badge--down'}` }, word) : null,
),
);
}
recalc();
return el(
'div',
{ class: 'calc' },
el(
'div',
{ class: 'calc__sides' },
atkSlot,
el('span', { class: 'calc__vs', 'aria-hidden': 'true' }, '⇄'),
defSlot,
),
moveDatalist,
el('div', { class: 'calc__movebar' }, moveInput, moveNote),
el(
'div',
{ class: 'calc__controls' },
numField('Level', state.level, { min: 1, max: 100 }, (n) => (state.level = n)),
natureField('Attacker nature', state.atkNature, (v) => (state.atkNature = v)),
numField('Attacker EV', state.atkEv, { min: 0, max: 252 }, (n) => (state.atkEv = n)),
natureField('Defender nature', state.defNature, (v) => (state.defNature = v)),
numField('Defender EV', state.defEv, { min: 0, max: 252 }, (n) => (state.defEv = n)),
numField('Defender HP EV', state.hpEv, { min: 0, max: 252 }, (n) => (state.hpEv = n)),
critField,
),
result,
el(
'p',
{ class: 'calc__caveat' },
'Estimate from base stats (31 IVs assumed) — items, abilities, weather and terrain arent modeled.',
),
);
}
render();
const off = team.subscribe(render);
// Re-render on any settings change: sprite style feeds the lineup, and the