feat(control): 3D-кубики в отдельном окне

Бросок синхронизирован на пульте и презентации; d100 как десятки; оверлей скрывается только кнопкой «Убрать».

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-04 13:01:34 +08:00
parent 86d6d157f0
commit 9ac01d217f
137 changed files with 958 additions and 1 deletions
@@ -0,0 +1,91 @@
.layer {
position: absolute;
inset: 0;
z-index: 40;
pointer-events: none;
opacity: 0;
transition: opacity 160ms ease;
}
.layer[data-active='1'] {
opacity: 1;
pointer-events: auto;
}
.backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.45);
}
.tray {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: min(920px, 92%);
height: min(520px, 78%);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
overflow: hidden;
background: rgba(10, 14, 20, 0.55);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45);
}
.host {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.host :global(canvas) {
width: 100% !important;
height: 100% !important;
display: block;
}
.result {
position: absolute;
left: 50%;
bottom: 14px;
transform: translateX(-50%);
display: flex;
align-items: baseline;
gap: 10px;
padding: 8px 14px;
border-radius: 999px;
background: rgba(9, 9, 11, 0.82);
border: 1px solid rgba(255, 255, 255, 0.14);
color: #f8fafc;
font-weight: 700;
pointer-events: none;
white-space: nowrap;
}
.resultNotation {
color: #93c5fd;
font-size: 13px;
}
.resultValues {
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.resultTotal {
font-size: 18px;
font-variant-numeric: tabular-nums;
}
.error {
position: absolute;
inset: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #fecaca;
font-size: 13px;
text-align: center;
pointer-events: none;
}
+132
View File
@@ -0,0 +1,132 @@
import React, { useEffect, useId, useRef, useState } from 'react';
import DiceBox from '@3d-dice/dice-box-threejs';
import {
formatPredeterminedDiceNotation,
isDiceSides,
type DiceRollState,
} from '../../../shared/types/diceRoll';
import { useDiceRollState } from './useDiceRollState';
import styles from './DiceRollLayer.module.css';
function diceAssetPath(): string {
const base = import.meta.env.BASE_URL || '/';
const joined = `${base}dice-box/`.replace(/([^:]\/)\/+/g, '$1');
return joined;
}
function parseNotationSides(notation: string): number | null {
const m = /^(\d+)d(\d+)$/i.exec(notation.trim());
if (!m) return null;
const sides = Number(m[2]);
return Number.isFinite(sides) ? sides : null;
}
function parseNotationCount(notation: string): number {
const m = /^(\d+)d(\d+)$/i.exec(notation.trim());
if (!m) return 1;
return Math.max(1, Number(m[1]) || 1);
}
/** Полноэкранный оверлей 3D-броска на превью и презентации. */
export function DiceRollLayer() {
const [state] = useDiceRollState();
const reactId = useId().replace(/:/g, '');
const hostId = `dice-roll-host-${reactId}`;
const boxRef = useRef<DiceBox | null>(null);
const rollingRef = useRef(false);
const lastRevisionRef = useRef(0);
const [ready, setReady] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const box = new DiceBox(`#${hostId}`, {
assetPath: diceAssetPath(),
sounds: false,
shadows: true,
theme_surface: 'green-felt',
theme_colorset: 'white',
theme_material: 'glass',
strength: 1,
gravity_multiplier: 400,
});
boxRef.current = box;
void box
.initialize()
.then(() => {
if (!cancelled) setReady(true);
})
.catch((err: unknown) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
});
return () => {
cancelled = true;
try {
box.clearDice?.();
} catch {
/* ignore */
}
boxRef.current = null;
const el = document.getElementById(hostId);
el?.replaceChildren();
};
}, [hostId]);
useEffect(() => {
if (!ready || !state?.active) return;
if (state.revision === lastRevisionRef.current) return;
if (rollingRef.current) return;
const box = boxRef.current;
if (!box) return;
const sidesRaw = parseNotationSides(state.notation);
if (sidesRaw == null || !isDiceSides(sidesRaw)) return;
const count = parseNotationCount(state.notation);
const notation = formatPredeterminedDiceNotation(count, sidesRaw, state.values);
const revision = state.revision;
lastRevisionRef.current = revision;
rollingRef.current = true;
void (async () => {
try {
await box.roll(notation);
} catch (err) {
console.error('[dice] roll failed', err);
} finally {
rollingRef.current = false;
}
})();
}, [ready, state]);
const visible = Boolean(state?.active);
return (
<div
className={styles.layer}
data-testid="dice-roll-layer"
data-active={visible ? '1' : '0'}
aria-hidden={visible ? undefined : true}
>
<div className={styles.backdrop} />
<div className={styles.tray}>
<div id={hostId} className={styles.host} />
{error ? <div className={styles.error}>{error}</div> : null}
{visible && state ? <DiceResultBadge state={state} /> : null}
</div>
</div>
);
}
function DiceResultBadge({ state }: { state: DiceRollState }) {
if (!state.values.length) return null;
return (
<div className={styles.result} data-testid="dice-roll-result">
<span className={styles.resultNotation}>{state.notation}</span>
<span className={styles.resultValues}>{state.values.join(' + ')}</span>
<span className={styles.resultTotal}>= {state.total}</span>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
declare module '@3d-dice/dice-box-threejs' {
export type DiceBoxConfig = {
assetPath?: string;
framerate?: number;
sounds?: boolean;
volume?: number;
shadows?: boolean;
theme_surface?: string;
theme_colorset?: string;
theme_texture?: string;
theme_material?: string;
gravity_multiplier?: number;
light_intensity?: number;
baseScale?: number;
strength?: number;
onRollComplete?: (results: unknown) => void;
};
export default class DiceBox {
constructor(elementSelector: string, options?: DiceBoxConfig);
initialize(): Promise<void>;
roll(notation: string): Promise<unknown>;
clearDice(): void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}
}
@@ -0,0 +1,33 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { DiceRollEvent, DiceRollState } from '../../../shared/types';
import { getDndApi } from '../dndApi';
export function useDiceRollState(): readonly [
DiceRollState | null,
{ dispatch: (event: DiceRollEvent) => Promise<DiceRollState> },
] {
const api = getDndApi();
const [state, setState] = useState<DiceRollState | null>(null);
useEffect(() => {
void api.invoke(ipcChannels.diceRoll.getState, {}).then((r) => {
setState(r.state);
});
return api.on(ipcChannels.diceRoll.stateChanged, ({ state: next }) => {
setState(next);
});
}, [api]);
return [
state,
{
dispatch: async (event) => {
const r = await api.invoke(ipcChannels.diceRoll.dispatch, { event });
setState(r.state);
return r.state;
},
},
] as const;
}