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:
@@ -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 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 playerCtx.close();
|
||||
});
|
||||
|
||||
@@ -100,6 +100,7 @@ export function buildServer() {
|
||||
case 'seatGrant': hub.seatGrant(sender, m.gmSecret, m.targetPlayerId, m.character); 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 'gmRoll': hub.gmRoll(sender, m.gmSecret, m.label, m.expression, m.total, m.breakdown); break;
|
||||
}
|
||||
});
|
||||
socket.on('close', () => { clearInterval(refill); hub.disconnect(sender); });
|
||||
|
||||
@@ -184,6 +184,15 @@ export class RoomHub {
|
||||
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 {
|
||||
const conn = this.conns.get(socket);
|
||||
if (!conn) return;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { useEncounters, useEncounter } from './hooks';
|
||||
import { EncounterTracker } from './EncounterTracker';
|
||||
import { RollFeed } from '@/features/player/RollFeed';
|
||||
|
||||
export function CombatPage() {
|
||||
return <RequireCampaign>{(campaign) => <Combat campaign={campaign} />}</RequireCampaign>;
|
||||
@@ -100,6 +101,9 @@ function Combat({ campaign }: { campaign: Campaign }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Players' live rolls reach the GM here while hosting a session. */}
|
||||
<RollFeed />
|
||||
|
||||
<Modal
|
||||
open={creating}
|
||||
onClose={() => setCreating(false)}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
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 { diceRepo } from '@/lib/db/repositories';
|
||||
import { broadcastGmRoll } from '@/lib/sync/wsSync';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { useMacroStore } from '@/stores/macroStore';
|
||||
import { useRollStore } from '@/stores/rollStore';
|
||||
import { useSessionStore } from '@/stores/sessionStore';
|
||||
import { Page, PageHeader } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
@@ -29,6 +31,9 @@ export function DicePage() {
|
||||
|
||||
const mode = useRollStore((s) => s.mode);
|
||||
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 = '') => {
|
||||
try {
|
||||
@@ -45,6 +50,7 @@ export function DicePage() {
|
||||
total: result.total,
|
||||
breakdown: result.breakdown,
|
||||
});
|
||||
if (!useRollStore.getState().secret) broadcastGmRoll((label + modeTag).trim() || 'Roll', finalExpr, result.total, result.breakdown);
|
||||
} catch (e) {
|
||||
setError(e instanceof DiceParseError ? e.message : 'Could not roll that expression');
|
||||
}
|
||||
@@ -105,12 +111,26 @@ export function DicePage() {
|
||||
>
|
||||
Disadvantage
|
||||
</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>
|
||||
{mode !== 'normal' && (
|
||||
<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'}.
|
||||
</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) */}
|
||||
<div className="mt-4">
|
||||
@@ -150,15 +170,21 @@ export function DicePage() {
|
||||
|
||||
{error && <p className="mt-4 text-sm text-danger">{error}</p>}
|
||||
|
||||
{last && (
|
||||
<div className="mt-6 rounded-lg border border-line bg-panel p-6 text-center">
|
||||
<AnimatedTotal key={rollCount} result={last} />
|
||||
<div className="mt-2 font-mono text-sm text-muted">{last.breakdown}</div>
|
||||
<span className="sr-only" aria-live="polite">
|
||||
Rolled {last.expression}: {last.total}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{last && (() => {
|
||||
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} />
|
||||
<div className="mt-2 font-mono text-sm text-muted">{last.breakdown}</div>
|
||||
<span className="sr-only" aria-live="polite">
|
||||
Rolled {last.expression}: {last.total}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<aside>
|
||||
|
||||
@@ -46,6 +46,14 @@ export interface RollResult {
|
||||
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 type RollMode = 'normal' | 'advantage' | 'disadvantage';
|
||||
|
||||
@@ -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) }),
|
||||
// two-way play (GM → server)
|
||||
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>;
|
||||
|
||||
|
||||
@@ -144,6 +144,11 @@ export function sendPlayerRoll(characterId: string, label: string, expression: s
|
||||
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. */
|
||||
export function grantSeat(targetPlayerId: string, character: Character): void {
|
||||
if (useSessionStore.getState().role === 'gm') {
|
||||
|
||||
+8
-12
@@ -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 { degreeOfSuccess, type Degree } from '@/lib/dice/check';
|
||||
import { diceRepo } from '@/lib/db/repositories';
|
||||
import { broadcastGmRoll } from '@/lib/sync/wsSync';
|
||||
import { useRollStore } from '@/stores/rollStore';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { useSessionStore } from '@/stores/sessionStore';
|
||||
import type { SystemId } from '@/lib/rules';
|
||||
|
||||
export interface RollOptions {
|
||||
@@ -14,17 +16,6 @@ export interface RollOptions {
|
||||
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.
|
||||
* 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,
|
||||
});
|
||||
|
||||
// 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 } : {}) };
|
||||
}
|
||||
|
||||
|
||||
@@ -15,19 +15,24 @@ interface RollState {
|
||||
last: TrayRoll | null;
|
||||
/** advantage/disadvantage toggle applied to d20 rolls everywhere */
|
||||
mode: RollMode;
|
||||
/** GM only: when on, the GM's rolls are NOT broadcast to the table */
|
||||
secret: boolean;
|
||||
push: (roll: Omit<TrayRoll, 'seq'>) => void;
|
||||
dismiss: () => void;
|
||||
setMode: (mode: RollMode) => void;
|
||||
/** click the same mode again to turn it off */
|
||||
toggleMode: (mode: Exclude<RollMode, 'normal'>) => void;
|
||||
toggleSecret: () => void;
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
export const useRollStore = create<RollState>((set, get) => ({
|
||||
last: null,
|
||||
mode: 'normal',
|
||||
secret: false,
|
||||
push: (roll) => set({ last: { ...roll, seq: ++seq } }),
|
||||
dismiss: () => set({ last: null }),
|
||||
setMode: (mode) => set({ mode }),
|
||||
toggleMode: (mode) => set({ mode: get().mode === mode ? 'normal' : mode }),
|
||||
toggleSecret: () => set({ secret: !get().secret }),
|
||||
}));
|
||||
|
||||
@@ -139,3 +139,26 @@ body {
|
||||
.animate-dice-rolling {
|
||||
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; }
|
||||
|
||||
Reference in New Issue
Block a user