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
+11 -8
View File
@@ -1224,7 +1224,7 @@ export function ControlApp() {
const cw = cr ? cr.w : ps.w;
const ch = cr ? cr.h : ps.h;
const minDim = Math.min(cw, ch);
const size = Math.max(2, t.radiusN * minDim * 2);
const size = Math.max(1, t.radiusN * minDim * 2);
el.style.left = `${String(ox + p.x * cw)}px`;
el.style.top = `${String(oy + p.y * ch)}px`;
el.style.width = `${String(size)}px`;
@@ -2110,19 +2110,22 @@ export function ControlApp() {
<div className={styles.radiusLabel}>{t('control.brushRadius')}</div>
<input
type="range"
min={0.015}
max={0.18}
step={0.001}
value={tool.radiusN}
min={1}
max={36}
step={1}
value={Math.max(1, Math.min(36, Math.round(tool.radiusN / 0.005)))}
onChange={(e) => {
const v = Number((e.currentTarget as HTMLInputElement).value);
const next = Math.max(0.01, Math.min(0.25, Number.isFinite(v) ? v : tool.radiusN));
const ui = Number((e.currentTarget as HTMLInputElement).value);
const uiClamped = Math.max(1, Math.min(36, Number.isFinite(ui) ? Math.round(ui) : 1));
const next = uiClamped * 0.005;
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, radiusN: next } });
}}
className={styles.range}
aria-label={t('control.brushRadius')}
/>
<div className={styles.radiusValue}>{Math.round(tool.radiusN * 100)}</div>
<div className={styles.radiusValue}>
{Math.max(1, Math.min(36, Math.round(tool.radiusN / 0.005)))}
</div>
</div>
{tool.tool === 'exploreBrush' || tool.tool === 'closeBrush' ? (
<div className={styles.brushShapeRow}>
+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')}
+7
View File
@@ -688,6 +688,13 @@
margin-top: 4px;
}
.aboutSponsorsList {
display: flex;
flex-direction: column;
gap: 4px;
line-height: 1.45;
}
.aboutLink {
color: var(--color-accent, #a78bfa);
text-decoration: none;
+47
View File
@@ -62,6 +62,7 @@ import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
import { PlayersManagerModal } from './PlayersModals';
import { LaunchPlayersModal } from './LaunchPlayersModal';
import { PresentationSettingsModal } from './PresentationSettingsModal';
import { GameSystemsModal } from './GameSystemsModal';
import { PlaylistsManagerModal } from './PlaylistsModal';
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
import { SceneDescriptionModal } from './SceneDescriptionModal';
@@ -166,10 +167,28 @@ export function EditorApp() {
const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false);
const [playlistsManagerOpen, setPlaylistsManagerOpen] = useState(false);
const [presentationSettingsOpen, setPresentationSettingsOpen] = useState(false);
const [gameSystemsOpen, setGameSystemsOpen] = useState(false);
const [runMenuOpen, setRunMenuOpen] = useState(false);
const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | null>(null);
const runSplitRef = useRef<HTMLDivElement | null>(null);
const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(null);
const listGameSystems = useCallback(async () => {
const res = await getDndApi().invoke(ipcChannels.gameSystems.list, {});
return res.systems;
}, []);
const importGameSystem = useCallback(async () => {
const res = await getDndApi().invoke(ipcChannels.gameSystems.importFromFile, {});
return res.canceled ? null : res.system;
}, []);
const deleteGameSystem = useCallback(async (id: string) => {
await getDndApi().invoke(ipcChannels.gameSystems.delete, { id });
}, []);
const previewGameSystem = useCallback(async (id: string) => {
console.log('[gameSystems] preview stub', id);
window.alert(`Preview (stub): ${id}`);
}, []);
const onProjectNotice = useCallback(
(code: ProjectNoticeCode) => {
const handlers: Record<ProjectNoticeCode, () => void> = {
@@ -1360,6 +1379,18 @@ export function EditorApp() {
>
{t('menu.presentationSettings')}
</button>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setSettingsMenuOpen(false);
setSettingsLangSubOpen(false);
setGameSystemsOpen(true);
}}
>
{t('menu.gameSystems')}
</button>
<div className={styles.fileMenuSubHost} role="presentation">
<button
type="button"
@@ -1682,6 +1713,22 @@ export function EditorApp() {
open={presentationSettingsOpen}
onClose={() => setPresentationSettingsOpen(false)}
/>
<GameSystemsModal
open={gameSystemsOpen}
onClose={() => setGameSystemsOpen(false)}
labels={{
title: t('gameSystems.title'),
import: t('gameSystems.import'),
preview: t('gameSystems.preview'),
remove: t('gameSystems.remove'),
empty: t('gameSystems.empty'),
close: t('gameSystems.close'),
}}
listSystems={listGameSystems}
importSystem={importGameSystem}
deleteSystem={deleteGameSystem}
previewSystem={previewGameSystem}
/>
<PlaylistsManagerModal
open={playlistsManagerOpen}
onClose={() => setPlaylistsManagerOpen(false)}
+134
View File
@@ -0,0 +1,134 @@
import { useEffect, useState } from 'react';
import type { GameSystemListItem } from '../../shared/ipc/contracts';
export type { GameSystemListItem };
type Props = {
open: boolean;
onClose: () => void;
labels: {
title: string;
import: string;
preview: string;
remove: string;
empty: string;
close: string;
};
listSystems: () => Promise<GameSystemListItem[]>;
importSystem: () => Promise<GameSystemListItem | null>;
deleteSystem: (id: string) => Promise<void>;
previewSystem: (id: string) => Promise<void>;
};
export function GameSystemsModal({
open,
onClose,
labels,
listSystems,
importSystem,
deleteSystem,
previewSystem,
}: Props) {
const [items, setItems] = useState<GameSystemListItem[]>([]);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!open) return;
void (async () => {
setItems(await listSystems());
})();
}, [open, listSystems]);
if (!open) return null;
return (
<div
data-testid="game-systems-modal"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.55)',
display: 'grid',
placeItems: 'center',
zIndex: 10000,
}}
>
<div
style={{
width: 560,
maxHeight: '80vh',
overflow: 'auto',
background: 'rgba(10,10,14,0.98)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 12,
padding: 16,
color: 'rgba(255,255,255,0.92)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
<strong>{labels.title}</strong>
<button type="button" data-testid="game-systems-close" onClick={onClose}>
{labels.close}
</button>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<button
type="button"
data-testid="game-systems-import"
disabled={busy}
onClick={async () => {
setBusy(true);
try {
await importSystem();
setItems(await listSystems());
} finally {
setBusy(false);
}
}}
>
{labels.import}
</button>
</div>
{items.length === 0 ? (
<div style={{ opacity: 0.6 }}>{labels.empty}</div>
) : (
items.map((s) => (
<div
key={s.id}
data-testid={`game-system-${s.id}`}
style={{
display: 'flex',
justifyContent: 'space-between',
gap: 8,
padding: '10px 0',
borderBottom: '1px solid rgba(255,255,255,0.08)',
}}
>
<div>
<div>{s.name}</div>
<div style={{ opacity: 0.55, fontSize: 12 }}>
{s.id} · r{s.revision}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" onClick={() => void previewSystem(s.id)}>
{labels.preview}
</button>
<button
type="button"
onClick={async () => {
await deleteSystem(s.id);
setItems(await listSystems());
}}
>
{labels.remove}
</button>
</div>
</div>
))
)}
</div>
</div>
);
}
@@ -13,6 +13,15 @@ import {
} from '../help/helpSections';
import { useEditorI18n } from '../i18n/EditorI18nContext';
const ABOUT_SPONSORS = [
{ kind: 'text', name: 'Исаков Артур' },
{ kind: 'text', name: 'Подчуфарова Евгения' },
{ kind: 'text', name: 'Никитин Виктор' },
{ kind: 'text', name: 'Майер С.В. (Клуб «Серый Май»)' },
{ kind: 'link', name: 'true_rus(КАВ)', url: 'https://truecorp.su/' },
{ kind: 'text', name: 'Макс LonelyFlame Чурсин' },
] as const;
type AppAboutModalProps = {
open: boolean;
onClose: () => void;
@@ -80,6 +89,24 @@ export function AppAboutModal({ open, onClose, appVersion }: AppAboutModalProps)
{t('app.about.websiteUrl')}
</a>
</div>
<div className={styles.fieldLabel}>{t('app.about.sponsorsLabel')}</div>
<div className={styles.aboutSponsorsList}>
{ABOUT_SPONSORS.map((sponsor) =>
sponsor.kind === 'link' ? (
<a
key={sponsor.name}
className={styles.aboutLink}
href={sponsor.url}
target="_blank"
rel="noreferrer"
>
{sponsor.name}
</a>
) : (
<div key={sponsor.name}>{sponsor.name}</div>
),
)}
</div>
</div>
</div>
<div className={styles.modalFooter}>
@@ -132,12 +132,20 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'menu.aboutLicense': 'О лицензии',
'menu.checkUpdates': 'Проверить обновления',
'menu.presentationSettings': 'Экран презентации',
'menu.gameSystems': 'Игровые системы',
'menu.language': 'Язык',
'menu.langRu': 'Русский',
'menu.langEn': 'English',
'menu.aboutProgram': 'О программе',
'menu.instructions': 'Инструкция',
'gameSystems.title': 'Игровые системы',
'gameSystems.import': 'Импорт…',
'gameSystems.preview': 'Просмотр',
'gameSystems.remove': 'Удалить',
'gameSystems.empty': 'Нет импортированных систем',
'gameSystems.close': 'Закрыть',
'top.aboutApp': 'О приложении',
'app.about.title': 'О программе',
@@ -152,6 +160,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'app.about.supportEmail': 'player.ttrpg@gmail.com',
'app.about.websiteLabel': 'САЙТ И ОБНОВЛЕНИЯ',
'app.about.websiteUrl': 'https://ttrpgplayer.ru/',
'app.about.sponsorsLabel': 'СПОНСОРЫ',
'help.title': 'Инструкция',
'help.navAria': 'Разделы инструкции',
@@ -676,6 +685,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'dice.noResult': 'Пока нет броска',
'dice.inc': 'Больше',
'dice.dec': 'Меньше',
'dice.showAnimationToPlayers': 'Показать анимацию игрокам',
'sessionTimer.duration': 'Длительность (MM:SS)',
'sessionTimer.start': 'Старт',
@@ -877,12 +887,20 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'menu.aboutLicense': 'About license',
'menu.checkUpdates': 'Check for updates',
'menu.presentationSettings': 'Presentation display',
'menu.gameSystems': 'Game systems',
'menu.language': 'Language',
'menu.langRu': 'Русский',
'menu.langEn': 'English',
'menu.aboutProgram': 'About',
'menu.instructions': 'Instructions',
'gameSystems.title': 'Game systems',
'gameSystems.import': 'Import…',
'gameSystems.preview': 'Preview',
'gameSystems.remove': 'Remove',
'gameSystems.empty': 'No imported systems',
'gameSystems.close': 'Close',
'top.aboutApp': 'About',
'app.about.title': 'About',
@@ -897,6 +915,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'app.about.supportEmail': 'player.ttrpg@gmail.com',
'app.about.websiteLabel': 'WEBSITE & UPDATES',
'app.about.websiteUrl': 'https://ttrpgplayer.ru/',
'app.about.sponsorsLabel': 'SPONSORS',
'help.title': 'Instructions',
'help.navAria': 'Instruction sections',
@@ -1423,6 +1442,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'dice.noResult': 'No roll yet',
'dice.inc': 'More',
'dice.dec': 'Fewer',
'dice.showAnimationToPlayers': 'Show animation to players',
'sessionTimer.duration': 'Duration (MM:SS)',
'sessionTimer.start': 'Start',