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 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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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); });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user