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
This commit is contained in:
parent
1adc06d11f
commit
bdc21fc4e6
11
README.md
11
README.md
@ -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,
|
||||
|
||||
@ -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
46
src/lib/damage-calc.js
Normal 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 85–100% 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 };
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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 aren’t modeled.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
render();
|
||||
const off = team.subscribe(render);
|
||||
// Re-render on any settings change: sprite style feeds the lineup, and the
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user