From bdc21fc4e61336f8546bb20d159a3c207cef805a Mon Sep 17 00:00:00 2001 From: chris Date: Fri, 4 Sep 2026 11:58:51 -0400 Subject: [PATCH] Team builder: add a damage calculator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu --- README.md | 11 +- src/components/MovesList.js | 2 +- src/lib/damage-calc.js | 46 ++++++ src/styles/layout.css | 174 ++++++++++++++++++++ src/views/TeamView.js | 313 +++++++++++++++++++++++++++++++++++- 5 files changed, 534 insertions(+), 12 deletions(-) create mode 100644 src/lib/damage-calc.js diff --git a/README.md b/README.md index 1e30c0d..10bcf1a 100644 --- a/README.md +++ b/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, diff --git a/src/components/MovesList.js b/src/components/MovesList.js index dd627b1..04df0c8 100644 --- a/src/components/MovesList.js +++ b/src/components/MovesList.js @@ -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, diff --git a/src/lib/damage-calc.js b/src/lib/damage-calc.js new file mode 100644 index 0000000..16ee2dc --- /dev/null +++ b/src/lib/damage-calc.js @@ -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 }; +} diff --git a/src/styles/layout.css b/src/styles/layout.css index bdc6340..a25c4ce 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -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; diff --git a/src/views/TeamView.js b/src/views/TeamView.js index f244313..9342e17 100644 --- a/src/views/TeamView.js +++ b/src/views/TeamView.js @@ -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