P13: dice — crit/fumble animation + roll visibility
- Crit/fumble emphasis: RollTray (and the Dice page result) highlight a nat-20 / critical-success as "✦ Critical ✦" (gold pop + glow) and a nat-1 / critical- failure as "✦ Fumble ✦" (red shake). naturalD20() exported from notation. - DM sees players' rolls: RollFeed now also renders on the Combat page (players' rolls already broadcast to the GM). - Players see the DM's public rolls: new gmRoll message — a hosting GM's rolls (Dice page + rollAndShow) broadcast to the table as "GM". - DM secret-roll toggle (rollStore.secret) on the Dice page suppresses the broadcast. - Realtime e2e: GM public roll appears in the player's table feed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,22 +1,42 @@
|
||||
import { useRollStore } from '@/stores/rollStore';
|
||||
import { useRollStore, type TrayRoll } from '@/stores/rollStore';
|
||||
import { DEGREE_COLOR, DEGREE_LABEL } from '@/lib/dice/check';
|
||||
import { naturalD20 } from '@/lib/dice/notation';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
/** A nat-20 / critical-success is a crit; a nat-1 / critical-failure is a fumble. */
|
||||
function critKind(roll: TrayRoll): 'crit' | 'fumble' | null {
|
||||
if (roll.degree === 'critical-success') return 'crit';
|
||||
if (roll.degree === 'critical-failure') return 'fumble';
|
||||
const n = naturalD20(roll.result);
|
||||
if (n === 20) return 'crit';
|
||||
if (n === 1) return 'fumble';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Floating result of the most recent roll, shared across the whole app. */
|
||||
export function RollTray() {
|
||||
const last = useRollStore((s) => s.last);
|
||||
const dismiss = useRollStore((s) => s.dismiss);
|
||||
if (!last) return null;
|
||||
|
||||
const crit = critKind(last);
|
||||
|
||||
return (
|
||||
<div data-roll-tray className="pointer-events-none fixed bottom-4 right-4 z-50 w-72 print:hidden">
|
||||
<div
|
||||
key={last.seq}
|
||||
className="animate-dice-settle pointer-events-auto rounded-lg border border-line bg-panel p-4 shadow-2xl"
|
||||
className={cn(
|
||||
'pointer-events-auto rounded-lg border bg-panel p-4 shadow-2xl',
|
||||
crit === 'crit' && 'animate-crit animate-crit-glow border-warning',
|
||||
crit === 'fumble' && 'animate-fumble border-danger',
|
||||
!crit && 'animate-dice-settle border-line',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
{last.label && <div className="truncate text-xs font-medium text-muted">{last.label}</div>}
|
||||
{crit === 'crit' && <div className="text-xs font-bold uppercase tracking-wide text-warning">✦ Critical ✦</div>}
|
||||
{crit === 'fumble' && <div className="text-xs font-bold uppercase tracking-wide text-danger">✦ Fumble ✦</div>}
|
||||
{last.degree && (
|
||||
<div className={cn('text-xs font-semibold', DEGREE_COLOR[last.degree])}>
|
||||
{DEGREE_LABEL[last.degree]}
|
||||
@@ -26,7 +46,7 @@ export function RollTray() {
|
||||
</div>
|
||||
<button onClick={dismiss} aria-label="Dismiss roll" className="text-muted hover:text-ink">✕</button>
|
||||
</div>
|
||||
<div className="mt-1 font-display text-4xl font-bold text-accent">{last.result.total}</div>
|
||||
<div className={cn('mt-1 font-display text-4xl font-bold', crit === 'crit' ? 'text-warning' : crit === 'fumble' ? 'text-danger' : 'text-accent')}>{last.result.total}</div>
|
||||
<div className="mt-1 font-mono text-xs text-muted">{last.result.breakdown}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user