9ac01d217f
Бросок синхронизирован на пульте и презентации; d100 как десятки; оверлей скрывается только кнопкой «Убрать». Co-authored-by: Cursor <cursoragent@cursor.com>
133 lines
3.9 KiB
TypeScript
133 lines
3.9 KiB
TypeScript
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>
|
|
);
|
|
}
|