feat: кубики/таймер/кисть/спонсоры и задел игровых систем

Показ анимации кубиков игрокам, закрытие оверлеев с окнами, ручной ввод количества, меньший радиус кисти, блок спонсоров в «О программе»; плюс черновик game systems / character sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-13 15:37:44 +08:00
parent 3d0474fe97
commit 712086ddca
32 changed files with 1712 additions and 34 deletions
+17
View File
@@ -49,6 +49,14 @@
border-radius: var(--radius-sm);
padding: 8px;
background: var(--color-overlay-dark-3, rgba(0, 0, 0, 0.28));
color: inherit;
width: 100%;
box-sizing: border-box;
outline: none;
}
.countValue:focus {
border-color: rgba(147, 197, 253, 0.75);
}
.actions {
@@ -58,6 +66,15 @@
margin-top: 4px;
}
.checkRow {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--text-sm);
font-weight: 600;
user-select: none;
}
.actions > * {
width: 100%;
max-width: none;
+93 -7
View File
@@ -1,7 +1,13 @@
import React, { useEffect, useState } from 'react';
import { ipcChannels } from '../../shared/ipc/contracts';
import { DICE_SIDES, type DiceSides } from '../../shared/types/diceRoll';
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';
@@ -15,6 +21,7 @@ export function DiceApp() {
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) => {
@@ -26,6 +33,32 @@ export function DiceApp() {
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>
@@ -46,27 +79,80 @@ export function DiceApp() {
<div className={styles.countRow}>
<Button
variant="ghost"
onClick={() => setCount((c) => Math.max(1, c - 1))}
onClick={() => setCountClamped(count - 1)}
ariaLabel={t('dice.dec')}
disabled={count <= DICE_COUNT_MIN}
>
−
</Button>
<div className={styles.countValue} data-testid="dice-count">
{count}
</div>
<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={() => setCount((c) => Math.min(20, c + 1))}
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"
onClick={() => void diceApi.dispatch({ kind: 'roll', count, sides })}
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')}