feat(control): 3D-кубики в отдельном окне
Бросок синхронизирован на пульте и презентации; d100 как десятки; оверлей скрывается только кнопкой «Убрать». Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user