959 lines
31 KiB
Plaintext
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>
|