// attr-panel.jsx — Painel lateral persistente de atributos do personagem // Expõe: window.CHAR_ATTRS (store) + window.AttrPanel (componente React) // // Revisão visual + interativa: estética grimório (pergaminho/gilt/Cinzel), // colapsável com handle vertical, breakdowns em hover, animação numérica, // tooltips de fórmula, e melhor feedback de compra/rolagem. const ATTRS = ["FOR", "DES", "CON", "INT", "SAB", "CAR"]; const ATTR_LABELS = { FOR: "Força", DES: "Destreza", CON: "Constituição", INT: "Inteligência", SAB: "Sabedoria", CAR: "Carisma", }; const STORAGE_KEY = "arton_char_attrs"; const COLLAPSED_KEY = "arton_attr_collapsed"; const BUY_BUDGET = 10; // custos T20 (valor → custo em pontos) const BUY_COSTS = { "-2": -2, "-1": -1, "0": 0, "1": 1, "2": 2, "3": 4, "4": 7 }; function _defaultState() { return { method: "buy", base_buy: { FOR: 0, DES: 0, CON: 0, INT: 0, SAB: 0, CAR: 0 }, base_roll: { FOR: 0, DES: 0, CON: 0, INT: 0, SAB: 0, CAR: 0 }, rolls: null, rollAssignments: {}, }; } function _loadFromStorage() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return _defaultState(); return { ..._defaultState(), ...JSON.parse(raw) }; } catch { return _defaultState(); } } function _readRace() { try { const raw = localStorage.getItem("arton_char_race"); return raw ? JSON.parse(raw) : null; } catch { return null; } } function _readOrigin() { try { const raw = localStorage.getItem("arton_char_origin"); return raw ? JSON.parse(raw) : null; } catch { return null; } } function _attrAbbrevFromName(name) { if (!name) return null; const k = String(name).toLowerCase(); if (k.startsWith("for")) return "FOR"; if (k.startsWith("des")) return "DES"; if (k.startsWith("con")) return "CON"; if (k.startsWith("int")) return "INT"; if (k.startsWith("sab")) return "SAB"; if (k.startsWith("car")) return "CAR"; return null; } function _parseBonusInt(bonus) { if (typeof bonus === "number") return bonus; if (!bonus) return 0; const m = String(bonus).match(/-?\d+/); return m ? parseInt(m[0], 10) : 0; } function _raceAttrMods() { const stored = _readRace(); if (!stored?.id) return {}; const full = (window.RACES || []).find((r) => r.id === stored.id); if (!full?.attr_modifiers) return {}; const out = {}; for (const [k, v] of Object.entries(full.attr_modifiers)) { if (k === "variable") continue; if (typeof v !== "number" || v === 0) continue; const abbrev = _attrAbbrevFromName(k); if (abbrev) out[abbrev] = (out[abbrev] || 0) + v; } /* Bônus da regra "Escolha X atributos +Y" (variable_picks salvos pelo race-creator). */ const variableRule = full.attr_modifiers.variable; const picks = Array.isArray(stored.variable_picks) ? stored.variable_picks : null; if (variableRule && variableRule.amount && picks) { for (const attr of picks) { if (!attr) continue; const abbrev = _attrAbbrevFromName(attr); if (abbrev) out[abbrev] = (out[abbrev] || 0) + variableRule.amount; } } return out; } function _originAttrMods() { const stored = _readOrigin(); if (!stored?.id) return {}; const full = (window.ORIGINS || []).find((o) => o.id === stored.id); if (!full?.benefits) return {}; const out = {}; const picks = Array.isArray(stored.picks) ? stored.picks : []; for (let i = 0; i < full.benefits.length; i++) { const b = full.benefits[i]; if (b?.type !== "attribute") continue; const isAuto = !b.choice; const isPicked = picks.includes(i); if (!isAuto && !isPicked) continue; const abbrev = _attrAbbrevFromName(b.name); if (!abbrev) continue; out[abbrev] = (out[abbrev] || 0) + _parseBonusInt(b.bonus); } return out; } function _computeTotals(state) { const raceMods = _raceAttrMods(); const originMods = _originAttrMods(); const base = state.method === "buy" ? state.base_buy : state.base_roll; const totals = {}; for (const a of ATTRS) { totals[a] = (base[a] || 0) + (raceMods[a] || 0) + (originMods[a] || 0); } return totals; } function _computePointsSpent(buyBase) { return ATTRS.reduce((sum, a) => sum + (BUY_COSTS[String(buyBase[a])] ?? 0), 0); } (function createStore() { let state = _loadFromStorage(); const listeners = new Set(); function persist() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch {} } function getState() { return { method: state.method, base_buy: { ...state.base_buy }, base_roll: { ...state.base_roll }, rolls: state.rolls ? [...state.rolls] : null, rollAssignments: { ...state.rollAssignments }, totals: _computeTotals(state), pointsSpent: _computePointsSpent(state.base_buy), }; } function notify() { const snapshot = getState(); listeners.forEach((fn) => { try { fn(snapshot); } catch (e) { console.error(e); } }); } function setBase(attr, val) { if (!ATTRS.includes(attr)) return; if (val < -2 || val > 4) return; if (state.method === "buy") { /* Floor T20: total (base + raça + origem) não pode ficar abaixo de -2. Ao reduzir, cada passo já gera refund de orçamento via BUY_COSTS. */ const raceMods = _raceAttrMods(); const originMods = _originAttrMods(); const total = val + (raceMods[attr] || 0) + (originMods[attr] || 0); if (total < -2) return; const next = { ...state.base_buy, [attr]: val }; if (_computePointsSpent(next) > BUY_BUDGET) return; state.base_buy = next; } else { state.base_roll = { ...state.base_roll, [attr]: val }; } persist(); notify(); } function setMethod(m) { if (m !== "buy" && m !== "roll") return; state.method = m; persist(); notify(); } function setRolls(arr) { state.rolls = arr; state.rollAssignments = {}; state.base_roll = { FOR: 0, DES: 0, CON: 0, INT: 0, SAB: 0, CAR: 0 }; persist(); notify(); } function assignRoll(attr, rollIdx) { if (!ATTRS.includes(attr) || !state.rolls) return; const prev = { ...state.rollAssignments }; for (const k of Object.keys(prev)) { if (prev[k] === rollIdx) delete prev[k]; } if (rollIdx === null || rollIdx === undefined) delete prev[attr]; else prev[attr] = rollIdx; state.rollAssignments = prev; const nextBase = { FOR: 0, DES: 0, CON: 0, INT: 0, SAB: 0, CAR: 0 }; for (const a of ATTRS) { const idx = prev[a]; if (idx !== undefined) nextBase[a] = state.rolls[idx]; } state.base_roll = nextBase; persist(); notify(); } function reset() { state = _defaultState(); persist(); notify(); } function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); } window.CHAR_ATTRS = { getState, setBase, setMethod, setRolls, assignRoll, reset, subscribe, ATTRS, BUY_BUDGET, BUY_COSTS, }; window.addEventListener("arton:char-changed", notify); if (window.DATA_READY && typeof window.DATA_READY.then === "function") { window.DATA_READY.then(notify, () => {}); } })(); // --------------------------------------------------------------------------- // Hooks // --------------------------------------------------------------------------- function useCharAttrs() { const [state, setState] = React.useState(() => window.CHAR_ATTRS.getState()); React.useEffect(() => window.CHAR_ATTRS.subscribe(setState), []); return state; } // AnimatedNumber — anima transição entre dois inteiros com um pulse. function AnimatedNumber({ value, format = (v) => v }) { const prev = React.useRef(value); const [pulse, setPulse] = React.useState(null); React.useEffect(() => { if (prev.current !== value) { setPulse(value > prev.current ? "up" : "down"); prev.current = value; const t = setTimeout(() => setPulse(null), 400); return () => clearTimeout(t); } }, [value]); return {format(value)}; } // --------------------------------------------------------------------------- // AttrCard — Card de um único atributo, com hover-breakdown // --------------------------------------------------------------------------- function AttrCard({ attr, total, base, raceMod, originMod, method }) { const fmt = (n) => (n >= 0 ? `+${n}` : `${n}`); const tone = total > 0 ? "pos" : total < 0 ? "neg" : "neutral"; const ref = React.useRef(null); const [pos, setPos] = React.useState(null); // Recomputa a posição do popover quando ativo (caso o painel role). function computePos() { const el = ref.current; if (!el) return null; const r = el.getBoundingClientRect(); const popH = 150; const popW = 230; // 200 content + 2 border + 24 padding + safety const margin = 12; const above = r.top - margin - popH > 8; // centro horizontal preferido — depois cortamos pra caber no viewport let cx = r.left + r.width / 2; const halfW = popW / 2; if (cx + halfW > window.innerWidth - margin) cx = window.innerWidth - margin - halfW; if (cx - halfW < margin) cx = margin + halfW; return { left: cx, top: above ? r.top - margin : r.bottom + margin, // deslocamento da setinha (em px relativos ao centro do popover) — pra que aponte para o card mesmo após o clamp arrowOffset: r.left + r.width / 2 - cx, placement: above ? "above" : "below", }; } function activate() { setPos(computePos()); } function deactivate() { setPos(null); } React.useEffect(() => { if (!pos) return; function onScrollOrResize() { setPos(computePos()); } window.addEventListener("scroll", onScrollOrResize, true); window.addEventListener("resize", onScrollOrResize); return () => { window.removeEventListener("scroll", onScrollOrResize, true); window.removeEventListener("resize", onScrollOrResize); }; }, [pos != null]); const popover = pos && ReactDOM.createPortal(
{ATTR_LABELS[attr]}
, document.body ); return (
{attr}
{base !== 0 && ( {fmt(base)} )} {raceMod !== 0 && ( {fmt(raceMod)} )} {originMod !== 0 && ( {fmt(originMod)} )} {base === 0 && raceMod === 0 && originMod === 0 && ( )}
{popover}
); } // --------------------------------------------------------------------------- // Buy mode — com barra de orçamento // --------------------------------------------------------------------------- function BuyConfig({ state }) { const { base_buy, pointsSpent, totals } = state; const remaining = window.CHAR_ATTRS.BUY_BUDGET - pointsSpent; function canIncrement(attr) { const cur = base_buy[attr]; if (cur >= 4) return false; const nextCost = window.CHAR_ATTRS.BUY_COSTS[String(cur + 1)]; const curCost = window.CHAR_ATTRS.BUY_COSTS[String(cur)]; return remaining + curCost - nextCost >= 0; } /* Não permite reduzir abaixo do floor -2 no total (incl. mods de raça/origem). */ function canDecrement(attr) { const cur = base_buy[attr]; if (cur <= -2) return false; /* O total atual já é (cur + raceMod + originMod). Próximo total = total-1. */ return (totals?.[attr] ?? cur) - 1 >= -2; } const fmt = (n) => (n >= 0 ? `+${n}` : `${n}`); const pct = (pointsSpent / window.CHAR_ATTRS.BUY_BUDGET) * 100; return (
{Array.from({ length: window.CHAR_ATTRS.BUY_BUDGET - 1 }).map((_, i) => ( ))}
orçamento {pointsSpent} / {window.CHAR_ATTRS.BUY_BUDGET}
{window.CHAR_ATTRS.ATTRS.map((a) => { const cur = base_buy[a]; const nextCost = window.CHAR_ATTRS.BUY_COSTS[String(cur + 1)]; return ( ); })}
{a} {fmt(cur)} {cur < 4 ? `→${nextCost}pt` : "máx"}
); } // --------------------------------------------------------------------------- // Derivados (PV, PM, Defesa, CD, Perícias) // --------------------------------------------------------------------------- function _readClasses() { try { const raw = localStorage.getItem("arton_char_class"); if (!raw) return null; const parsed = JSON.parse(raw); return parsed?.classes || null; } catch { return null; } } function _findClassDef(name) { return (window.CLASSES || []).find((c) => c.name === name) || null; } function _castingAttrFor(classDef, entry) { const meta = classDef?.path_metadata; if (!meta || !entry?.path) return classDef?.casting_attribute; const opt = (meta.options || []).find((o) => o.id === entry.path); return opt?.casting_attribute || classDef?.casting_attribute; } function _attrAbbrev(name) { if (!name) return null; const k = String(name).toLowerCase(); if (k.startsWith("for")) return "FOR"; if (k.startsWith("des")) return "DES"; if (k.startsWith("con")) return "CON"; if (k.startsWith("int")) return "INT"; if (k.startsWith("sab")) return "SAB"; if (k.startsWith("car")) return "CAR"; return null; } function computeDerivatives(state, classes) { const totals = state.totals; if (!classes || classes.length === 0) { return { pv: 0, pm: 0, defesa: 10 + (totals.DES || 0), cds: [], skills: null, totalLevel: 0, pvFormula: null, pmFormula: null }; } const totalLevel = classes.reduce((s, c) => s + (c.level || 1), 0); const initial = classes[0]; const initialDef = _findClassDef(initial.className); /* PV T20: classe inicial dá pv_initial + (nível-1) × pv_per_level. Classes adicionais (multiclasse) dão pv_per_level × níveis (sem o bloco inicial). CON aplica em todos os níveis. hp_die é só pra regeneração em descansos. */ const initialBase = initialDef?.pv_initial || 0; const initialPerLvl = initialDef?.pv_per_level || 0; let pv = initialBase + ((initial.level || 1) - 1) * initialPerLvl; for (let i = 1; i < classes.length; i++) { const def = _findClassDef(classes[i].className); const perLvl = def?.pv_per_level || 0; pv += (classes[i].level || 1) * perLvl; } pv += (totals.CON || 0) * totalLevel; let pm = 0; for (const c of classes) { const def = _findClassDef(c.className); pm += (def?.pm_per_level || 0) * (c.level || 1); } const defesa = 10 + (totals.DES || 0); const cds = []; for (const c of classes) { const def = _findClassDef(c.className); if (!def?.is_caster) continue; const attrName = _castingAttrFor(def, c); const abbrev = _attrAbbrev(attrName); if (!abbrev) continue; const mod = totals[abbrev] || 0; cds.push({ className: c.className, attr: abbrev, value: 10 + Math.floor((c.level || 1) / 2) + mod }); } const initialSkillPoints = initialDef?.skill_points || 0; const skills = initialSkillPoints + Math.max(0, totals.INT || 0); const skillsBase = initialSkillPoints; const skillsIntBonus = Math.max(0, totals.INT || 0); const pvFormula = `${initialBase} + ${(initial.level||1)-1}×${initialPerLvl} + CON×${totalLevel}`; const pmFormula = classes.map((c) => { const def = _findClassDef(c.className); return `${def?.pm_per_level || 0}×${c.level || 1}`; }).join(" + "); return { pv, pm, defesa, cds, skills, skillsBase, skillsIntBonus, totalLevel, pvFormula, pmFormula }; } function rollFourDropLowest() { const dice = [0, 0, 0, 0].map(() => 1 + Math.floor(Math.random() * 6)); dice.sort((a, b) => a - b); return dice[1] + dice[2] + dice[3]; } function sumToMod(sum) { if (sum <= 7) return -2; if (sum <= 9) return -1; if (sum <= 11) return 0; if (sum <= 13) return 1; if (sum <= 15) return 2; if (sum <= 17) return 3; return 4; } // --------------------------------------------------------------------------- // Roll mode // --------------------------------------------------------------------------- function RollConfig({ state }) { const { rolls, rollAssignments } = state; const [selectedChip, setSelectedChip] = React.useState(null); function doRoll() { if (rolls && Object.keys(rollAssignments).length > 0) { if (!confirm("Descartar atribuições atuais e rolar de novo?")) return; } const mods = [0, 0, 0, 0, 0, 0].map(() => sumToMod(rollFourDropLowest())); window.CHAR_ATTRS.setRolls(mods); setSelectedChip(null); } function clickChip(idx) { setSelectedChip(selectedChip === idx ? null : idx); } function clickAttr(attr) { if (selectedChip === null) { if (rollAssignments[attr] !== undefined) { window.CHAR_ATTRS.assignRoll(attr, null); } return; } window.CHAR_ATTRS.assignRoll(attr, selectedChip); setSelectedChip(null); } const usedIdxs = new Set(Object.values(rollAssignments)); const fmt = (n) => (n >= 0 ? `+${n}` : `${n}`); if (!rolls) { return (

Rola 4d6 (descarta o menor) seis vezes e converte em modificadores.

); } return (

{selectedChip === null ? "Toque uma rolagem, depois um atributo." : "Agora toque o atributo destino."}

{rolls.map((mod, i) => ( ))}
{window.CHAR_ATTRS.ATTRS.map((a) => { const idx = rollAssignments[a]; const mod = idx !== undefined ? rolls[idx] : null; const filled = mod !== null; return ( ); })}
); } // --------------------------------------------------------------------------- // Conflitos com pré-requisitos // --------------------------------------------------------------------------- function computeConflicts(totals, classes) { if (!classes || !window.POWERS) return []; const pickedIds = new Set(); for (const c of classes) { if (Array.isArray(c.powerPicks)) { for (const id of c.powerPicks) if (id) pickedIds.add(id); } } if (pickedIds.size === 0) return []; const conflicts = []; for (const p of window.POWERS) { if (!pickedIds.has(p.id)) continue; const parsed = p.prerequisites_parsed?.attrs; if (!parsed) continue; const missing = []; for (const [attr, min] of Object.entries(parsed)) { if ((totals[attr] ?? -99) < min) missing.push({ attr, min }); } if (missing.length > 0) conflicts.push({ name: p.name, missing }); } return conflicts; } function ConflictsSection({ conflicts }) { const [collapsed, setCollapsed] = React.useState(false); if (conflicts.length === 0) return null; return (
{!collapsed && ( )}
); } // --------------------------------------------------------------------------- // DerivativesSection // --------------------------------------------------------------------------- function DerivativesSection({ deriv, totals }) { if (deriv.totalLevel === 0) { return (
Derivados

Escolha uma classe para ver PV, PM, CD e perícias.

= 0 ? '+' : ''}${totals.DES})`}> Def
); } const single = deriv.cds.length === 1 ? deriv.cds[0] : null; return (
Derivados nv {deriv.totalLevel}
PV
PM
= 0 ? '+' : ''}${totals.DES})`}> Def
{single && (
CD
)}
{deriv.cds.length > 1 && ( )} {deriv.skills !== null && (
0 ? '+' + deriv.skillsIntBonus : '+0'}`}> Perícias treinadas
)}
); } // --------------------------------------------------------------------------- // Painel principal // --------------------------------------------------------------------------- function AttrPanel() { const state = useCharAttrs(); const [configOpen, setConfigOpen] = React.useState(true); const [collapsed, setCollapsed] = React.useState(() => { try { return localStorage.getItem(COLLAPSED_KEY) === "1"; } catch { return false; } }); // Persistir colapso + propagar p/ CSS root pra app-shell saber reservar espaço React.useEffect(() => { try { localStorage.setItem(COLLAPSED_KEY, collapsed ? "1" : "0"); } catch {} document.documentElement.setAttribute("data-attr-panel", collapsed ? "collapsed" : "expanded"); }, [collapsed]); // garantir set inicial mesmo antes do useEffect rodar React.useEffect(() => { document.documentElement.setAttribute("data-attr-panel", collapsed ? "collapsed" : "expanded"); }, []); const raceMods = _raceAttrMods(); const originMods = _originAttrMods(); const base = state.method === "buy" ? state.base_buy : state.base_roll; const classes = _readClasses(); const deriv = computeDerivatives(state, classes); const conflicts = computeConflicts(state.totals, classes); const fmt = (n) => (n >= 0 ? `+${n}` : `${n}`); const incompleteRoll = state.method === "roll" && state.rolls && Object.keys(state.rollAssignments).length < 6; return ( ); } window.AttrPanel = AttrPanel;