feat(control): 3D-кубики в отдельном окне
Бросок синхронизирован на пульте и презентации; d100 как десятки; оверлей скрывается только кнопкой «Убрать». Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -1875,6 +1875,22 @@ export function ControlApp() {
|
||||
⚔
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
title={t('control.diceTool')}
|
||||
ariaLabel={t('control.diceTool')}
|
||||
data-testid="control-dice-tool"
|
||||
onClick={() => {
|
||||
void api.invoke(ipcChannels.windows.openDice, {}).catch((err) => {
|
||||
console.error('[control] openDice failed', err);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className={styles.iconGlyph} aria-hidden>
|
||||
🎲
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||
<title>TTRPG</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/dice/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,128 @@
|
||||
.page {
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text2);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sidesGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidesGrid > * {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.sidesGrid button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.countRow {
|
||||
display: grid;
|
||||
grid-template-columns: 48px 1fr 48px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.countValue {
|
||||
text-align: center;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px;
|
||||
background: var(--color-overlay-dark-3, rgba(0, 0, 0, 0.28));
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.actions > * {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.lastResult {
|
||||
margin-top: auto;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-3, rgba(0, 0, 0, 0.28));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lastLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.lastNotation {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.valueChips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.valueChip {
|
||||
min-width: 36px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(245, 197, 66, 0.55);
|
||||
background: rgba(245, 197, 66, 0.12);
|
||||
color: #fef3c7;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lastTotal {
|
||||
font-size: 16px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rollingHint {
|
||||
font-size: 12px;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.emptyResult {
|
||||
margin-top: auto;
|
||||
padding: 12px;
|
||||
color: var(--text2);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||
|
||||
import { DiceApp } from './DiceApp';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) {
|
||||
throw new Error('Missing #root element');
|
||||
}
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<WindowErrorBoundary title="Dice">
|
||||
<EditorI18nProvider>
|
||||
<DiceApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -660,8 +660,20 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.playlistsTool': 'Плейлисты',
|
||||
'control.timerTool': 'Таймер',
|
||||
'control.initiativeTool': 'Инициатива',
|
||||
'control.diceTool': 'Кубики',
|
||||
'control.npcsTool': 'НПС',
|
||||
|
||||
'dice.sides': 'Кубик',
|
||||
'dice.count': 'Количество',
|
||||
'dice.roll': 'Бросить',
|
||||
'dice.clear': 'Убрать',
|
||||
'dice.result': 'Результат',
|
||||
'dice.total': 'Сумма',
|
||||
'dice.rolling': 'Анимация на экране…',
|
||||
'dice.noResult': 'Пока нет броска',
|
||||
'dice.inc': 'Больше',
|
||||
'dice.dec': 'Меньше',
|
||||
|
||||
'sessionTimer.duration': 'Длительность (MM:SS)',
|
||||
'sessionTimer.start': 'Старт',
|
||||
'sessionTimer.pause': 'Пауза',
|
||||
@@ -1392,8 +1404,20 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.playlistsTool': 'Playlists',
|
||||
'control.timerTool': 'Timer',
|
||||
'control.initiativeTool': 'Initiative',
|
||||
'control.diceTool': 'Dice',
|
||||
'control.npcsTool': 'NPCs',
|
||||
|
||||
'dice.sides': 'Die',
|
||||
'dice.count': 'Count',
|
||||
'dice.roll': 'Roll',
|
||||
'dice.clear': 'Clear',
|
||||
'dice.result': 'Result',
|
||||
'dice.total': 'Total',
|
||||
'dice.rolling': 'Animating on screen…',
|
||||
'dice.noResult': 'No roll yet',
|
||||
'dice.inc': 'More',
|
||||
'dice.dec': 'Fewer',
|
||||
|
||||
'sessionTimer.duration': 'Duration (MM:SS)',
|
||||
'sessionTimer.start': 'Start',
|
||||
'sessionTimer.pause': 'Pause',
|
||||
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 49 KiB |