Pure formatting — no behaviour change. Verified: build passes, all 14 routes render, service worker active, no console errors. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
741 lines
23 KiB
JavaScript
741 lines
23 KiB
JavaScript
import { el, clear, onTeardown } from '../lib/dom.js';
|
||
import { loadSnapshot } from '../data/snapshot.js';
|
||
import { settings } from '../store/settings.js';
|
||
import { ui } from '../store/ui.js';
|
||
import { team, addToTeam, removeFromTeam, clearTeam, slot, 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 { CompareTable } from '../components/CompareTable.js';
|
||
import { openPokemonPicker } from '../components/PokemonPicker.js';
|
||
import { segThumb } from '../lib/anim.js';
|
||
|
||
const prettify = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||
const loose = (s) =>
|
||
s
|
||
.toLowerCase()
|
||
.replace(/[-\s]+/g, ' ')
|
||
.trim();
|
||
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();
|
||
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' },
|
||
...MODES.map(([id, label], i) =>
|
||
el(
|
||
'button',
|
||
{
|
||
class: `seg__btn${id === mode ? ' is-active' : ''}`,
|
||
type: 'button',
|
||
onclick: () => {
|
||
mode = id;
|
||
ui.set({ teamMode: id });
|
||
seg
|
||
.querySelectorAll('.seg__btn')
|
||
.forEach((b, j) => b.classList.toggle('is-active', MODES[j][0] === id));
|
||
setThumb(i);
|
||
render();
|
||
},
|
||
},
|
||
label,
|
||
),
|
||
),
|
||
);
|
||
const setThumb = segThumb(
|
||
seg,
|
||
MODES.length,
|
||
Math.max(
|
||
0,
|
||
MODES.findIndex(([id]) => id === mode),
|
||
),
|
||
);
|
||
|
||
const lineup = el('div', { class: 'lineup' });
|
||
const body = el('div', { class: 'teamview__body' });
|
||
|
||
view.append(
|
||
el(
|
||
'header',
|
||
{ class: 'view__header' },
|
||
el('h1', {}, 'Team'),
|
||
el('p', {}, `Up to ${MAX_TEAM} Pokémon — check weaknesses, coverage and stats.`),
|
||
),
|
||
el(
|
||
'div',
|
||
{ class: 'teamview__bar' },
|
||
seg,
|
||
el(
|
||
'nav',
|
||
{ class: 'teamview__refs', 'aria-label': 'Reference tools' },
|
||
el(
|
||
'a',
|
||
{ class: 'reflink', href: '#/natures' },
|
||
el('span', { class: 'reflink__ico', 'aria-hidden': 'true' }, '✦'),
|
||
'Natures',
|
||
),
|
||
el(
|
||
'a',
|
||
{ class: 'reflink', href: '#/types' },
|
||
el('span', { class: 'reflink__ico', 'aria-hidden': 'true' }, '▦'),
|
||
'Type chart',
|
||
),
|
||
el(
|
||
'a',
|
||
{ class: 'reflink', href: '#/breeding' },
|
||
el('span', { class: 'reflink__ico', 'aria-hidden': 'true' }, '⬡'),
|
||
'Breeding',
|
||
),
|
||
),
|
||
),
|
||
lineup,
|
||
body,
|
||
);
|
||
|
||
function members() {
|
||
return team
|
||
.get()
|
||
.members.map((m) => {
|
||
const s = slot(m);
|
||
const base = snap.speciesById.get(s.id);
|
||
if (!base) return null;
|
||
const f = s.form && (base.forms || []).find((x) => x.slug === s.form);
|
||
if (!f) return { ...base, _slotForm: null };
|
||
// Merge the snapshot form's baked types / stats so coverage,
|
||
// compare and the calc all analyse the form, not the base.
|
||
return {
|
||
...base,
|
||
_slotForm: s.form,
|
||
name: `${base.name} (${f.name})`,
|
||
spriteId: f.id,
|
||
types: f.types || base.types,
|
||
pastTypes: [],
|
||
stats: f.stats || base.stats,
|
||
bst: f.bst ?? base.bst,
|
||
};
|
||
})
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function renderLineup() {
|
||
const mem = members();
|
||
clear(lineup);
|
||
const style = settings.get().spriteStyle;
|
||
for (const sp of mem) {
|
||
lineup.append(
|
||
el(
|
||
'div',
|
||
{ class: 'lineup__slot' },
|
||
el(
|
||
'button',
|
||
{
|
||
class: 'lineup__x',
|
||
type: 'button',
|
||
'aria-label': `Remove ${sp.name}`,
|
||
onclick: () => {
|
||
removeFromTeam(sp.id, sp._slotForm);
|
||
},
|
||
},
|
||
'✕',
|
||
),
|
||
el(
|
||
'a',
|
||
{ href: `#/pokemon/${sp.id}`, class: 'lineup__link' },
|
||
Sprite(sp.spriteId ?? sp.id, { style, alt: sp.name, size: 64 }),
|
||
el('span', { class: 'lineup__name' }, sp.name.replace(/-/g, ' ')),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
if (mem.length < MAX_TEAM) {
|
||
lineup.append(
|
||
el(
|
||
'button',
|
||
{
|
||
class: 'lineup__add',
|
||
type: 'button',
|
||
onclick: () => openPokemonPicker((id) => addToTeam(id), { closeAfterPick: true }),
|
||
},
|
||
'+',
|
||
el('span', {}, 'Add'),
|
||
),
|
||
);
|
||
}
|
||
if (mem.length) {
|
||
lineup.append(
|
||
el(
|
||
'button',
|
||
{ class: 'lineup__clear', type: 'button', onclick: () => clearTeam() },
|
||
'Clear',
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
function render() {
|
||
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;
|
||
}
|
||
body.append(mode === 'coverage' ? coverage(mem) : compare(mem));
|
||
}
|
||
|
||
function coverage(mem) {
|
||
const st = settings.get();
|
||
const vg = snap.versionGroupByKey.get(st.versionGroup);
|
||
const gen = vg ? vg.generation : 9;
|
||
const gameLabel = st.versionGroup === 'all' ? 'all games' : prettify(st.versionGroup);
|
||
const style = st.spriteStyle;
|
||
|
||
// Only the types that exist in the selected game (no Fairy before Gen 6,
|
||
// no Steel/Dark before Gen 2). Member typings are era-accurate too —
|
||
// pre-Gen-6 Clefairy is Normal, Gen 1 Magnemite is pure Electric.
|
||
const GEN_TYPES = TYPES.filter((t) => {
|
||
if (t === 'fairy' && gen < 6) return false;
|
||
if ((t === 'steel' || t === 'dark') && gen < 2) return false;
|
||
return true;
|
||
});
|
||
const memTypes = mem.map((sp) => typesForGen(sp, gen));
|
||
const vectors = memTypes.map((types) => defenseVector(types, gen));
|
||
|
||
// Per attacking type: which members are weak, how many resist.
|
||
const analysis = GEN_TYPES.map((t) => {
|
||
const weak = [];
|
||
let resist = 0;
|
||
vectors.forEach((v, i) => {
|
||
const m = v[t] ?? 1;
|
||
if (m >= 2) weak.push({ sp: mem[i], m });
|
||
else if (m < 1) resist += 1;
|
||
});
|
||
return { type: t, weak, weakN: weak.length, resist };
|
||
});
|
||
|
||
// A "weak spot": 2+ members weak, or someone weak and nobody resists it.
|
||
const threats = analysis
|
||
.filter((a) => a.weakN >= 2 || (a.weakN >= 1 && a.resist === 0))
|
||
.sort((a, b) => b.weakN - a.weakN || a.resist - b.resist);
|
||
const unresisted = analysis.filter((a) => a.weakN === 0 && a.resist === 0).map((a) => a.type);
|
||
|
||
const weakSpots = threats.length
|
||
? el(
|
||
'div',
|
||
{ class: 'weakspots' },
|
||
...threats.map((a) =>
|
||
el(
|
||
'div',
|
||
{ class: `weakspot${a.resist === 0 ? ' is-nores' : ''}` },
|
||
TypeChip(a.type),
|
||
el('span', { class: 'weakspot__count' }, `${a.weakN} weak`),
|
||
a.resist === 0
|
||
? el('span', { class: 'weakspot__flag' }, '0 resist')
|
||
: el('span', { class: 'weakspot__res' }, `${a.resist} resist`),
|
||
el(
|
||
'span',
|
||
{ class: 'weakspot__mem' },
|
||
...a.weak.map(({ sp, m }) =>
|
||
el(
|
||
'a',
|
||
{
|
||
class: 'weakspot__chip',
|
||
href: `#/pokemon/${sp.id}`,
|
||
title: `${prettify(sp.name)} takes ${m}×`,
|
||
},
|
||
Sprite(sp.spriteId ?? sp.id, { style, size: 26, alt: sp.name }),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
)
|
||
: el('p', { class: 'cov__ok' }, '✓ Nothing hits two or more of your team super-effectively.');
|
||
|
||
// Defensive grid: a diverging bar (resists ◀ | ▶ weaknesses) makes the
|
||
// rows to worry about jump out; threat rows get a tinted background.
|
||
const head = el(
|
||
'div',
|
||
{ class: 'cov__row cov__row--head' },
|
||
el('span', { class: 'cov__type' }, ''),
|
||
...mem.map((sp) =>
|
||
el(
|
||
'a',
|
||
{ class: 'cov__mem', href: `#/pokemon/${sp.id}`, title: prettify(sp.name) },
|
||
Sprite(sp.spriteId ?? sp.id, { style, size: 34, alt: sp.name }),
|
||
),
|
||
),
|
||
el('span', { class: 'cov__bal cov__bal--head' }, 'resist · weak'),
|
||
);
|
||
const rows = GEN_TYPES.map((t) => {
|
||
const a = analysis.find((x) => x.type === t);
|
||
const cells = vectors.map((v) => {
|
||
const m = v[t] ?? 1;
|
||
return el('span', { class: `cov__cell ${multClass(m)}` }, multText(m));
|
||
});
|
||
const bal = el(
|
||
'span',
|
||
{ class: 'cov__bal' },
|
||
el(
|
||
'span',
|
||
{ class: 'cov__bal-side cov__bal-side--l' },
|
||
el('span', { class: 'cov__bal-res', style: `width:${Math.min(a.resist, 6) * 10}px` }),
|
||
),
|
||
el(
|
||
'span',
|
||
{ class: 'cov__bal-side cov__bal-side--r' },
|
||
el(
|
||
'span',
|
||
{ class: 'cov__bal-weak', style: `width:${Math.min(a.weakN, 6) * 10}px` },
|
||
a.weakN || '',
|
||
),
|
||
),
|
||
);
|
||
return el(
|
||
'div',
|
||
{ class: `cov__row${a.weakN >= 2 ? ' cov__row--threat' : ''}` },
|
||
el('span', { class: 'cov__type' }, TypeChip(t)),
|
||
...cells,
|
||
bal,
|
||
);
|
||
});
|
||
|
||
// Offensive coverage gaps: defending types no member hits super-effectively
|
||
const gaps = GEN_TYPES.filter(
|
||
(def) => !mem.some((sp, i) => memTypes[i].some((atk) => multiplier(atk, [def], gen) >= 2)),
|
||
);
|
||
|
||
// Quick team stats
|
||
const avgBst = Math.round(mem.reduce((s, m) => s + (m.bst || 0), 0) / mem.length);
|
||
const fastest = mem.slice().sort((a, b) => (b.stats?.spe || 0) - (a.stats?.spe || 0))[0];
|
||
const bulkiest = mem
|
||
.slice()
|
||
.sort(
|
||
(a, b) =>
|
||
(b.stats?.hp || 0) +
|
||
(b.stats?.def || 0) +
|
||
(b.stats?.spd || 0) -
|
||
((a.stats?.hp || 0) + (a.stats?.def || 0) + (a.stats?.spd || 0)),
|
||
)[0];
|
||
|
||
return el(
|
||
'div',
|
||
{},
|
||
el('h2', { class: 'ppanel__sub' }, `Weak spots · ${gameLabel} (Gen ${gen})`),
|
||
weakSpots,
|
||
unresisted.length
|
||
? el(
|
||
'p',
|
||
{ class: 'cov__unres' },
|
||
el('span', { class: 'cov__unres-k' }, 'No resist to: '),
|
||
el('span', { class: 'matchups__types' }, ...unresisted.map(TypeChip)),
|
||
)
|
||
: null,
|
||
el('h2', { class: 'ppanel__sub' }, 'Full matchup grid'),
|
||
el('div', { class: 'cov' }, head, ...rows),
|
||
el('h2', { class: 'ppanel__sub' }, 'Offensive gaps (no super-effective STAB)'),
|
||
gaps.length
|
||
? el('div', { class: 'matchups__types' }, ...gaps.map(TypeChip))
|
||
: el('p', { class: 'detail__muted' }, 'Your team hits every type super-effectively. Nice.'),
|
||
el('h2', { class: 'ppanel__sub' }, 'At a glance'),
|
||
el(
|
||
'dl',
|
||
{ class: 'pfacts' },
|
||
fact('Average BST', String(avgBst)),
|
||
fact('Fastest', monLink(fastest, `${fastest.stats?.spe ?? '?'} Spe`)),
|
||
fact(
|
||
'Bulkiest',
|
||
monLink(
|
||
bulkiest,
|
||
`${(bulkiest.stats?.hp || 0) + (bulkiest.stats?.def || 0) + (bulkiest.stats?.spd || 0)} HP+Def+SpD`,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
function monLink(sp, tail) {
|
||
return el(
|
||
'span',
|
||
{},
|
||
el('a', { class: 'link', href: `#/pokemon/${sp.id}` }, prettify(sp.name)),
|
||
` (${tail})`,
|
||
);
|
||
}
|
||
|
||
function compare(mem) {
|
||
const vg = snap.versionGroupByKey.get(settings.get().versionGroup);
|
||
const gen = vg ? vg.generation : 9;
|
||
return el(
|
||
'div',
|
||
{ class: 'teamcompare' },
|
||
el(
|
||
'p',
|
||
{ class: 'teamcompare__note' },
|
||
'Your six side by side. ',
|
||
el('a', { class: 'link', href: '#/compare' }, 'Compare any Pokémon →'),
|
||
),
|
||
CompareTable(mem, { gen, style: settings.get().spriteStyle }),
|
||
);
|
||
}
|
||
|
||
// ---- 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.spriteId ?? 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.spriteId ?? 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 aren’t modeled.',
|
||
),
|
||
);
|
||
}
|
||
|
||
render();
|
||
const off = team.subscribe(render);
|
||
// Re-render on any settings change: sprite style feeds the lineup, and the
|
||
// selected game drives the coverage generation + era typings.
|
||
const offSettings = settings.subscribe(render);
|
||
onTeardown(view, () => {
|
||
off();
|
||
offSettings();
|
||
});
|
||
return view;
|
||
}
|
||
|
||
function fact(k, v) {
|
||
return el('div', { class: 'fact' }, el('dt', {}, k), el('dd', {}, v));
|
||
}
|