feat(control): 3D-кубики в отдельном окне
Бросок синхронизирован на пульте и презентации; d100 как десятки; оверлей скрывается только кнопкой «Убрать». Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import { DICE_SIDES, type DiceSides } from '../../shared/types/diceRoll';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { useDiceRollState } from '../shared/dice/useDiceRollState';
|
||||
import { Button } from '../shared/ui/controls';
|
||||
|
||||
import styles from './DiceApp.module.css';
|
||||
|
||||
export function DiceApp() {
|
||||
const { t } = useEditorI18n();
|
||||
const api = getDndApi();
|
||||
const [state, diceApi] = useDiceRollState();
|
||||
const [sides, setSides] = useState<DiceSides>(20);
|
||||
const [count, setCount] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
void api.invoke(ipcChannels.windows.closeDice, {});
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="dice-app">
|
||||
<div className={styles.sectionLabel}>{t('dice.sides')}</div>
|
||||
<div className={styles.sidesGrid}>
|
||||
{DICE_SIDES.map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
variant={sides === s ? 'primary' : 'ghost'}
|
||||
onClick={() => setSides(s)}
|
||||
data-testid={`dice-side-${s}`}
|
||||
>
|
||||
d{s}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.sectionLabel}>{t('dice.count')}</div>
|
||||
<div className={styles.countRow}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setCount((c) => Math.max(1, c - 1))}
|
||||
ariaLabel={t('dice.dec')}
|
||||
>
|
||||
−
|
||||
</Button>
|
||||
<div className={styles.countValue} data-testid="dice-count">
|
||||
{count}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setCount((c) => Math.min(20, c + 1))}
|
||||
ariaLabel={t('dice.inc')}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void diceApi.dispatch({ kind: 'roll', count, sides })}
|
||||
data-testid="dice-roll"
|
||||
>
|
||||
{t('dice.roll')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={!state?.values.length && !state?.active}
|
||||
onClick={() => void diceApi.dispatch({ kind: 'clear' })}
|
||||
>
|
||||
{t('dice.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{state && state.values.length > 0 ? (
|
||||
<div className={styles.lastResult} data-testid="dice-last-result">
|
||||
<div className={styles.lastLabel}>{t('dice.result')}</div>
|
||||
<div className={styles.lastNotation}>{state.notation}</div>
|
||||
<div className={styles.valueChips} aria-label={t('dice.result')}>
|
||||
{state.values.map((v, i) => (
|
||||
<span key={`${state.revision}-${i}`} className={styles.valueChip}>
|
||||
{v}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.lastTotal}>
|
||||
{t('dice.total')}: <strong>{state.total}</strong>
|
||||
</div>
|
||||
{state.active ? <div className={styles.rollingHint}>{t('dice.rolling')}</div> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.emptyResult}>{t('dice.noResult')}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user