feat(dice): full notation engine with kh/kl/dh/dl, exploding dice, reroll, presets, roll history, adv/disadv toggle

- Inline dice engine: XdY±Z, keep/drop (4d6dl1), exploding (3d6!), penetrating (!p), reroll (r1, ro1, r<3)
- Notation input with real-time validation and bidirectional sync with controls
- Advantage/Disadvantage toggle with smart disable when count>1 or keep/drop present
- 6 presets: Attack, Damage, Fireball, Sneak Attack, Stat Roll (4d6dl1), Savage Strike (2d6!)
- Roll history with dropped strikethrough, explosion chains, reroll arrows
- Updated SEO title/description and When-to-use blocks (EN+RU)
This commit is contained in:
Emil Shanaty
2026-05-10 01:23:50 +03:00
parent 2168b88407
commit ef2aeb5345
5 changed files with 821 additions and 390 deletions
+544 -384
View File
@@ -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>
+3 -3
View File
@@ -32,12 +32,12 @@ export const generators: Generator[] = [
description: 'Roll one or more dice with any number of sides.',
icon: 'dice-6',
status: 'live',
seoTitle: 'Dice Roller — d4 d6 d8 d10 d12 d20 | D&D Dice | Randify',
seoDescription: 'Free online dice roller for D&D and tabletop RPGs. Supports any XdY+Z notation, advantage/disadvantage, roll history, and quick presets for attacks, damage, and stat rolls.',
seoTitle: 'Dice Roller — d4 d6 d20 + Full Notation | D&D Dice | Randify',
seoDescription: 'Free online dice roller with full XdY+Z notation, keep/drop (4d6dl1), exploding dice (3d6!), reroll, advantage/disadvantage, and roll history. Perfect for D&D, Pathfinder, and Savage Worlds.',
ruTitle: 'Кубик',
ruDescription: 'Бросьте один или несколько кубиков с любым числом граней.',
ruSeoTitle: 'Бросить кубик онлайн — d4 d6 d8 d10 d12 d20 | D&D | Randify',
ruSeoDescription: 'Бесплатный онлайн-бросок кубиков для D&D и настольных RPG. Поддерживает нотацию XdY+Z, преимущество/помеху, историю бросков и быстрые пресеты.',
ruSeoDescription: 'Бесплатный онлайн-бросок кубиков с полной нотацией XdY+Z, keep/drop (4d6dl1), взрывающимися кубиками (3d6!), перебросом, преимуществом/помехой и историей. Для D&D, Pathfinder и Savage Worlds.',
},
{
slug: 'wheel',
+267
View File
@@ -0,0 +1,267 @@
// Dice notation engine for Randify.pro
// Supports: XdY±Z, kh/kl/dh/dl, ! exploding, r reroll, advantage/disadvantage
// Caps: explode chain ≤100, reroll recursion ≤1000
type KeepDrop = { type: 'kh' | 'kl' | 'dh' | 'dl'; count: number } | null;
type Explode = {
active: boolean;
threshold: number | null; // null = max value
penetrating: boolean;
};
type Reroll = {
active: boolean;
values: Set<number>;
once: boolean; // "ro" = once only; "r" = recursive
operator: 'eq' | 'lt' | 'gt';
};
export interface Parsed {
count: number;
sides: number;
modifier: number;
keepDrop: KeepDrop;
explode: Explode;
reroll: Reroll;
advantage: boolean;
disadvantage: boolean;
}
export interface DieResult {
value: number;
original: number;
exploded: boolean;
explosions: number[]; // chained explosion rolls
rerolledFrom: number | null;
dropped: boolean;
}
export interface RollResult {
dice: DieResult[];
kept: DieResult[];
dropped: DieResult[];
modifier: number;
total: number;
notation: string;
advantageRolls: [number, number] | null; // for adv/disadv display
}
const EXPLODE_CAP = 100;
const REROLL_CAP = 1000;
/** Remove all whitespace and lowercase for parsing */
function normalize(raw: string): string {
return raw.replace(/\s+/g, '').toLowerCase();
}
/**
* Parse dice notation string into structured object.
* Returns null if syntax is invalid.
*/
export function parseDiceNotation(raw: string): Parsed | null {
const s = normalize(raw);
if (!s) return null;
// Main pattern: count d sides [modifiers+operators]
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);
const rest = main[3];
if (count < 1 || count > 20 || sides < 1 || sides > 9999) return null;
let remaining = rest;
// Keep/Drop: khN, klN, dhN, dlN
let keepDrop: KeepDrop = null;
const kdMatch = remaining.match(/^(kh|kl|dh|dl)(\d+)/);
if (kdMatch) {
const countKD = parseInt(kdMatch[2], 10);
if (countKD < 1 || countKD >= count) return null;
keepDrop = { type: kdMatch[1] as 'kh' | 'kl' | 'dh' | 'dl', count: countKD };
remaining = remaining.slice(kdMatch[0].length);
}
// Exploding: ! or !>N or !p or !p>N
let explode: Explode = { active: false, threshold: null, penetrating: false };
const expMatch = remaining.match(/^(!p|!)(>?)(\d*)/);
if (expMatch) {
explode.active = true;
explode.penetrating = expMatch[1] === '!p';
if (expMatch[2] === '>' && expMatch[3]) {
explode.threshold = parseInt(expMatch[3], 10);
if (explode.threshold < 2 || explode.threshold > sides) return null;
} else {
explode.threshold = sides; // default: explode on max
}
remaining = remaining.slice(expMatch[0].length);
}
// Reroll: rN, roN, r<N, ro<N
let reroll: Reroll = { active: false, values: new Set(), once: false, operator: 'eq' };
const rrMatch = remaining.match(/^(ro|r)([<>]?)(\d+)/);
if (rrMatch) {
reroll.active = true;
reroll.once = rrMatch[1] === 'ro';
const op = rrMatch[2] as '' | '<' | '>';
const val = parseInt(rrMatch[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(rrMatch[0].length);
}
// Flat modifier at the end: +N or -N
let modifier = 0;
const modMatch = remaining.match(/^([+-]\d+)$/);
if (modMatch) {
modifier = parseInt(modMatch[1], 10);
if (Math.abs(modifier) > 999) return null;
remaining = remaining.slice(modMatch[0].length);
}
// If anything remains unparsed, it's invalid
if (remaining.length > 0) return null;
return { count, sides, modifier, keepDrop, explode, reroll, advantage: false, disadvantage: false };
}
/** Roll a single die with optional reroll and explode logic */
function rollDie(sides: number, explode: Explode, reroll: Reroll): DieResult {
let value = Math.floor(Math.random() * sides) + 1;
const original = value;
let rerolledFrom: number | null = null;
let explosions: number[] = [];
let exploded = false;
// Handle reroll
if (reroll.active && reroll.values.has(value)) {
rerolledFrom = value;
let rerollCount = 0;
while (reroll.values.has(value) && rerollCount < REROLL_CAP) {
value = Math.floor(Math.random() * sides) + 1;
rerollCount++;
if (reroll.once) break;
}
}
// Handle exploding
if (explode.active && value >= (explode.threshold ?? sides)) {
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); // penetrating subtracts 1
}
explosions.push(next);
if (next < (explode.threshold ?? sides)) break;
chain++;
}
}
return {
value,
original,
exploded,
explosions,
rerolledFrom,
dropped: false,
};
}
/** Execute a roll from parsed notation */
export function rollDice(parsed: Parsed): RollResult {
const dice: DieResult[] = [];
for (let i = 0; i < parsed.count; i++) {
dice.push(rollDie(parsed.sides, parsed.explode, parsed.reroll));
}
// Apply keep/drop
let kept = [...dice];
let dropped: DieResult[] = [];
if (parsed.keepDrop) {
const sorted = [...dice].map((d, i) => ({ die: d, idx: i }));
if (parsed.keepDrop.type === 'kh') {
sorted.sort((a, b) => b.die.value - a.die.value);
const keepIndices = new Set(sorted.slice(0, parsed.keepDrop.count).map(x => x.idx));
kept = dice.filter((_, i) => keepIndices.has(i));
dropped = dice.filter((_, i) => !keepIndices.has(i));
} else if (parsed.keepDrop.type === 'kl') {
sorted.sort((a, b) => a.die.value - b.die.value);
const keepIndices = new Set(sorted.slice(0, parsed.keepDrop.count).map(x => x.idx));
kept = dice.filter((_, i) => keepIndices.has(i));
dropped = dice.filter((_, i) => !keepIndices.has(i));
} else if (parsed.keepDrop.type === 'dh') {
sorted.sort((a, b) => b.die.value - a.die.value);
const dropIndices = new Set(sorted.slice(0, parsed.keepDrop.count).map(x => x.idx));
kept = dice.filter((_, i) => !dropIndices.has(i));
dropped = dice.filter((_, i) => dropIndices.has(i));
} else if (parsed.keepDrop.type === 'dl') {
sorted.sort((a, b) => a.die.value - b.die.value);
const dropIndices = new Set(sorted.slice(0, parsed.keepDrop.count).map(x => x.idx));
kept = dice.filter((_, i) => !dropIndices.has(i));
dropped = dice.filter((_, i) => dropIndices.has(i));
}
}
dropped.forEach(d => { d.dropped = true; });
// Calculate total
let total = kept.reduce((sum, d) => sum + d.value, 0);
total += kept.reduce((sum, d) => sum + d.explosions.reduce((s, v) => s + v, 0), 0);
total += parsed.modifier;
return {
dice,
kept,
dropped,
modifier: parsed.modifier,
total,
notation: '', // filled by caller
advantageRolls: null,
};
}
/** Roll with advantage or disadvantage (2dX keep highest/lowest) */
export function rollAdvantage(sides: number, modifier: number, advantage: boolean): RollResult {
const r1 = Math.floor(Math.random() * sides) + 1;
const r2 = Math.floor(Math.random() * sides) + 1;
const keptVal = advantage ? Math.max(r1, r2) : Math.min(r1, r2);
return {
dice: [{ value: keptVal, original: keptVal, exploded: false, explosions: [], rerolledFrom: null, dropped: false }],
kept: [{ value: keptVal, original: keptVal, exploded: false, explosions: [], rerolledFrom: null, dropped: false }],
dropped: [],
modifier,
total: keptVal + modifier,
notation: '',
advantageRolls: [r1, r2],
};
}
/** Build notation string from parsed object (basic only, for simple sync) */
export 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;
}
/** Check if notation contains advanced features beyond basic XdY±Z */
export function isAdvancedNotation(raw: string): boolean {
const s = normalize(raw);
return /(kh|kl|dh|dl|!|[<>]?r\d)/.test(s);
}
+3 -1
View File
@@ -70,7 +70,9 @@ const generator = generators.find((g) => g.slug === 'dice')!;
'Rolling dice for D&D, Pathfinder, or any tabletop RPG.',
'Using dice notation shortcuts like 2d6+3 or 1d20 for fast rolling.',
'Rolling with advantage or disadvantage for D&D 5e mechanics.',
'Generating character stats with the 4d6 drop-lowest method.',
'Generating character stats with 4d6 drop-lowest method.',
'Rolling exploding dice for Savage Worlds or house rules (e.g., 3d6!).',
'Using keep/drop modifiers like 4d6kh3 to keep highest rolls.',
]}
/>
</div>
+4 -2
View File
@@ -30,12 +30,14 @@ const generator = generators.find((g) => g.slug === 'dice')!;
'Введите нотацию, например 2d6+3 или 1d20 — или настройте кубики вручную.',
'Выберите режим: Обычный, Преимущество или Помеха (только для одного кубика).',
'Нажмите «Бросить» — результаты отобразятся мгновенно.',
'Нажмите «Копировать», чтобы сохранить результат.',
'Максимумы подсвечены фиолетовым, единицы — красным.',
]} whenTo={[
'Броски в настольных ролевых играх (D&D и другие).',
'Быстрые броски через нотацию: 2d6+3, 1d20 и любые другие.',
'Преимущество и помеха для механик D&D 5e.',
'Бросок характеристик: 4d6, отбросить наименьший.',
'Бросок характеристик: 4d6, отбросить наименьший (4d6dl1).',
'Взрывающиеся кубики для Savage Worlds или хаус-рулов (например, 3d6!).',
'Использование keep/drop модификаторов типа 4d6kh3 (оставить три лучших).',
]} />
</div>
</BaseLayout>