Files
Randify.pro/src/components/generators/DiceGenerator.astro
T

959 lines
31 KiB
Plaintext

---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
const notationPlaceholder = isRu
? "напр. 2d6+3, 1d20, 4d6dl1"
: "e.g. 2d6+3, 1d20, 4d6dl1";
const modeNormal = isRu ? "Обычный" : "Normal";
const modeAdvantage = isRu ? "Преимущество" : "Advantage";
const modeDisadvantage = isRu ? "Помеха" : "Disadvantage";
const advOnly = isRu
? "Преимущество работает только с одним кубиком"
: "Advantage only works with a single die";
const historyTitle = isRu ? "История бросков" : "Roll history";
const clearHistory = isRu ? "Очистить историю" : "Clear history";
const presetsLabel = isRu ? "Пресеты" : "Presets";
const presets = [
{ label: isRu ? "Атака" : "Attack", notation: "1d20" },
{ label: isRu ? "Урон" : "Damage", notation: "1d8+3" },
{ label: isRu ? "Огненный шар" : "Fireball", notation: "8d6" },
{ label: isRu ? "Скрытая атака" : "Sneak Attack", notation: "3d6" },
{ label: isRu ? "Характеристика" : "Stat Roll", notation: "4d6dl1" },
{ label: isRu ? "Яростный удар" : "Savage Strike", notation: "2d6!" },
];
---
<div id="dice-generator" class="mt-8">
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5">
<!-- Presets toggle -->
<div class="mb-2">
<button
id="dg-presets-toggle"
type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-zinc-500 hover:text-zinc-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded transition-colors cursor-pointer"
>
<span>{presetsLabel}</span>
<svg
id="dg-presets-arrow"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="transition-transform duration-150"
><path d="m6 9 6 6 6-6"></path></svg
>
</button>
<div id="dg-presets-panel" class="hidden mt-2">
<div
class="flex flex-wrap gap-2"
role="group"
aria-label={isRu ? "Быстрые пресеты" : "Quick presets"}
>
{
presets.map((p) => (
<button
type="button"
class="dg-preset px-3 py-1.5 rounded-lg border border-zinc-700 text-sm font-medium text-zinc-400 hover:border-accent hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors cursor-pointer"
data-preset={p.notation}
>
{p.label}
</button>
))
}
</div>
</div>
</div>
<!-- Notation input -->
<div class="mb-4">
<input
id="dg-notation"
type="text"
placeholder={notationPlaceholder}
autocomplete="off"
spellcheck="false"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors"
/>
<p id="dg-notation-err" class="mt-1 text-xs text-red-500 hidden"></p>
</div>
<!-- Count + Sides -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="dg-count" class="block text-sm font-medium text-zinc-400 mb-1"
>{T.numberOfDice}</label
>
<input
id="dg-count"
type="number"
value="2"
min="1"
max="20"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors"
aria-label={T.numberOfDice}
/>
</div>
<div>
<label class="block text-sm font-medium text-zinc-400 mb-1"
>{T.sides}</label
>
<div class="flex flex-wrap gap-2" role="radiogroup" aria-label={T.sides}>
{
["4", "6", "8", "10", "12", "20", "100"].map((s) => (
<label class="cursor-pointer">
<input
type="radio"
name="dg-sides"
value={s}
class="sr-only peer"
checked={s === "6"}
/>
<span class="inline-flex items-center justify-center w-10 h-10 rounded-lg border border-zinc-700 text-sm font-semibold text-zinc-400 peer-checked:border-accent peer-checked:text-accent peer-checked:bg-accent/10 hover:border-zinc-500 transition-colors select-none">
d{s}
</span>
</label>
))
}
</div>
</div>
</div>
<!-- Advantage / Disadvantage toggle -->
<div class="mt-4">
<label class="block text-sm font-medium text-zinc-400 mb-1.5"
>{isRu ? "Режим" : "Mode"}</label
>
<div
id="dg-mode-wrap"
class="inline-flex rounded-lg border border-zinc-700 overflow-hidden transition-opacity"
role="radiogroup"
aria-label={isRu ? "Режим" : "Mode"}
>
{
[
{ value: "normal", label: modeNormal },
{ value: "advantage", label: modeAdvantage },
{ value: "disadvantage", label: modeDisadvantage },
].map(({ value, label }, i) => (
<label class="cursor-pointer">
<input
type="radio"
name="dg-mode"
value={value}
class="sr-only peer"
checked={value === "normal"}
/>
<span
class={`inline-flex items-center justify-center px-3 sm:px-4 h-9 text-sm font-medium text-zinc-400 peer-checked:text-accent peer-checked:bg-accent/10 hover:text-zinc-200 transition-colors select-none whitespace-nowrap${i < 2 ? " border-r border-zinc-700" : ""}`}
>
{label}
</span>
</label>
))
}
</div>
<p id="dg-adv-hint" class="mt-1 text-xs text-zinc-600 hidden">{advOnly}</p>
</div>
<!-- Roll error -->
<p
id="dg-error"
role="alert"
aria-live="polite"
class="mt-3 text-sm text-red-500 hidden"
>
</p>
</div>
<!-- Results -->
<div class="my-6 h-52 overflow-hidden flex flex-col items-center justify-center gap-3 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8">
<div
id="dg-dice"
class="flex flex-wrap justify-center gap-2"
aria-live="polite"
aria-label="Dice results"
>
</div>
<div
id="dg-adv-info"
class="hidden text-sm font-mono text-zinc-400 text-center px-2"
>
</div>
<div
id="dg-modifier-line"
class="hidden text-sm text-zinc-500 tabular-nums"
>
</div>
<div id="dg-total-wrap" class="hidden flex items-center gap-2">
<span class="text-sm text-zinc-500">{T.total}</span>
<span id="dg-total" class="text-2xl font-bold tabular-nums text-zinc-100"
></span>
</div>
<button
id="dg-copy-btn"
type="button"
aria-label="Copy results to clipboard"
class="invisible inline-flex items-center gap-1.5 text-xs font-medium text-zinc-500 hover:text-zinc-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded"
>
<span id="dg-copy-label">{T.copy}</span>
<span id="dg-copy-icon" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
></path>
</svg>
</span>
<span id="dg-check-icon" class="hidden text-accent" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5"></path>
</svg>
</span>
</button>
</div>
<!-- Roll button -->
<div class="flex justify-center">
<button
id="dg-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{T.roll}
</button>
</div>
<!-- Roll history -->
<div id="dg-history-panel" class="hidden mt-10 pt-6 border-t border-zinc-800">
<div class="flex items-center justify-between mb-3">
<h3 class="text-xs font-semibold uppercase tracking-widest text-zinc-600">
{historyTitle}
</h3>
<button
id="dg-clear-btn"
type="button"
class="text-xs text-zinc-600 hover:text-zinc-300 transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded"
>
{clearHistory}
</button>
</div>
<div id="dg-history-list" class="space-y-1"></div>
</div>
</div>
<!-- Script section appended to DiceGenerator.astro -->
<script type="module">
// ═══ Inline Dice Engine ═══════════════════════════════════════════════════════
const EXPLODE_CAP = 100;
const REROLL_CAP = 1000;
function rollDie(sides, explode, reroll) {
let value = Math.floor(Math.random() * sides) + 1;
const original = value;
let rerolledFrom = null;
const explosions = [];
let exploded = false;
if (reroll.active && reroll.values.has(value)) {
rerolledFrom = value;
let rc = 0;
while (reroll.values.has(value) && rc < REROLL_CAP) {
value = Math.floor(Math.random() * sides) + 1;
rc++;
if (reroll.once) break;
}
}
const threshold = explode.threshold ?? sides;
if (explode.active && value >= threshold) {
exploded = true;
let chain = 0;
while (chain < EXPLODE_CAP) {
let next = Math.floor(Math.random() * sides) + 1;
if (explode.penetrating) next = Math.max(1, next - 1);
explosions.push(next);
if (next < threshold) break;
chain++;
}
}
return {
value,
original,
exploded,
explosions,
rerolledFrom,
dropped: false,
};
}
function parseDiceNotation(raw) {
const s = raw.replace(/\s+/g, "").toLowerCase();
if (!s) return null;
const main = s.match(/^(\d*)d(\d+)(.*)$/);
if (!main) return null;
const count = main[1] === "" ? 1 : parseInt(main[1], 10);
const sides = parseInt(main[2], 10);
let remaining = main[3];
if (count < 1 || count > 20 || sides < 1 || sides > 9999) return null;
let keepDrop = null;
const kd = remaining.match(/^(kh|kl|dh|dl)(\d+)/);
if (kd) {
const kc = parseInt(kd[2], 10);
if (kc < 1 || kc >= count) return null;
keepDrop = { type: kd[1], count: kc };
remaining = remaining.slice(kd[0].length);
}
const explode = { active: false, threshold: null, penetrating: false };
const em = remaining.match(/^(!p|!)(>?)(\d*)/);
if (em) {
explode.active = true;
explode.penetrating = em[1] === "!p";
if (em[2] === ">" && em[3]) {
explode.threshold = parseInt(em[3], 10);
if (explode.threshold < 2 || explode.threshold > sides) return null;
}
remaining = remaining.slice(em[0].length);
}
const reroll = {
active: false,
values: new Set(),
once: false,
operator: "eq",
};
const rm = remaining.match(/^(ro|r)([<>]?)(\d+)/);
if (rm) {
reroll.active = true;
reroll.once = rm[1] === "ro";
const op = rm[2],
val = parseInt(rm[3], 10);
if (op === "<") {
reroll.operator = "lt";
for (let i = 1; i < val && i < sides; i++) reroll.values.add(i);
} else if (op === ">") {
reroll.operator = "gt";
for (let i = val + 1; i <= sides; i++) reroll.values.add(i);
} else {
reroll.operator = "eq";
reroll.values.add(val);
}
remaining = remaining.slice(rm[0].length);
}
let modifier = 0;
const mm = remaining.match(/^([+-]\d+)$/);
if (mm) {
modifier = parseInt(mm[1], 10);
if (Math.abs(modifier) > 999) return null;
remaining = remaining.slice(mm[0].length);
}
if (remaining.length > 0) return null;
return {
count,
sides,
modifier,
keepDrop,
explode,
reroll,
advantage: false,
disadvantage: false,
};
}
function rollDice(parsed) {
const dice = [];
for (let i = 0; i < parsed.count; i++)
dice.push(rollDie(parsed.sides, parsed.explode, parsed.reroll));
let kept = [...dice];
let dropped = [];
if (parsed.keepDrop) {
const indexed = dice.map((d, i) => ({ die: d, idx: i }));
if (parsed.keepDrop.type === "kh")
indexed.sort((a, b) => b.die.value - a.die.value);
else if (parsed.keepDrop.type === "kl")
indexed.sort((a, b) => a.die.value - b.die.value);
else if (parsed.keepDrop.type === "dh")
indexed.sort((a, b) => b.die.value - a.die.value);
else if (parsed.keepDrop.type === "dl")
indexed.sort((a, b) => a.die.value - b.die.value);
const keepN = parsed.keepDrop.type.startsWith("k")
? parsed.keepDrop.count
: parsed.count - parsed.keepDrop.count;
const keepSet = new Set(indexed.slice(0, keepN).map((x) => x.idx));
kept = dice.filter((_, i) => keepSet.has(i));
dropped = dice.filter((_, i) => !keepSet.has(i));
}
dropped.forEach((d) => (d.dropped = true));
let total = kept.reduce((s, d) => s + d.value, 0);
total += kept.reduce(
(s, d) => s + d.explosions.reduce((ss, v) => ss + v, 0),
0,
);
total += parsed.modifier;
return {
dice,
kept,
dropped,
modifier: parsed.modifier,
total,
notation: "",
advantageRolls: null,
};
}
function rollAdvantage(sides, modifier, advantage) {
const r1 = Math.floor(Math.random() * sides) + 1;
const r2 = Math.floor(Math.random() * sides) + 1;
const kv = advantage ? Math.max(r1, r2) : Math.min(r1, r2);
const die = {
value: kv,
original: kv,
exploded: false,
explosions: [],
rerolledFrom: null,
dropped: false,
};
return {
dice: [die],
kept: [die],
dropped: [],
modifier,
total: kv + modifier,
notation: "",
advantageRolls: [r1, r2],
};
}
function buildNotation(count, sides, modifier) {
let s = `${count}d${sides}`;
if (modifier > 0) s += `+${modifier}`;
else if (modifier < 0) s += modifier;
return s;
}
// ═══ DOM + Logic ══════════════════════════════════════════════════════════════
const isRu = document.documentElement.lang === "ru";
const COPY_LABEL = isRu ? "Копировать" : "Copy";
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
const ERR_COUNT = isRu
? "Количество кубиков должно быть не менее 1."
: "Number of dice must be at least 1.";
const ERR_MAX = isRu
? "Максимум 20 кубиков одновременно."
: "Maximum 20 dice at once.";
const ERR_NOTATION = isRu
? "Неверный формат — попробуйте 2d6+3"
: "Invalid notation — try 2d6+3";
// refs
const notationInput = document.getElementById("dg-notation");
const notationErr = document.getElementById("dg-notation-err");
const countInput = document.getElementById("dg-count");
const btn = document.getElementById("dg-btn");
const copyBtn = document.getElementById("dg-copy-btn");
const diceEl = document.getElementById("dg-dice");
const advInfoEl = document.getElementById("dg-adv-info");
const modifierLine = document.getElementById("dg-modifier-line");
const totalWrap = document.getElementById("dg-total-wrap");
const totalEl = document.getElementById("dg-total");
const errorEl = document.getElementById("dg-error");
const copyLabel = document.getElementById("dg-copy-label");
const copyIcon = document.getElementById("dg-copy-icon");
const checkIcon = document.getElementById("dg-check-icon");
const modeWrap = document.getElementById("dg-mode-wrap");
const advHint = document.getElementById("dg-adv-hint");
const historyPanel = document.getElementById("dg-history-panel");
const historyList = document.getElementById("dg-history-list");
const clearBtn = document.getElementById("dg-clear-btn");
const presetsToggle = document.getElementById("dg-presets-toggle");
const presetsPanel = document.getElementById("dg-presets-panel");
const presetsArrow = document.getElementById("dg-presets-arrow");
let suppressSync = false;
let lastCopyText = "";
let copyRevertTimer = null;
const HISTORY_KEY = "dg-history2";
function getSelectedSides() {
const r = document.querySelector('input[name="dg-sides"]:checked');
return r ? parseInt(r.value, 10) : 6;
}
function setSelectedSides(sides) {
document.querySelectorAll('input[name="dg-sides"]').forEach((r) => {
r.checked = parseInt(r.value, 10) === sides;
});
}
function getMode() {
const r = document.querySelector('input[name="dg-mode"]:checked');
return r?.value ?? "normal";
}
function setMode(v) {
document.querySelectorAll('input[name="dg-mode"]').forEach((r) => {
if (r.value === v) r.checked = true;
});
}
function showError(msg) {
errorEl.textContent = msg;
errorEl.classList.remove("hidden");
}
function clearError() {
errorEl.textContent = "";
errorEl.classList.add("hidden");
}
function showNotationErr(msg) {
notationErr.textContent = msg;
notationErr.classList.remove("hidden");
}
function clearNotationErr() {
notationErr.textContent = "";
notationErr.classList.add("hidden");
}
function updateModeToggle() {
const parsed = parseDiceNotation(notationInput.value.trim());
const count = parsed ? parsed.count : parseInt(countInput.value, 10);
const hasKeepDrop = parsed?.keepDrop;
const disabled = (!isNaN(count) && count > 1) || hasKeepDrop;
modeWrap.classList.toggle("opacity-40", disabled);
modeWrap.classList.toggle("pointer-events-none", disabled);
advHint.classList.toggle("hidden", !disabled);
}
function makeDieChip(v, sides, extra = "") {
const el = document.createElement("span");
const text = String(v);
const color =
v === sides
? "border-accent text-accent bg-accent/10"
: v === 1
? "border-red-700/50 text-red-400 bg-red-900/10"
: "border-zinc-700 text-zinc-100 bg-zinc-900";
el.className =
`inline-flex items-center justify-center min-w-[2.75rem] h-11 px-2 rounded-lg border text-sm font-bold tabular-nums ${color} ${extra}`.trim();
el.textContent = text;
return el;
}
function dieHtml(v, sides) {
const cls =
v === sides
? "text-accent font-bold"
: v === 1
? "text-red-400 font-bold"
: "text-zinc-300";
return `<span class="${cls}">${v}</span>`;
}
function loadHistory() {
try {
return JSON.parse(sessionStorage.getItem(HISTORY_KEY) ?? "[]");
} catch {
return [];
}
}
function saveHistory(h) {
sessionStorage.setItem(HISTORY_KEY, JSON.stringify(h.slice(0, 10)));
}
function renderHistory() {
const h = loadHistory();
if (h.length === 0) {
historyPanel.classList.add("hidden");
return;
}
historyPanel.classList.remove("hidden");
historyList.innerHTML = h
.map((entry) => {
let html = `<div class="flex items-center gap-1.5 text-xs font-mono py-1 border-b border-zinc-800/40 last:border-b-0">`;
html += `<span class="text-zinc-500 shrink-0 mr-1">${entry.label}</span>`;
html += `<span class="text-zinc-400">${entry.text}</span>`;
html += `</div>`;
return html;
})
.join("");
}
function addToHistory(label, text) {
const h = loadHistory();
h.unshift({ label, text, ts: Date.now() });
saveHistory(h);
renderHistory();
}
function formatDieResult(d, sides) {
let text = String(d.value);
if (d.dropped) {
return { text, cls: "line-through opacity-40 text-zinc-500" };
}
if (d.rerolledFrom !== null) {
text = `${d.rerolledFrom}→${d.value}`;
}
if (d.exploded) {
text += d.explosions.map((v) => `→${v}`).join("");
}
let color =
d.value === sides
? "text-accent"
: d.value === 1
? "text-red-400"
: "text-zinc-100";
if (d.rerolledFrom !== null) color = "text-zinc-300";
return { text, cls: color };
}
function formatHistoryDice(d, sides) {
if (d.dropped) return `<s class="text-zinc-500">${d.value}</s>`;
let out = dieHtml(d.value, sides);
if (d.rerolledFrom !== null) {
out = `~~${d.rerolledFrom}~~→${out}`;
}
if (d.exploded) {
out += d.explosions.map((v) => `→${dieHtml(v, sides)}`).join("");
}
return out;
}
function copyResults() {
if (!lastCopyText) return;
navigator.clipboard.writeText(lastCopyText);
copyIcon.classList.add("hidden");
checkIcon.classList.remove("hidden");
copyLabel.textContent = COPIED_LABEL;
if (copyRevertTimer) clearTimeout(copyRevertTimer);
copyRevertTimer = setTimeout(() => {
checkIcon.classList.add("hidden");
copyIcon.classList.remove("hidden");
copyLabel.textContent = COPY_LABEL;
}, 1500);
}
// ═══ Roll logic ═══════════════════════════════════════════════════════════════
function roll() {
clearError();
const raw = notationInput.value.trim();
let parsed = null;
let notation;
let result;
let isAdv = false,
isDis = false;
if (raw) {
parsed = parseDiceNotation(raw);
if (!parsed) {
showNotationErr(ERR_NOTATION);
return;
}
clearNotationErr();
} else {
const c = parseInt(countInput.value, 10);
const s = getSelectedSides();
if (!Number.isInteger(c) || c < 1) {
showError(ERR_COUNT);
return;
}
if (c > 20) {
showError(ERR_MAX);
return;
}
parsed = {
count: c,
sides: s,
modifier: 0,
keepDrop: null,
explode: { active: false },
reroll: { active: false },
advantage: false,
disadvantage: false,
};
}
// Determine mode from notation or toggle
const mode = getMode();
if (mode === "advantage" || mode === "disadvantage") {
if (parsed.count === 1 && !parsed.keepDrop) {
isAdv = mode === "advantage";
isDis = mode === "disadvantage";
}
}
// Build notation string
notation =
raw || buildNotation(parsed.count, parsed.sides, parsed.modifier);
// Append mode symbols for history if from toggle
if (isAdv && !notation.includes("kh"))
notation = "2d" + parsed.sides + "kh1";
if (isDis && !notation.includes("kl"))
notation = "2d" + parsed.sides + "kl1";
// Reset display
advInfoEl.classList.add("hidden");
advInfoEl.innerHTML = "";
modifierLine.classList.add("hidden");
totalWrap.classList.add("hidden");
diceEl.innerHTML = "";
if (isAdv || isDis) {
result = rollAdvantage(parsed.sides, parsed.modifier, isAdv);
const [r1, r2] = result.advantageRolls;
const winner = isAdv ? Math.max(r1, r2) : Math.min(r1, r2);
const prefix = isAdv ? "↑" : "↓";
const modStr =
parsed.modifier !== 0
? ` ${parsed.modifier > 0 ? "+" : ""}${parsed.modifier} = ${result.total}`
: "";
let w1 = false;
[r1, r2].forEach((v) => {
const isW = v === winner && !w1;
if (isW) w1 = true;
diceEl.appendChild(
makeDieChip(v, parsed.sides, isW ? "" : "opacity-40"),
);
});
advInfoEl.textContent = `${prefix} ${winner}${modStr} (${r1}, ${r2})`;
advInfoEl.classList.remove("hidden");
if (parsed.modifier !== 0) {
totalEl.textContent = String(result.total);
totalWrap.classList.remove("hidden");
}
lastCopyText = `${prefix} ${winner}${modStr} (${r1}, ${r2})`;
addToHistory(notation, `${prefix} ${winner}${modStr} (${r1}, ${r2})`);
} else {
result = rollDice(parsed);
// Render dice chips
result.dice.forEach((d) => {
const fmt = formatDieResult(d, parsed.sides);
const chip = makeDieChip(d.value, parsed.sides, fmt.cls);
if (d.exploded && d.explosions.length > 0) {
chip.textContent = fmt.text;
}
diceEl.appendChild(chip);
});
if (parsed.modifier !== 0) {
modifierLine.textContent =
parsed.modifier > 0 ? `+${parsed.modifier}` : String(parsed.modifier);
modifierLine.classList.remove("hidden");
}
totalEl.textContent = String(result.total);
totalWrap.classList.remove("hidden");
// Build history entry
const diceStr =
"[" +
result.dice.map((d) => formatHistoryDice(d, parsed.sides)).join(", ") +
"]";
const modStr =
parsed.modifier > 0
? ` +${parsed.modifier}`
: parsed.modifier < 0
? ` ${parsed.modifier}`
: "";
const text = `${diceStr}${modStr} = ${result.total}`;
lastCopyText = text;
addToHistory(notation, text);
}
copyBtn.classList.remove("invisible");
copyLabel.textContent = COPY_LABEL;
}
// ═══ Presets toggle ═══════════════════════════════════════════════════════════
function togglePresets() {
const closed = presetsPanel.classList.contains("hidden");
if (closed) {
presetsPanel.classList.remove("hidden");
presetsArrow.style.transform = "rotate(180deg)";
sessionStorage.setItem("dg-presets-open", "1");
} else {
presetsPanel.classList.add("hidden");
presetsArrow.style.transform = "rotate(0deg)";
sessionStorage.setItem("dg-presets-open", "0");
}
}
function initPresets() {
if (sessionStorage.getItem("dg-presets-open") === "1") {
presetsPanel.classList.remove("hidden");
presetsArrow.style.transform = "rotate(180deg)";
}
}
// ═══ Event listeners ══════════════════════════════════════════════════════════
// Notation input → controls sync
notationInput.addEventListener("input", () => {
const raw = notationInput.value.trim();
if (!raw) {
clearNotationErr();
return;
}
const parsed = parseDiceNotation(raw);
if (!parsed) {
showNotationErr(ERR_NOTATION);
return;
}
clearNotationErr();
suppressSync = true;
countInput.value = String(parsed.count);
setSelectedSides(parsed.sides);
suppressSync = false;
updateModeToggle();
});
notationInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
roll();
}
});
// Controls → notation sync
countInput.addEventListener("input", () => {
if (suppressSync) return;
const count = parseInt(countInput.value, 10);
const sides = getSelectedSides();
const parsed = parseDiceNotation(notationInput.value.trim());
const modifier = parsed?.modifier ?? 0;
if (!isNaN(count) && count > 0) {
notationInput.value = buildNotation(count, sides, modifier);
clearNotationErr();
}
updateModeToggle();
});
countInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") roll();
});
document.querySelectorAll('input[name="dg-sides"]').forEach((radio) => {
radio.addEventListener("change", () => {
if (suppressSync) return;
const count = parseInt(countInput.value, 10) || 1;
const sides = getSelectedSides();
const parsed = parseDiceNotation(notationInput.value.trim());
const modifier = parsed?.modifier ?? 0;
notationInput.value = buildNotation(count, sides, modifier);
clearNotationErr();
updateModeToggle();
});
});
// Mode toggle → notation sync
document.querySelectorAll('input[name="dg-mode"]').forEach((radio) => {
radio.addEventListener("change", () => {
const raw = notationInput.value.trim();
const mode = getMode();
if (!raw) return;
const parsed = parseDiceNotation(raw);
if (!parsed) return;
// If notation has keep/drop, clear mode
if (parsed.keepDrop) {
setMode("normal");
return;
}
// If mode is adv/dis and count is 1, update notation to kh1/kl1
if (mode === "advantage" && parsed.count === 1) {
notationInput.value =
buildNotation(2, parsed.sides, parsed.modifier) + "kh1";
} else if (mode === "disadvantage" && parsed.count === 1) {
notationInput.value =
buildNotation(2, parsed.sides, parsed.modifier) + "kl1";
} else if (mode === "normal") {
notationInput.value = buildNotation(
parsed.count,
parsed.sides,
parsed.modifier,
);
}
updateModeToggle();
});
});
// Presets
document.querySelectorAll(".dg-preset").forEach((el) => {
el.addEventListener("click", () => {
const notation = el.getAttribute("data-preset");
notationInput.value = notation;
clearNotationErr();
const parsed = parseDiceNotation(notation);
if (parsed) {
suppressSync = true;
countInput.value = String(parsed.count);
setSelectedSides(parsed.sides);
suppressSync = false;
}
updateModeToggle();
roll();
});
});
// Presets toggle
presetsToggle?.addEventListener("click", togglePresets);
// Buttons
btn.addEventListener("click", roll);
copyBtn.addEventListener("click", copyResults);
clearBtn.addEventListener("click", () => {
sessionStorage.removeItem(HISTORY_KEY);
renderHistory();
});
// ═══ Init ═════════════════════════════════════════════════════════════════════
initPresets();
notationInput.value = buildNotation(
parseInt(countInput.value, 10),
getSelectedSides(),
0,
);
updateModeToggle();
renderHistory();
</script>