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:
2026-06-08 16:35:48 +02:00
parent d4ae60f199
commit d94df41a95
12 changed files with 129 additions and 25 deletions
+6
View File
@@ -46,6 +46,12 @@ test('GM hosts a session; a player on another device sees live combat', async ({
await player.getByTestId('player-live').getByRole('link').click(); await player.getByTestId('player-live').getByRole('link').click();
await expect(player.getByText('Lia the Brave').first()).toBeVisible(); await expect(player.getByText('Lia the Brave').first()).toBeVisible();
// GM rolls publicly on the Dice page → the player sees it in the table feed.
await gm.getByLabel('Primary').getByRole('link', { name: 'Dice' }).click();
await gm.getByRole('button', { name: 'Roll', exact: true }).click();
await expect(player.getByText('Table rolls')).toBeVisible();
await expect(player.getByText('GM').first()).toBeVisible();
await gmCtx.close(); await gmCtx.close();
await playerCtx.close(); await playerCtx.close();
}); });
+1
View File
@@ -100,6 +100,7 @@ export function buildServer() {
case 'seatGrant': hub.seatGrant(sender, m.gmSecret, m.targetPlayerId, m.character); break; case 'seatGrant': hub.seatGrant(sender, m.gmSecret, m.targetPlayerId, m.character); break;
case 'playerPatch': hub.playerPatch(sender, m.characterId, m.diff); break; case 'playerPatch': hub.playerPatch(sender, m.characterId, m.diff); break;
case 'playerRoll': hub.playerRoll(sender, m.characterId, m.label, m.expression, m.total, m.breakdown); break; case 'playerRoll': hub.playerRoll(sender, m.characterId, m.label, m.expression, m.total, m.breakdown); break;
case 'gmRoll': hub.gmRoll(sender, m.gmSecret, m.label, m.expression, m.total, m.breakdown); break;
} }
}); });
socket.on('close', () => { clearInterval(refill); hub.disconnect(sender); }); socket.on('close', () => { clearInterval(refill); hub.disconnect(sender); });
+9
View File
@@ -184,6 +184,15 @@ export class RoomHub {
for (const p of room.players) if (p !== socket) p.send(msg); for (const p of room.players) if (p !== socket) p.send(msg);
} }
/** The GM broadcasts one of their own (public) rolls to the players. */
gmRoll(socket: Sender, gmSecret: string, label: string, expression: string, total: number, breakdown: string): void {
const room = this.gmRoom(socket, gmSecret);
if (!room) return;
room.lastActivity = this.now();
const msg = { t: 'rollBroadcast', playerName: 'GM', label, expression, total, breakdown } as const;
for (const p of room.players) p.send(msg); // players only; the GM already sees it locally
}
disconnect(socket: Sender): void { disconnect(socket: Sender): void {
const conn = this.conns.get(socket); const conn = this.conns.get(socket);
if (!conn) return; if (!conn) return;
+23 -3
View File
@@ -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 { DEGREE_COLOR, DEGREE_LABEL } from '@/lib/dice/check';
import { naturalD20 } from '@/lib/dice/notation';
import { cn } from '@/lib/cn'; 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. */ /** Floating result of the most recent roll, shared across the whole app. */
export function RollTray() { export function RollTray() {
const last = useRollStore((s) => s.last); const last = useRollStore((s) => s.last);
const dismiss = useRollStore((s) => s.dismiss); const dismiss = useRollStore((s) => s.dismiss);
if (!last) return null; if (!last) return null;
const crit = critKind(last);
return ( return (
<div data-roll-tray className="pointer-events-none fixed bottom-4 right-4 z-50 w-72 print:hidden"> <div data-roll-tray className="pointer-events-none fixed bottom-4 right-4 z-50 w-72 print:hidden">
<div <div
key={last.seq} 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="flex items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
{last.label && <div className="truncate text-xs font-medium text-muted">{last.label}</div>} {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 && ( {last.degree && (
<div className={cn('text-xs font-semibold', DEGREE_COLOR[last.degree])}> <div className={cn('text-xs font-semibold', DEGREE_COLOR[last.degree])}>
{DEGREE_LABEL[last.degree]} {DEGREE_LABEL[last.degree]}
@@ -26,7 +46,7 @@ export function RollTray() {
</div> </div>
<button onClick={dismiss} aria-label="Dismiss roll" className="text-muted hover:text-ink"></button> <button onClick={dismiss} aria-label="Dismiss roll" className="text-muted hover:text-ink"></button>
</div> </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 className="mt-1 font-mono text-xs text-muted">{last.result.breakdown}</div>
</div> </div>
</div> </div>
+4
View File
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { useEncounters, useEncounter } from './hooks'; import { useEncounters, useEncounter } from './hooks';
import { EncounterTracker } from './EncounterTracker'; import { EncounterTracker } from './EncounterTracker';
import { RollFeed } from '@/features/player/RollFeed';
export function CombatPage() { export function CombatPage() {
return <RequireCampaign>{(campaign) => <Combat campaign={campaign} />}</RequireCampaign>; return <RequireCampaign>{(campaign) => <Combat campaign={campaign} />}</RequireCampaign>;
@@ -100,6 +101,9 @@ function Combat({ campaign }: { campaign: Campaign }) {
</div> </div>
)} )}
{/* Players' live rolls reach the GM here while hosting a session. */}
<RollFeed />
<Modal <Modal
open={creating} open={creating}
onClose={() => setCreating(false)} onClose={() => setCreating(false)}
+30 -4
View File
@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useLiveQuery } from 'dexie-react-hooks'; import { useLiveQuery } from 'dexie-react-hooks';
import { rollDice, applyRollMode, DiceParseError, type RollResult } from '@/lib/dice/notation'; import { rollDice, applyRollMode, naturalD20, DiceParseError, type RollResult } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng'; import { createRng } from '@/lib/rng';
import { diceRepo } from '@/lib/db/repositories'; import { diceRepo } from '@/lib/db/repositories';
import { broadcastGmRoll } from '@/lib/sync/wsSync';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
import { useMacroStore } from '@/stores/macroStore'; import { useMacroStore } from '@/stores/macroStore';
import { useRollStore } from '@/stores/rollStore'; import { useRollStore } from '@/stores/rollStore';
import { useSessionStore } from '@/stores/sessionStore';
import { Page, PageHeader } from '@/components/ui/Page'; import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
@@ -29,6 +31,9 @@ export function DicePage() {
const mode = useRollStore((s) => s.mode); const mode = useRollStore((s) => s.mode);
const toggleMode = useRollStore((s) => s.toggleMode); const toggleMode = useRollStore((s) => s.toggleMode);
const secret = useRollStore((s) => s.secret);
const toggleSecret = useRollStore((s) => s.toggleSecret);
const isGm = useSessionStore((s) => s.role) === 'gm';
const roll = async (expression: string, label = '') => { const roll = async (expression: string, label = '') => {
try { try {
@@ -45,6 +50,7 @@ export function DicePage() {
total: result.total, total: result.total,
breakdown: result.breakdown, breakdown: result.breakdown,
}); });
if (!useRollStore.getState().secret) broadcastGmRoll((label + modeTag).trim() || 'Roll', finalExpr, result.total, result.breakdown);
} catch (e) { } catch (e) {
setError(e instanceof DiceParseError ? e.message : 'Could not roll that expression'); setError(e instanceof DiceParseError ? e.message : 'Could not roll that expression');
} }
@@ -105,12 +111,26 @@ export function DicePage() {
> >
Disadvantage Disadvantage
</Button> </Button>
{isGm && (
<Button
size="sm"
variant={secret ? 'primary' : 'secondary'}
aria-pressed={secret}
onClick={toggleSecret}
title="When on, your rolls are NOT shared with the players"
>
🤫 Secret
</Button>
)}
</div> </div>
{mode !== 'normal' && ( {mode !== 'normal' && (
<p className="mt-2 text-xs text-accent"> <p className="mt-2 text-xs text-accent">
{mode === 'advantage' ? 'Advantage' : 'Disadvantage'} is on the next single die rolls twice, keeping the {mode === 'advantage' ? 'highest' : 'lowest'}. {mode === 'advantage' ? 'Advantage' : 'Disadvantage'} is on the next single die rolls twice, keeping the {mode === 'advantage' ? 'highest' : 'lowest'}.
</p> </p>
)} )}
{isGm && secret && (
<p className="mt-2 text-xs text-warning">🤫 Secret is on your rolls are not shared with the players.</p>
)}
{/* Saved rolls (macros) */} {/* Saved rolls (macros) */}
<div className="mt-4"> <div className="mt-4">
@@ -150,15 +170,21 @@ export function DicePage() {
{error && <p className="mt-4 text-sm text-danger">{error}</p>} {error && <p className="mt-4 text-sm text-danger">{error}</p>}
{last && ( {last && (() => {
<div className="mt-6 rounded-lg border border-line bg-panel p-6 text-center"> const nat = naturalD20(last);
const crit = nat === 20 ? 'crit' : nat === 1 ? 'fumble' : null;
return (
<div className={cn('mt-6 rounded-lg border bg-panel p-6 text-center', crit === 'crit' ? 'animate-crit-glow border-warning' : crit === 'fumble' ? 'border-danger' : 'border-line')}>
{crit === 'crit' && <div className="mb-1 text-sm font-bold uppercase tracking-wide text-warning"> Critical! </div>}
{crit === 'fumble' && <div className="mb-1 text-sm font-bold uppercase tracking-wide text-danger"> Fumble! </div>}
<AnimatedTotal key={rollCount} result={last} /> <AnimatedTotal key={rollCount} result={last} />
<div className="mt-2 font-mono text-sm text-muted">{last.breakdown}</div> <div className="mt-2 font-mono text-sm text-muted">{last.breakdown}</div>
<span className="sr-only" aria-live="polite"> <span className="sr-only" aria-live="polite">
Rolled {last.expression}: {last.total} Rolled {last.expression}: {last.total}
</span> </span>
</div> </div>
)} );
})()}
</div> </div>
<aside> <aside>
+8
View File
@@ -46,6 +46,14 @@ export interface RollResult {
breakdown: string; breakdown: string;
} }
/** Natural face of the first kept d20 (for crit/fumble emphasis), if any. */
export function naturalD20(result: RollResult): number | undefined {
for (const t of result.terms) {
for (const d of t.dice) if (d.sides === 20 && d.kept) return d.value;
}
return undefined;
}
export class DiceParseError extends Error {} export class DiceParseError extends Error {}
export type RollMode = 'normal' | 'advantage' | 'disadvantage'; export type RollMode = 'normal' | 'advantage' | 'disadvantage';
+1
View File
@@ -112,6 +112,7 @@ export const clientMessageSchema = z.discriminatedUnion('t', [
z.object({ t: z.literal('playerRoll'), characterId: z.string(), label: z.string().max(120), expression: z.string().max(200), total: int, breakdown: z.string().max(600) }), z.object({ t: z.literal('playerRoll'), characterId: z.string(), label: z.string().max(120), expression: z.string().max(200), total: int, breakdown: z.string().max(600) }),
// two-way play (GM → server) // two-way play (GM → server)
z.object({ t: z.literal('seatGrant'), gmSecret: z.string(), targetPlayerId: z.string(), character: characterSchema }), z.object({ t: z.literal('seatGrant'), gmSecret: z.string(), targetPlayerId: z.string(), character: characterSchema }),
z.object({ t: z.literal('gmRoll'), gmSecret: z.string(), label: z.string().max(120), expression: z.string().max(200), total: int, breakdown: z.string().max(600) }),
]); ]);
export type ClientMessage = z.infer<typeof clientMessageSchema>; export type ClientMessage = z.infer<typeof clientMessageSchema>;
+5
View File
@@ -144,6 +144,11 @@ export function sendPlayerRoll(characterId: string, label: string, expression: s
send({ t: 'playerRoll', characterId, label, expression, total, breakdown }); send({ t: 'playerRoll', characterId, label, expression, total, breakdown });
} }
/** GM: broadcast one of their own (public) rolls to the players. No-op unless hosting. */
export function broadcastGmRoll(label: string, expression: string, total: number, breakdown: string): void {
if (useSessionStore.getState().role === 'gm' && gmSecret) send({ t: 'gmRoll', gmSecret, label, expression, total, breakdown });
}
/** GM: approve a seat request, handing the player their authoritative sheet. */ /** GM: approve a seat request, handing the player their authoritative sheet. */
export function grantSeat(targetPlayerId: string, character: Character): void { export function grantSeat(targetPlayerId: string, character: Character): void {
if (useSessionStore.getState().role === 'gm') { if (useSessionStore.getState().role === 'gm') {
+8 -12
View File
@@ -1,9 +1,11 @@
import { rollDice, applyRollMode } from '@/lib/dice/notation'; import { rollDice, applyRollMode, naturalD20 } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng'; import { createRng } from '@/lib/rng';
import { degreeOfSuccess, type Degree } from '@/lib/dice/check'; import { degreeOfSuccess, type Degree } from '@/lib/dice/check';
import { diceRepo } from '@/lib/db/repositories'; import { diceRepo } from '@/lib/db/repositories';
import { broadcastGmRoll } from '@/lib/sync/wsSync';
import { useRollStore } from '@/stores/rollStore'; import { useRollStore } from '@/stores/rollStore';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
import { useSessionStore } from '@/stores/sessionStore';
import type { SystemId } from '@/lib/rules'; import type { SystemId } from '@/lib/rules';
export interface RollOptions { export interface RollOptions {
@@ -14,17 +16,6 @@ export interface RollOptions {
system?: SystemId; system?: SystemId;
} }
/** Find the natural face of the (kept) d20, for crit/degree handling. */
function naturalD20(result: ReturnType<typeof rollDice>): number | undefined {
for (const t of result.terms) {
if (t.term.kind === 'dice' && t.term.sides === 20) {
const kept = t.dice.find((d) => d.kept);
if (kept) return kept.value;
}
}
return undefined;
}
/** /**
* Roll an expression, show it in the global tray, and persist it to history. * Roll an expression, show it in the global tray, and persist it to history.
* Returns the result + degree. Used by the dice page, sheets, and statblocks. * Returns the result + degree. Used by the dice page, sheets, and statblocks.
@@ -57,6 +48,11 @@ export function rollAndShow(opts: RollOptions): { total: number; degree?: Degree
breakdown: result.breakdown, breakdown: result.breakdown,
}); });
// A hosting GM's public rolls go to the table, unless they've toggled "secret".
if (useSessionStore.getState().role === 'gm' && !useRollStore.getState().secret) {
broadcastGmRoll(label.trim() || 'Roll', opts.expression, result.total, result.breakdown);
}
return { total: result.total, ...(degree ? { degree } : {}) }; return { total: result.total, ...(degree ? { degree } : {}) };
} }
+5
View File
@@ -15,19 +15,24 @@ interface RollState {
last: TrayRoll | null; last: TrayRoll | null;
/** advantage/disadvantage toggle applied to d20 rolls everywhere */ /** advantage/disadvantage toggle applied to d20 rolls everywhere */
mode: RollMode; mode: RollMode;
/** GM only: when on, the GM's rolls are NOT broadcast to the table */
secret: boolean;
push: (roll: Omit<TrayRoll, 'seq'>) => void; push: (roll: Omit<TrayRoll, 'seq'>) => void;
dismiss: () => void; dismiss: () => void;
setMode: (mode: RollMode) => void; setMode: (mode: RollMode) => void;
/** click the same mode again to turn it off */ /** click the same mode again to turn it off */
toggleMode: (mode: Exclude<RollMode, 'normal'>) => void; toggleMode: (mode: Exclude<RollMode, 'normal'>) => void;
toggleSecret: () => void;
} }
let seq = 0; let seq = 0;
export const useRollStore = create<RollState>((set, get) => ({ export const useRollStore = create<RollState>((set, get) => ({
last: null, last: null,
mode: 'normal', mode: 'normal',
secret: false,
push: (roll) => set({ last: { ...roll, seq: ++seq } }), push: (roll) => set({ last: { ...roll, seq: ++seq } }),
dismiss: () => set({ last: null }), dismiss: () => set({ last: null }),
setMode: (mode) => set({ mode }), setMode: (mode) => set({ mode }),
toggleMode: (mode) => set({ mode: get().mode === mode ? 'normal' : mode }), toggleMode: (mode) => set({ mode: get().mode === mode ? 'normal' : mode }),
toggleSecret: () => set({ secret: !get().secret }),
})); }));
+23
View File
@@ -139,3 +139,26 @@ body {
.animate-dice-rolling { .animate-dice-rolling {
animation: dice-shake 0.12s linear infinite; animation: dice-shake 0.12s linear infinite;
} }
/* Crit / fumble emphasis on the roll tray */
@keyframes crit-pop {
0% { transform: scale(0.6) rotate(-10deg); }
45% { transform: scale(1.2) rotate(5deg); }
70% { transform: scale(0.96); }
100% { transform: scale(1) rotate(0deg); }
}
.animate-crit { animation: crit-pop 0.55s cubic-bezier(0.22, 1, 0.36, 1); }
@keyframes crit-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 200, 60, 0); }
50% { box-shadow: 0 0 24px 5px rgba(255, 200, 60, 0.8); }
}
.animate-crit-glow { animation: crit-glow 1s ease-in-out 2; }
@keyframes fumble-fall {
0% { transform: translateY(-5px) rotate(0); }
20% { transform: translateX(-7px) rotate(-5deg); }
40% { transform: translateX(6px) rotate(4deg); }
60% { transform: translateX(-5px) rotate(-3deg); }
80% { transform: translateX(3px) rotate(2deg); }
100% { transform: translateX(0) rotate(0); }
}
.animate-fumble { animation: fumble-fall 0.6s ease-in-out; }