Phase 4: interactive dice & sheet rolling
- Dice notation: exploding (Nd6!) and reroll-below (Nd6r2), capped + tested - Degrees of success / roll-vs-DC (PF2e ±10 + nat 20/1 steps; 5e crit on nat 20/1) - Global roll tray (shared store + RollTray in layout) with animated result + degree - Roll from the character sheet: skills, saves, and attack to-hit/damage are clickable and post to the tray + history (rollAndShow/rollCheck helpers) - Saved roll macros per campaign (persisted) on the Dice page 10 new unit tests (notation explode/reroll, degrees), interactive-dice e2e. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import type { Character } from '@/lib/schemas';
|
||||
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
|
||||
import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
|
||||
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
|
||||
import { charactersRepo } from '@/lib/db/repositories';
|
||||
import { PF2E_SAVES } from '@/lib/rules/pf2e/skills';
|
||||
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
||||
import { rollCheck } from '@/lib/useRoll';
|
||||
import { Page } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input, Select } from '@/components/ui/Input';
|
||||
@@ -151,6 +152,7 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
rank={(c.saveRanks[s.ability] as ProficiencyRank) ?? 'untrained'}
|
||||
ranks={ranks}
|
||||
onRank={(rank) => update({ saveRanks: { ...c.saveRanks, [s.ability]: rank } })}
|
||||
system={c.system}
|
||||
/>
|
||||
))
|
||||
: ABILITIES.map((a) => (
|
||||
@@ -161,6 +163,7 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
rank={(c.saveRanks[a] as ProficiencyRank) ?? 'untrained'}
|
||||
ranks={ranks}
|
||||
onRank={(rank) => update({ saveRanks: { ...c.saveRanks, [a]: rank } })}
|
||||
system={c.system}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -178,6 +181,7 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
rank={(c.skillRanks[s.key] as ProficiencyRank) ?? 'untrained'}
|
||||
ranks={ranks}
|
||||
onRank={(rank) => update({ skillRanks: { ...c.skillRanks, [s.key]: rank } })}
|
||||
system={c.system}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -256,16 +260,24 @@ function RankRow({
|
||||
rank,
|
||||
ranks,
|
||||
onRank,
|
||||
system,
|
||||
}: {
|
||||
label: string;
|
||||
modifier: number;
|
||||
rank: ProficiencyRank;
|
||||
ranks: ProficiencyRank[];
|
||||
onRank: (r: ProficiencyRank) => void;
|
||||
system: SystemId;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border border-line bg-panel px-3 py-1.5">
|
||||
<span className="w-8 font-display text-lg font-semibold text-accent">{formatModifier(modifier)}</span>
|
||||
<button
|
||||
onClick={() => rollCheck(modifier, label, { system })}
|
||||
className="w-9 rounded font-display text-lg font-semibold text-accent hover:bg-accent/10"
|
||||
title={`Roll ${label}`}
|
||||
>
|
||||
{formatModifier(modifier)}
|
||||
</button>
|
||||
<span className="flex-1 truncate text-sm text-ink">{label}</span>
|
||||
<Select className="w-auto py-1 text-xs" value={rank} onChange={(e) => onRank(e.target.value as ProficiencyRank)}>
|
||||
{ranks.map((r) => (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getSystem, ABILITY_ABBR } from '@/lib/rules';
|
||||
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
|
||||
import type { Attack } from '@/lib/schemas';
|
||||
import { formatModifier } from '@/lib/format';
|
||||
import { rollAndShow } from '@/lib/useRoll';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input, Select } from '@/components/ui/Input';
|
||||
import { NumberField } from '@/components/ui/NumberField';
|
||||
@@ -66,9 +67,22 @@ export function AttacksSection({ c, update }: SectionProps) {
|
||||
</Select>
|
||||
<label className="text-xs text-muted">dice<Input className="ml-1 inline-block h-8 w-20" value={a.damageDice} onChange={(e) => patch(a.id, { damageDice: e.target.value })} aria-label="Damage dice" /></label>
|
||||
<label className="text-xs text-muted">+item<NumberField className="ml-1 w-14" value={a.itemBonus} onChange={(v) => patch(a.id, { itemBonus: v })} aria-label="Item bonus" /></label>
|
||||
<span className="ml-auto rounded bg-elevated px-2 py-1 text-sm">
|
||||
<span className="text-muted">to hit </span><span className="font-display font-semibold text-accent">{formatModifier(result.toHit)}</span>
|
||||
<span className="text-muted"> · dmg </span><span className="font-mono text-ink">{result.damage}{a.damageType ? ` ${a.damageType}` : ''}</span>
|
||||
<span className="ml-auto flex items-center gap-1 rounded bg-elevated px-2 py-1 text-sm">
|
||||
<button
|
||||
onClick={() => rollAndShow({ expression: `1d20${formatModifier(result.toHit)}`, label: `${a.name} — attack` })}
|
||||
className="rounded px-1 font-display font-semibold text-accent hover:bg-accent/10"
|
||||
title="Roll attack"
|
||||
>
|
||||
{formatModifier(result.toHit)}
|
||||
</button>
|
||||
<span className="text-muted">to hit ·</span>
|
||||
<button
|
||||
onClick={() => rollAndShow({ expression: result.damage, label: `${a.name} — damage` })}
|
||||
className="rounded px-1 font-mono text-ink hover:bg-accent/10"
|
||||
title="Roll damage"
|
||||
>
|
||||
{result.damage}{a.damageType ? ` ${a.damageType}` : ''}
|
||||
</button>
|
||||
</span>
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(a.id)} aria-label={`Remove ${a.name}`}>✕</Button>
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user