feat: dice macros — persisted named rolls (Grimjaw attack, fire damage…)
Type a notation in the input, name it, '+ macro' saves it. Chips roll with the template relabel path so history reads 'Attack (1d20+5) → 17'; × deletes. Roadmap 1.7.
This commit is contained in:
@@ -24,12 +24,22 @@ function rollDie(sides: number): number {
|
|||||||
// with a runnable self-check (scripts/check-dice.ts). The Mode + DieResult
|
// with a runnable self-check (scripts/check-dice.ts). The Mode + DieResult
|
||||||
// types are imported from there too.
|
// types are imported from there too.
|
||||||
|
|
||||||
|
interface Macro {
|
||||||
|
label: string;
|
||||||
|
notation: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function DiceRoller() {
|
export function DiceRoller() {
|
||||||
const [input, setInput] = usePersistentState<string>("dice.input", "1d20");
|
const [input, setInput] = usePersistentState<string>("dice.input", "1d20");
|
||||||
const [results, setResults] = usePersistentState<DieResult[]>("dice.history", []);
|
const [results, setResults] = usePersistentState<DieResult[]>("dice.history", []);
|
||||||
const [lastTotal, setLastTotal] = useState<number | null>(null);
|
const [lastTotal, setLastTotal] = useState<number | null>(null);
|
||||||
const [lastBreakdown, setLastBreakdown] = useState<DieResult | null>(null);
|
const [lastBreakdown, setLastBreakdown] = useState<DieResult | null>(null);
|
||||||
const [mode, setMode] = usePersistentState<Mode>("dice.mode", "normal");
|
const [mode, setMode] = usePersistentState<Mode>("dice.mode", "normal");
|
||||||
|
// ponytail: macros = the DM's saved attack/damage rolls. Persisted; the
|
||||||
|
// notation is snapshot at save time ("Grimjaw attack" → "1d20+5"), not a
|
||||||
|
// live reference to the input box.
|
||||||
|
const [macros, setMacros] = usePersistentState<Macro[]>("dice.macros", []);
|
||||||
|
const [macroLabel, setMacroLabel] = useState("");
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
const roll = useCallback(
|
const roll = useCallback(
|
||||||
@@ -120,6 +130,18 @@ export function DiceRoller() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addMacro() {
|
||||||
|
const label = macroLabel.trim();
|
||||||
|
const notation = input.trim();
|
||||||
|
if (!label || !parseNotation(notation)) {
|
||||||
|
addToast("Type a name and a valid notation first", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMacros((prev) => [...prev, { label, notation }]);
|
||||||
|
setMacroLabel("");
|
||||||
|
addToast(`Macro "${label}" saved (${notation})`, "success");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 h-full">
|
<div className="flex flex-col gap-3 h-full">
|
||||||
{/* Big result display */}
|
{/* Big result display */}
|
||||||
@@ -209,6 +231,49 @@ export function DiceRoller() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Macros — the DM's named rolls. Same relabel path as templates. */}
|
||||||
|
{macros.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 items-center">
|
||||||
|
<span className="text-[var(--color-text-dim)] text-[10px] uppercase tracking-wider">Macros</span>
|
||||||
|
{macros.map((m, i) => (
|
||||||
|
<span key={i} className="flex items-center rounded-lg bg-[var(--color-gold-glow)] border border-[var(--color-border-glass)] overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => applyTemplate(m)}
|
||||||
|
className="px-2.5 py-1 text-xs text-[var(--color-gold-bright)] hover:bg-[var(--color-bg-card)] transition-colors cursor-pointer"
|
||||||
|
title={`${m.label} — ${m.notation}`}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMacros((prev) => prev.filter((_, j) => j !== i))}
|
||||||
|
className="px-1.5 py-1 text-[10px] text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer transition-colors"
|
||||||
|
title="Delete macro"
|
||||||
|
aria-label={`Delete macro ${m.label}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<input
|
||||||
|
className="flex-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] px-2.5 py-1 text-xs text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)]"
|
||||||
|
value={macroLabel}
|
||||||
|
onChange={(e) => setMacroLabel(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && addMacro()}
|
||||||
|
placeholder={`save "${input}" as macro… (type a name)`}
|
||||||
|
aria-label="Name for a new dice macro"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={addMacro}
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2.5 py-1 text-xs text-[var(--color-text-secondary)] hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||||
|
title={`Save ${input} as a named roll`}
|
||||||
|
>
|
||||||
|
+ macro
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* History */}
|
{/* History */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user