|
|
|
@@ -3,44 +3,54 @@ import { useT } from '../../i18n/translations';
|
|
|
|
|
const isRu = Astro.url.pathname.startsWith('/ru');
|
|
|
|
|
const T = useT(isRu ? 'ru' : 'en');
|
|
|
|
|
|
|
|
|
|
const notationLabel = isRu ? 'Нотация' : 'Notation';
|
|
|
|
|
const notationPlaceholder = isRu ? 'напр. 2d6+3, 1d20, 4d4-1' : 'e.g. 2d6+3, 1d20, 4d4-1';
|
|
|
|
|
const modeLabel = isRu ? 'Режим' : 'Mode';
|
|
|
|
|
const modeNormal = isRu ? 'Обычный' : 'Normal';
|
|
|
|
|
const modeAdvantage = isRu ? 'Преимущество' : 'Advantage';
|
|
|
|
|
const modeDisadvantage = isRu ? 'Помеха' : 'Disadvantage';
|
|
|
|
|
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 historyTitle = isRu ? 'История бросков' : 'Roll history';
|
|
|
|
|
const clearHistory = isRu ? 'Очистить историю' : 'Clear history';
|
|
|
|
|
const presetsLabel = isRu ? 'Пресеты' : 'Presets';
|
|
|
|
|
|
|
|
|
|
const presets = [
|
|
|
|
|
{ label: isRu ? 'Атака' : 'Attack', notation: '1d20', stat: false },
|
|
|
|
|
{ label: isRu ? 'Урон (1d8)' : 'Damage (1d8)', notation: '1d8+3', stat: false },
|
|
|
|
|
{ label: isRu ? 'Огненный шар' : 'Fireball', notation: '8d6', stat: false },
|
|
|
|
|
{ label: isRu ? 'Скрытая атака' : 'Sneak Attack', notation: '3d6', stat: false },
|
|
|
|
|
{ label: isRu ? 'Характеристика' : 'Stat Roll', notation: '4d6', stat: true },
|
|
|
|
|
{ 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">
|
|
|
|
|
|
|
|
|
|
<!-- Quick presets -->
|
|
|
|
|
<div class="flex flex-wrap gap-2 mb-5" 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-[#534AB7] hover:text-[#534AB7] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#534AB7] transition-colors cursor-pointer"
|
|
|
|
|
data-preset={p.notation}
|
|
|
|
|
data-stat-roll={p.stat ? '1' : undefined}
|
|
|
|
|
>
|
|
|
|
|
{p.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
<!-- 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-[#534AB7] 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"/></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-[#534AB7] hover:text-[#534AB7] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#534AB7] transition-colors cursor-pointer"
|
|
|
|
|
data-preset={p.notation}
|
|
|
|
|
>
|
|
|
|
|
{p.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Notation input -->
|
|
|
|
|
<div class="mb-4">
|
|
|
|
|
<label for="dg-notation" class="block text-sm font-medium text-zinc-400 mb-1">{notationLabel}</label>
|
|
|
|
|
<input
|
|
|
|
|
id="dg-notation"
|
|
|
|
|
type="text"
|
|
|
|
@@ -83,17 +93,17 @@ const presets = [
|
|
|
|
|
|
|
|
|
|
<!-- Advantage / Disadvantage toggle -->
|
|
|
|
|
<div class="mt-4">
|
|
|
|
|
<label class="block text-sm font-medium text-zinc-400 mb-1.5">{modeLabel}</label>
|
|
|
|
|
<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={modeLabel}
|
|
|
|
|
aria-label={isRu ? 'Режим' : 'Mode'}
|
|
|
|
|
>
|
|
|
|
|
{[
|
|
|
|
|
{ value: 'normal', label: modeNormal },
|
|
|
|
|
{ value: 'advantage', label: modeAdvantage },
|
|
|
|
|
{ value: 'disadvantage',label: modeDisadvantage },
|
|
|
|
|
{ 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'} />
|
|
|
|
@@ -167,390 +177,540 @@ const presets = [
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
const isRu = document.documentElement.lang === 'ru';
|
|
|
|
|
<!-- Script section appended to DiceGenerator.astro -->
|
|
|
|
|
|
|
|
|
|
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';
|
|
|
|
|
const ADV_WORD = isRu ? 'Преимущество' : 'Advantage';
|
|
|
|
|
const DIS_WORD = isRu ? 'Помеха' : 'Disadvantage';
|
|
|
|
|
const DROPPED_WORD = isRu ? 'сброшено' : 'dropped';
|
|
|
|
|
<script type="module">
|
|
|
|
|
// ═══ Inline Dice Engine ═══════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
// ── DOM refs ────────────────────────────────────────────────────────────────
|
|
|
|
|
const notationInput = document.getElementById('dg-notation') as HTMLInputElement;
|
|
|
|
|
const notationErr = document.getElementById('dg-notation-err') as HTMLParagraphElement;
|
|
|
|
|
const countInput = document.getElementById('dg-count') as HTMLInputElement;
|
|
|
|
|
const btn = document.getElementById('dg-btn') as HTMLButtonElement;
|
|
|
|
|
const copyBtn = document.getElementById('dg-copy-btn') as HTMLButtonElement;
|
|
|
|
|
const diceEl = document.getElementById('dg-dice') as HTMLDivElement;
|
|
|
|
|
const advInfoEl = document.getElementById('dg-adv-info') as HTMLDivElement;
|
|
|
|
|
const modifierLine = document.getElementById('dg-modifier-line') as HTMLDivElement;
|
|
|
|
|
const totalWrap = document.getElementById('dg-total-wrap') as HTMLDivElement;
|
|
|
|
|
const totalEl = document.getElementById('dg-total') as HTMLSpanElement;
|
|
|
|
|
const errorEl = document.getElementById('dg-error') as HTMLParagraphElement;
|
|
|
|
|
const copyLabel = document.getElementById('dg-copy-label') as HTMLSpanElement;
|
|
|
|
|
const copyIcon = document.getElementById('dg-copy-icon') as HTMLSpanElement;
|
|
|
|
|
const checkIcon = document.getElementById('dg-check-icon') as HTMLSpanElement;
|
|
|
|
|
const modeWrap = document.getElementById('dg-mode-wrap') as HTMLDivElement;
|
|
|
|
|
const advHint = document.getElementById('dg-adv-hint') as HTMLParagraphElement;
|
|
|
|
|
const historyPanel = document.getElementById('dg-history-panel') as HTMLDivElement;
|
|
|
|
|
const historyList = document.getElementById('dg-history-list') as HTMLDivElement;
|
|
|
|
|
const clearBtn = document.getElementById('dg-clear-btn') as HTMLButtonElement;
|
|
|
|
|
const EXPLODE_CAP = 100;
|
|
|
|
|
const REROLL_CAP = 1000;
|
|
|
|
|
|
|
|
|
|
// ── State ───────────────────────────────────────────────────────────────────
|
|
|
|
|
let suppressSync = false;
|
|
|
|
|
let statRollMode = false;
|
|
|
|
|
let lastCopyText = '';
|
|
|
|
|
let copyRevertTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
function rollDie(sides, explode, reroll) {
|
|
|
|
|
let value = Math.floor(Math.random() * sides) + 1;
|
|
|
|
|
const original = value;
|
|
|
|
|
let rerolledFrom = null;
|
|
|
|
|
let explosions = [];
|
|
|
|
|
let exploded = false;
|
|
|
|
|
|
|
|
|
|
const HISTORY_KEY = 'dg-history';
|
|
|
|
|
|
|
|
|
|
interface HistoryEntry {
|
|
|
|
|
notation: string;
|
|
|
|
|
rolls: number[];
|
|
|
|
|
sides: number;
|
|
|
|
|
modifier: number;
|
|
|
|
|
total: number;
|
|
|
|
|
isStatRoll: boolean;
|
|
|
|
|
droppedValue?: number;
|
|
|
|
|
keptRolls?: number[];
|
|
|
|
|
isAdvantage: boolean;
|
|
|
|
|
isDisadvantage: boolean;
|
|
|
|
|
advRolls?: [number, number];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Notation ────────────────────────────────────────────────────────────────
|
|
|
|
|
function parseNotation(raw: string): { count: number; sides: number; modifier: number } | null {
|
|
|
|
|
const s = raw.replace(/\s+/g, '').toLowerCase();
|
|
|
|
|
const m = s.match(/^(\d*)d(\d+)((?:[+-]\d+)*)$/);
|
|
|
|
|
if (!m) return null;
|
|
|
|
|
const count = m[1] === '' ? 1 : parseInt(m[1], 10);
|
|
|
|
|
const sides = parseInt(m[2], 10);
|
|
|
|
|
if (count < 1 || count > 20 || sides < 1) return null;
|
|
|
|
|
const parts = (m[3] || '').match(/[+-]\d+/g) ?? [];
|
|
|
|
|
const modifier = parts.reduce((sum, p) => sum + parseInt(p, 10), 0);
|
|
|
|
|
if (Math.abs(modifier) > 999) return null;
|
|
|
|
|
return { count, sides, modifier };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildNotation(count: number, sides: number, modifier: number): string {
|
|
|
|
|
let s = `${count}d${sides}`;
|
|
|
|
|
if (modifier > 0) s += `+${modifier}`;
|
|
|
|
|
else if (modifier < 0) s += modifier;
|
|
|
|
|
return s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Control helpers ─────────────────────────────────────────────────────────
|
|
|
|
|
function getSelectedSides(): number {
|
|
|
|
|
const r = document.querySelector('input[name="dg-sides"]:checked') as HTMLInputElement | null;
|
|
|
|
|
return r ? parseInt(r.value, 10) : 6;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setSelectedSides(sides: number): void {
|
|
|
|
|
(document.querySelectorAll('input[name="dg-sides"]') as NodeListOf<HTMLInputElement>)
|
|
|
|
|
.forEach(r => { r.checked = parseInt(r.value, 10) === sides; });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getMode(): 'normal' | 'advantage' | 'disadvantage' {
|
|
|
|
|
const r = document.querySelector('input[name="dg-mode"]:checked') as HTMLInputElement | null;
|
|
|
|
|
return (r?.value ?? 'normal') as 'normal' | 'advantage' | 'disadvantage';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getCurrentModifier(): number {
|
|
|
|
|
const raw = notationInput.value.trim();
|
|
|
|
|
if (!raw) return 0;
|
|
|
|
|
const parsed = parseNotation(raw);
|
|
|
|
|
return parsed?.modifier ?? 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateModeToggle(): void {
|
|
|
|
|
const count = parseInt(countInput.value, 10);
|
|
|
|
|
const disabled = !isNaN(count) && count > 1;
|
|
|
|
|
modeWrap.classList.toggle('opacity-40', disabled);
|
|
|
|
|
modeWrap.classList.toggle('pointer-events-none', disabled);
|
|
|
|
|
advHint.classList.toggle('hidden', !disabled);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Errors ──────────────────────────────────────────────────────────────────
|
|
|
|
|
function showError(msg: string) { errorEl.textContent = msg; errorEl.classList.remove('hidden'); }
|
|
|
|
|
function clearError() { errorEl.textContent = ''; errorEl.classList.add('hidden'); }
|
|
|
|
|
function showNotationErr(msg: string) { notationErr.textContent = msg; notationErr.classList.remove('hidden'); }
|
|
|
|
|
function clearNotationErr() { notationErr.textContent = ''; notationErr.classList.add('hidden'); }
|
|
|
|
|
|
|
|
|
|
// ── Die chip ────────────────────────────────────────────────────────────────
|
|
|
|
|
function makeDieChip(val: number, sides: number, extra = ''): HTMLSpanElement {
|
|
|
|
|
const el = document.createElement('span');
|
|
|
|
|
el.textContent = String(val);
|
|
|
|
|
const color = val === sides ? 'border-[#534AB7] text-[#534AB7] bg-[#534AB7]/10'
|
|
|
|
|
: val === 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();
|
|
|
|
|
return el;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Colored HTML span for use in history (innerHTML safe — values are numbers)
|
|
|
|
|
function dieHtml(val: number, sides: number): string {
|
|
|
|
|
const cls = val === sides ? 'text-[#534AB7] font-bold'
|
|
|
|
|
: val === 1 ? 'text-red-400 font-bold'
|
|
|
|
|
: 'text-zinc-300';
|
|
|
|
|
return `<span class="${cls}">${val}</span>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── History ─────────────────────────────────────────────────────────────────
|
|
|
|
|
function loadHistory(): HistoryEntry[] {
|
|
|
|
|
try { return JSON.parse(sessionStorage.getItem(HISTORY_KEY) ?? '[]'); } catch { return []; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function saveHistory(h: HistoryEntry[]): void {
|
|
|
|
|
sessionStorage.setItem(HISTORY_KEY, JSON.stringify(h.slice(0, 10)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function entryHtml(e: HistoryEntry): string {
|
|
|
|
|
const left = `<span class="text-zinc-500 shrink-0 mr-1">`;
|
|
|
|
|
|
|
|
|
|
if (e.isStatRoll && e.keptRolls !== undefined && e.droppedValue !== undefined) {
|
|
|
|
|
const keptStr = e.keptRolls.map(v => dieHtml(v, e.sides)).join('+');
|
|
|
|
|
return `${left}[${DROPPED_WORD}: <span class="text-red-400">${e.droppedValue}</span>]</span>`
|
|
|
|
|
+ ` <span class="text-zinc-400">${keptStr} = ${e.total}</span>`;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (e.isAdvantage || e.isDisadvantage) {
|
|
|
|
|
const [r1, r2] = e.advRolls ?? [0, 0];
|
|
|
|
|
const prefix = e.isAdvantage ? ADV_WORD : DIS_WORD;
|
|
|
|
|
const winner = e.isAdvantage ? Math.max(r1, r2) : Math.min(r1, r2);
|
|
|
|
|
const modStr = e.modifier !== 0
|
|
|
|
|
? ` ${e.modifier > 0 ? '+' : ''}${e.modifier} = ${e.total}`
|
|
|
|
|
: '';
|
|
|
|
|
return `${left}${prefix}</span>`
|
|
|
|
|
+ ` <span class="text-zinc-400">[${dieHtml(r1, e.sides)}, ${dieHtml(r2, e.sides)}] → ${dieHtml(winner, e.sides)}${modStr}</span>`;
|
|
|
|
|
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++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Normal
|
|
|
|
|
const rollsStr = `[${e.rolls.map(v => dieHtml(v, e.sides)).join(', ')}]`;
|
|
|
|
|
const modStr = e.modifier > 0 ? ` +${e.modifier}` : e.modifier < 0 ? ` ${e.modifier}` : '';
|
|
|
|
|
return `${left}${e.notation}</span>`
|
|
|
|
|
+ ` <span class="text-zinc-600">→</span>`
|
|
|
|
|
+ ` <span class="text-zinc-400">${rollsStr}${modStr} = ${e.total}</span>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderHistory(): void {
|
|
|
|
|
const h = loadHistory();
|
|
|
|
|
if (h.length === 0) { historyPanel.classList.add('hidden'); return; }
|
|
|
|
|
historyPanel.classList.remove('hidden');
|
|
|
|
|
historyList.innerHTML = h.map(e =>
|
|
|
|
|
`<div class="flex items-center gap-1.5 text-xs font-mono py-1 border-b border-zinc-800/40 last:border-b-0">${entryHtml(e)}</div>`
|
|
|
|
|
).join('');
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addToHistory(entry: HistoryEntry): void {
|
|
|
|
|
const h = loadHistory();
|
|
|
|
|
h.unshift(entry);
|
|
|
|
|
saveHistory(h);
|
|
|
|
|
renderHistory();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Get roll params ─────────────────────────────────────────────────────────
|
|
|
|
|
function getRollParams(): { count: number; sides: number; modifier: number } | null {
|
|
|
|
|
const raw = notationInput.value.trim();
|
|
|
|
|
if (raw) {
|
|
|
|
|
const parsed = parseNotation(raw);
|
|
|
|
|
if (!parsed) { showNotationErr(ERR_NOTATION); return null; }
|
|
|
|
|
clearNotationErr();
|
|
|
|
|
return parsed;
|
|
|
|
|
let 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;
|
|
|
|
|
}
|
|
|
|
|
const count = parseInt(countInput.value, 10);
|
|
|
|
|
const sides = getSelectedSides();
|
|
|
|
|
if (!Number.isInteger(count) || count < 1) { showError(ERR_COUNT); return null; }
|
|
|
|
|
if (count > 20) { showError(ERR_MAX); return null; }
|
|
|
|
|
return { count, sides, modifier: 0 };
|
|
|
|
|
remaining = remaining.slice(em[0].length);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Roll ────────────────────────────────────────────────────────────────────
|
|
|
|
|
function roll(): void {
|
|
|
|
|
clearError();
|
|
|
|
|
const params = getRollParams();
|
|
|
|
|
if (!params) return;
|
|
|
|
|
const { count, sides, modifier } = params;
|
|
|
|
|
const mode = getMode();
|
|
|
|
|
const notation = buildNotation(count, sides, modifier);
|
|
|
|
|
|
|
|
|
|
// Reset secondary display elements
|
|
|
|
|
advInfoEl.classList.add('hidden');
|
|
|
|
|
advInfoEl.innerHTML = '';
|
|
|
|
|
modifierLine.classList.add('hidden');
|
|
|
|
|
totalWrap.classList.add('hidden');
|
|
|
|
|
diceEl.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
// ── Stat Roll: 4d6 drop lowest ──────────────────────────────────────────
|
|
|
|
|
if (statRollMode) {
|
|
|
|
|
const allRolls = Array.from({ length: 4 }, () => Math.floor(Math.random() * sides) + 1);
|
|
|
|
|
const sorted = [...allRolls].sort((a, b) => a - b);
|
|
|
|
|
const droppedValue = sorted[0];
|
|
|
|
|
const keptRolls = sorted.slice(1);
|
|
|
|
|
const total = keptRolls.reduce((s, n) => s + n, 0);
|
|
|
|
|
|
|
|
|
|
let droppedUsed = false;
|
|
|
|
|
allRolls.forEach(v => {
|
|
|
|
|
const isDrop = v === droppedValue && !droppedUsed;
|
|
|
|
|
if (isDrop) droppedUsed = true;
|
|
|
|
|
diceEl.appendChild(makeDieChip(v, sides, isDrop ? 'opacity-40' : ''));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
advInfoEl.innerHTML = `[${DROPPED_WORD}: <span class="text-red-400">${droppedValue}</span>] ${keptRolls.join('+')} = ${total}`;
|
|
|
|
|
advInfoEl.classList.remove('hidden');
|
|
|
|
|
|
|
|
|
|
lastCopyText = `${keptRolls.join('+')} = ${total}`;
|
|
|
|
|
addToHistory({ notation: '4d6', rolls: allRolls, sides, modifier: 0, total, isStatRoll: true, droppedValue, keptRolls, isAdvantage: false, isDisadvantage: false });
|
|
|
|
|
|
|
|
|
|
// ── Advantage / Disadvantage ────────────────────────────────────────────
|
|
|
|
|
} else if (mode !== 'normal' && count === 1) {
|
|
|
|
|
const r1 = Math.floor(Math.random() * sides) + 1;
|
|
|
|
|
const r2 = Math.floor(Math.random() * sides) + 1;
|
|
|
|
|
const kept = mode === 'advantage' ? Math.max(r1, r2) : Math.min(r1, r2);
|
|
|
|
|
const total = kept + modifier;
|
|
|
|
|
const prefix = mode === 'advantage' ? ADV_WORD : DIS_WORD;
|
|
|
|
|
|
|
|
|
|
let winnerUsed = false;
|
|
|
|
|
[r1, r2].forEach(v => {
|
|
|
|
|
const isWinner = v === kept && !winnerUsed;
|
|
|
|
|
if (isWinner) winnerUsed = true;
|
|
|
|
|
diceEl.appendChild(makeDieChip(v, sides, isWinner ? '' : 'opacity-40'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const modStr = modifier !== 0
|
|
|
|
|
? ` ${modifier > 0 ? '+' : ''}${modifier} = ${total}`
|
|
|
|
|
: '';
|
|
|
|
|
advInfoEl.textContent = `${prefix}: [${r1}, ${r2}] → ${kept}${modStr}`;
|
|
|
|
|
advInfoEl.classList.remove('hidden');
|
|
|
|
|
|
|
|
|
|
if (modifier !== 0) {
|
|
|
|
|
totalEl.textContent = String(total);
|
|
|
|
|
totalWrap.classList.remove('hidden');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lastCopyText = `${prefix}: [${r1}, ${r2}] → ${kept}${modStr}`;
|
|
|
|
|
addToHistory({ notation, rolls: [kept], sides, modifier, total, isStatRoll: false, isAdvantage: mode === 'advantage', isDisadvantage: mode === 'disadvantage', advRolls: [r1, r2] });
|
|
|
|
|
|
|
|
|
|
// ── Normal roll ─────────────────────────────────────────────────────────
|
|
|
|
|
let 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 {
|
|
|
|
|
const rolls = Array.from({ length: count }, () => Math.floor(Math.random() * sides) + 1);
|
|
|
|
|
const rollSum = rolls.reduce((s, n) => s + n, 0);
|
|
|
|
|
const total = rollSum + modifier;
|
|
|
|
|
|
|
|
|
|
rolls.forEach(v => diceEl.appendChild(makeDieChip(v, sides)));
|
|
|
|
|
|
|
|
|
|
if (modifier !== 0) {
|
|
|
|
|
modifierLine.textContent = modifier > 0 ? `+${modifier}` : String(modifier);
|
|
|
|
|
modifierLine.classList.remove('hidden');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
totalEl.textContent = String(total);
|
|
|
|
|
totalWrap.classList.remove('hidden');
|
|
|
|
|
|
|
|
|
|
const modStr = modifier > 0 ? ` +${modifier}` : modifier < 0 ? ` ${modifier}` : '';
|
|
|
|
|
lastCopyText = `${rolls.join(', ')}${modStr} = ${total}`;
|
|
|
|
|
addToHistory({ notation, rolls, sides, modifier, total, isStatRoll: false, isAdvantage: false, isDisadvantage: false });
|
|
|
|
|
reroll.operator = 'eq';
|
|
|
|
|
reroll.values.add(val);
|
|
|
|
|
}
|
|
|
|
|
remaining = remaining.slice(rm[0].length);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
copyBtn.classList.remove('invisible');
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isAdvanced(raw) {
|
|
|
|
|
return /(kh|kl|dh|dl|!|[<>]?r\d)/i.test(raw.replace(/\s/g, ''));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ═══ 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';
|
|
|
|
|
const ADV_WORD = isRu ? 'Преимущество' : 'Advantage';
|
|
|
|
|
const DIS_WORD = isRu ? 'Помеха' : 'Disadvantage';
|
|
|
|
|
const DROPPED_WORD = isRu ? 'сброшено' : 'dropped';
|
|
|
|
|
|
|
|
|
|
// 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');
|
|
|
|
|
let text = String(v);
|
|
|
|
|
const color = v === sides ? 'border-[#534AB7] text-[#534AB7] bg-[#534AB7]/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-[#534AB7] 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);
|
|
|
|
|
let cls = '';
|
|
|
|
|
if (d.dropped) {
|
|
|
|
|
return { text: text, cls: 'line-through opacity-40 text-zinc-500' };
|
|
|
|
|
}
|
|
|
|
|
if (d.rerolledFrom !== null) {
|
|
|
|
|
text = `${d.rerolledFrom}→${d.value}`;
|
|
|
|
|
cls = 'text-zinc-300';
|
|
|
|
|
}
|
|
|
|
|
if (d.exploded) {
|
|
|
|
|
text += d.explosions.map(v => `→${v}`).join('');
|
|
|
|
|
}
|
|
|
|
|
let color = d.value === sides ? 'text-[#534AB7]'
|
|
|
|
|
: d.value === 1 ? 'text-red-400'
|
|
|
|
|
: 'text-zinc-100';
|
|
|
|
|
if (d.dropped) color = 'line-through opacity-40 text-zinc-500';
|
|
|
|
|
else 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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Copy ────────────────────────────────────────────────────────────────────
|
|
|
|
|
async function copyResults(): Promise<void> {
|
|
|
|
|
if (!lastCopyText) return;
|
|
|
|
|
await 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 ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
// ── Sync: notation → controls ───────────────────────────────────────────────
|
|
|
|
|
notationInput.addEventListener('input', () => {
|
|
|
|
|
const raw = notationInput.value.trim();
|
|
|
|
|
if (!raw) { clearNotationErr(); statRollMode = false; return; }
|
|
|
|
|
const parsed = parseNotation(raw);
|
|
|
|
|
function roll() {
|
|
|
|
|
clearError();
|
|
|
|
|
const raw = notationInput.value.trim();
|
|
|
|
|
let parsed = null;
|
|
|
|
|
let notation = '';
|
|
|
|
|
let result = null;
|
|
|
|
|
let isAdv = false, isDis = false;
|
|
|
|
|
|
|
|
|
|
if (raw) {
|
|
|
|
|
parsed = parseDiceNotation(raw);
|
|
|
|
|
if (!parsed) { showNotationErr(ERR_NOTATION); return; }
|
|
|
|
|
clearNotationErr();
|
|
|
|
|
suppressSync = true;
|
|
|
|
|
countInput.value = String(parsed.count);
|
|
|
|
|
setSelectedSides(parsed.sides);
|
|
|
|
|
statRollMode = false;
|
|
|
|
|
suppressSync = false;
|
|
|
|
|
} 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();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
notationInput.addEventListener('keydown', (e) => {
|
|
|
|
|
if (e.key === 'Enter') { e.preventDefault(); roll(); }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Sync: controls → notation ───────────────────────────────────────────────
|
|
|
|
|
countInput.addEventListener('input', () => {
|
|
|
|
|
if (suppressSync) return;
|
|
|
|
|
statRollMode = false;
|
|
|
|
|
const count = parseInt(countInput.value, 10);
|
|
|
|
|
const sides = getSelectedSides();
|
|
|
|
|
const modifier = getCurrentModifier();
|
|
|
|
|
if (!isNaN(count) && count > 0) {
|
|
|
|
|
notationInput.value = buildNotation(count, sides, modifier);
|
|
|
|
|
clearNotationErr();
|
|
|
|
|
// 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();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
countInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') roll(); });
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll('input[name="dg-sides"]').forEach(radio => {
|
|
|
|
|
radio.addEventListener('change', () => {
|
|
|
|
|
if (suppressSync) return;
|
|
|
|
|
statRollMode = false;
|
|
|
|
|
const count = parseInt(countInput.value, 10) || 1;
|
|
|
|
|
const sides = getSelectedSides();
|
|
|
|
|
const modifier = getCurrentModifier();
|
|
|
|
|
notationInput.value = buildNotation(count, sides, modifier);
|
|
|
|
|
clearNotationErr();
|
|
|
|
|
});
|
|
|
|
|
// 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 ─────────────────────────────────────────────────────────────────
|
|
|
|
|
document.querySelectorAll('.dg-preset').forEach(el => {
|
|
|
|
|
el.addEventListener('click', () => {
|
|
|
|
|
const notation = el.getAttribute('data-preset')!;
|
|
|
|
|
const isStat = el.getAttribute('data-stat-roll') === '1';
|
|
|
|
|
notationInput.value = notation;
|
|
|
|
|
clearNotationErr();
|
|
|
|
|
const parsed = parseNotation(notation);
|
|
|
|
|
if (parsed) {
|
|
|
|
|
suppressSync = true;
|
|
|
|
|
countInput.value = String(parsed.count);
|
|
|
|
|
setSelectedSides(parsed.sides);
|
|
|
|
|
suppressSync = false;
|
|
|
|
|
}
|
|
|
|
|
statRollMode = isStat;
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
// Buttons
|
|
|
|
|
btn.addEventListener('click', roll);
|
|
|
|
|
copyBtn.addEventListener('click', copyResults);
|
|
|
|
|
clearBtn.addEventListener('click', () => { sessionStorage.removeItem(HISTORY_KEY); renderHistory(); });
|
|
|
|
|
|
|
|
|
|
// ── Init ────────────────────────────────────────────────────────────────────
|
|
|
|
|
notationInput.value = buildNotation(parseInt(countInput.value, 10), getSelectedSides(), 0);
|
|
|
|
|
updateModeToggle();
|
|
|
|
|
renderHistory();
|
|
|
|
|
// ═══ Init ═════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
initPresets();
|
|
|
|
|
notationInput.value = buildNotation(parseInt(countInput.value, 10), getSelectedSides(), 0);
|
|
|
|
|
updateModeToggle();
|
|
|
|
|
renderHistory();
|
|
|
|
|
</script>
|
|
|
|
|