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
+4
View File
@@ -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)}
+36 -10
View File
@@ -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>