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
+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>
);
}