// 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]}
- base ({method === "buy" ? "compra" : "rolagem"}){fmt(base || 0)}
{raceMod ? - raça{fmt(raceMod)}
: null}
{originMod ? - origem{fmt(originMod)}
: null}
- total{fmt(total)}
,
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 && (
{conflicts.map((c) => (
-
{c.name}
falta {c.missing.map((m) => `${m.attr} ${m.min >= 0 ? "+" : ""}${m.min}`).join(", ")}
))}
)}
);
}
// ---------------------------------------------------------------------------
// 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}
= 0 ? '+' : ''}${totals.DES})`}>
Def
{single && (
)}
{deriv.cds.length > 1 && (
{deriv.cds.map((cd) => (
-
{cd.className}
CD {cd.value}
))}
)}
{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;