Files
DndGamePlayer/app/renderer/dice/DiceApp.tsx
T
Ivan Fontosh 712086ddca feat: кубики/таймер/кисть/спонсоры и задел игровых систем
Показ анимации кубиков игрокам, закрытие оверлеев с окнами, ручной ввод количества, меньший радиус кисти, блок спонсоров в «О программе»; плюс черновик game systems / character sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 15:37:44 +08:00

191 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState } from 'react';
import { ipcChannels } from '../../shared/ipc/contracts';
import {
DICE_COUNT_MAX,
DICE_COUNT_MIN,
DICE_SIDES,
clampDiceCount,
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);
const [countDraft, setCountDraft] = 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]);
const commitCountDraft = () => {
const trimmed = countDraft.trim();
if (trimmed === '') {
setCount(DICE_COUNT_MIN);
setCountDraft(String(DICE_COUNT_MIN));
return;
}
const parsed = Number(trimmed);
if (!Number.isFinite(parsed) || parsed < DICE_COUNT_MIN) {
setCount(DICE_COUNT_MIN);
setCountDraft(String(DICE_COUNT_MIN));
return;
}
const next = clampDiceCount(parsed);
setCount(next);
setCountDraft(String(next));
};
const setCountClamped = (n: number) => {
const next = clampDiceCount(n);
setCount(next);
setCountDraft(String(next));
};
const showToPlayers = state?.visibleToPlayers ?? true;
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={() => setCountClamped(count - 1)}
ariaLabel={t('dice.dec')}
disabled={count <= DICE_COUNT_MIN}
>
−
</Button>
<input
className={styles.countValue}
type="text"
inputMode="numeric"
value={countDraft}
onChange={(e) => {
const digits = e.target.value.replace(/\D/g, '');
if (digits === '') {
setCountDraft('');
return;
}
const n = Number(digits);
if (!Number.isFinite(n)) {
setCountDraft(String(DICE_COUNT_MIN));
setCount(DICE_COUNT_MIN);
return;
}
if (n > DICE_COUNT_MAX) {
setCountDraft(String(DICE_COUNT_MAX));
setCount(DICE_COUNT_MAX);
return;
}
setCountDraft(digits);
}}
onBlur={() => commitCountDraft()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commitCountDraft();
(e.currentTarget as HTMLInputElement).blur();
}
}}
aria-label={t('dice.count')}
data-testid="dice-count"
/>
<Button
variant="ghost"
onClick={() => setCountClamped(count + 1)}
ariaLabel={t('dice.inc')}
disabled={count >= DICE_COUNT_MAX}
>
+
</Button>
</div>
<label className={styles.checkRow}>
<input
type="checkbox"
checked={showToPlayers}
onChange={(e) =>
void diceApi.dispatch({ kind: 'setVisibleToPlayers', visible: e.target.checked })
}
data-testid="dice-show-to-players"
/>
<span>{t('dice.showAnimationToPlayers')}</span>
</label>
<div className={styles.actions}>
<Button
variant="primary"
disabled={clampDiceCount(Number(countDraft.trim()) || count) < 1}
onClick={() => {
const next = clampDiceCount(Number(countDraft.trim()));
setCount(next);
setCountDraft(String(next));
if (next < 1) return;
void diceApi.dispatch({ kind: 'roll', count: next, 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>
);
}