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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user