feat(control): 3D-кубики в отдельном окне
Бросок синхронизирован на пульте и презентации; d100 как десятки; оверлей скрывается только кнопкой «Убрать». Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -0,0 +1,64 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { DiceRollSessionStore } from './diceRollSessionStore';
|
||||||
|
import {
|
||||||
|
D100_FACE_VALUES,
|
||||||
|
formatPredeterminedDiceNotation,
|
||||||
|
snapD100Value,
|
||||||
|
} from '../../shared/types/diceRoll';
|
||||||
|
|
||||||
|
void test('diceRoll: roll produces values in range and notation', () => {
|
||||||
|
const store = new DiceRollSessionStore();
|
||||||
|
store.dispatch({ kind: 'roll', count: 3, sides: 6 });
|
||||||
|
const st = store.getState();
|
||||||
|
assert.equal(st.active, true);
|
||||||
|
assert.equal(st.notation, '3d6');
|
||||||
|
assert.equal(st.values.length, 3);
|
||||||
|
assert.ok(st.values.every((v) => v >= 1 && v <= 6));
|
||||||
|
assert.equal(st.total, st.values.reduce((a, b) => a + b, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('diceRoll: d100 values are tens faces only', () => {
|
||||||
|
const store = new DiceRollSessionStore();
|
||||||
|
store.dispatch({ kind: 'roll', count: 5, sides: 100 });
|
||||||
|
const st = store.getState();
|
||||||
|
assert.equal(st.notation, '5d100');
|
||||||
|
assert.equal(st.values.length, 5);
|
||||||
|
const allowed = new Set<number>(D100_FACE_VALUES);
|
||||||
|
assert.ok(st.values.every((v) => allowed.has(v)));
|
||||||
|
assert.equal(st.total, st.values.reduce((a, b) => a + b, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('diceRoll: clear resets', () => {
|
||||||
|
const store = new DiceRollSessionStore();
|
||||||
|
store.dispatch({ kind: 'roll', count: 1, sides: 20 });
|
||||||
|
store.dispatch({ kind: 'clear' });
|
||||||
|
assert.equal(store.getState().active, false);
|
||||||
|
assert.deepEqual(store.getState().values, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('diceRoll: hideOverlay keeps values for master UI', () => {
|
||||||
|
const store = new DiceRollSessionStore();
|
||||||
|
store.dispatch({ kind: 'roll', count: 2, sides: 6 });
|
||||||
|
const values = [...store.getState().values];
|
||||||
|
const total = store.getState().total;
|
||||||
|
store.dispatch({ kind: 'hideOverlay' });
|
||||||
|
assert.equal(store.getState().active, false);
|
||||||
|
assert.deepEqual(store.getState().values, values);
|
||||||
|
assert.equal(store.getState().total, total);
|
||||||
|
assert.equal(store.getState().notation, '2d6');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('formatPredeterminedDiceNotation', () => {
|
||||||
|
assert.equal(formatPredeterminedDiceNotation(2, 20, [15, 7]), '2d20@15,7');
|
||||||
|
assert.equal(formatPredeterminedDiceNotation(2, 100, [20, 100]), '2d100@20,100');
|
||||||
|
assert.equal(formatPredeterminedDiceNotation(1, 100, [27]), '1d100@30');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('snapD100Value', () => {
|
||||||
|
assert.equal(snapD100Value(20), 20);
|
||||||
|
assert.equal(snapD100Value(21), 30);
|
||||||
|
assert.equal(snapD100Value(100), 100);
|
||||||
|
assert.equal(snapD100Value(1), 10);
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
clampDiceCount,
|
||||||
|
createInitialDiceRollState,
|
||||||
|
formatDiceNotation,
|
||||||
|
isDiceSides,
|
||||||
|
rollDiceValues,
|
||||||
|
type DiceRollEvent,
|
||||||
|
type DiceRollState,
|
||||||
|
} from '../../shared/types/diceRoll';
|
||||||
|
|
||||||
|
export class DiceRollSessionStore {
|
||||||
|
private state: DiceRollState = createInitialDiceRollState();
|
||||||
|
|
||||||
|
getState(): DiceRollState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): DiceRollState {
|
||||||
|
this.state = createInitialDiceRollState();
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(event: DiceRollEvent): DiceRollState {
|
||||||
|
switch (event.kind) {
|
||||||
|
case 'clear': {
|
||||||
|
if (!this.state.active && this.state.values.length === 0) return this.state;
|
||||||
|
this.state = {
|
||||||
|
...createInitialDiceRollState(),
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'hideOverlay': {
|
||||||
|
if (!this.state.active) return this.state;
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
active: false,
|
||||||
|
startedAt: null,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'roll': {
|
||||||
|
if (!isDiceSides(event.sides)) return this.state;
|
||||||
|
const count = clampDiceCount(event.count);
|
||||||
|
const values = rollDiceValues(count, event.sides);
|
||||||
|
const total = values.reduce((a, b) => a + b, 0);
|
||||||
|
this.state = {
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
active: true,
|
||||||
|
notation: formatDiceNotation(count, event.sides),
|
||||||
|
values,
|
||||||
|
total,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const _exhaustive: never = event;
|
||||||
|
return _exhaustive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ import { MaterialsOverlayStore } from './materials/materialsOverlayStore';
|
|||||||
import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
|
import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
|
||||||
import { TimerSessionStore } from './sessionTimer/timerSessionStore';
|
import { TimerSessionStore } from './sessionTimer/timerSessionStore';
|
||||||
import { InitiativeSessionStore } from './initiative/initiativeSessionStore';
|
import { InitiativeSessionStore } from './initiative/initiativeSessionStore';
|
||||||
|
import { DiceRollSessionStore } from './diceRoll/diceRollSessionStore';
|
||||||
import { ZipProjectStore } from './project/zipStore';
|
import { ZipProjectStore } from './project/zipStore';
|
||||||
import { SceneViewStore } from './sceneView/sceneViewStore';
|
import { SceneViewStore } from './sceneView/sceneViewStore';
|
||||||
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
|
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
|
||||||
@@ -72,6 +73,7 @@ import {
|
|||||||
openPlaylistsWindow,
|
openPlaylistsWindow,
|
||||||
openTimerWindow,
|
openTimerWindow,
|
||||||
openInitiativeWindow,
|
openInitiativeWindow,
|
||||||
|
openDiceWindow,
|
||||||
openMultiWindow,
|
openMultiWindow,
|
||||||
openNpcsEditorWindow,
|
openNpcsEditorWindow,
|
||||||
openSceneEditorWindow,
|
openSceneEditorWindow,
|
||||||
@@ -82,6 +84,7 @@ import {
|
|||||||
closePlaylistsWindow,
|
closePlaylistsWindow,
|
||||||
closeTimerWindow,
|
closeTimerWindow,
|
||||||
closeInitiativeWindow,
|
closeInitiativeWindow,
|
||||||
|
closeDiceWindow,
|
||||||
closeNpcsEditorWindow,
|
closeNpcsEditorWindow,
|
||||||
closeSceneEditorWindow,
|
closeSceneEditorWindow,
|
||||||
closeTokenPathEditorWindow,
|
closeTokenPathEditorWindow,
|
||||||
@@ -187,6 +190,7 @@ const materialsOverlayStore = new MaterialsOverlayStore();
|
|||||||
const npcsOverlayStore = new NpcsOverlayStore();
|
const npcsOverlayStore = new NpcsOverlayStore();
|
||||||
const timerSessionStore = new TimerSessionStore();
|
const timerSessionStore = new TimerSessionStore();
|
||||||
const initiativeSessionStore = new InitiativeSessionStore();
|
const initiativeSessionStore = new InitiativeSessionStore();
|
||||||
|
const diceRollSessionStore = new DiceRollSessionStore();
|
||||||
const sceneTokensSessionStore = new SceneTokensSessionStore();
|
const sceneTokensSessionStore = new SceneTokensSessionStore();
|
||||||
const tokenPathSessionStore = new TokenPathSessionStore();
|
const tokenPathSessionStore = new TokenPathSessionStore();
|
||||||
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
|
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
|
||||||
@@ -300,6 +304,18 @@ function emitInitiativeState(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emitDiceRollState(): void {
|
||||||
|
const state = diceRollSessionStore.getState();
|
||||||
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
|
if (win.isDestroyed() || win.webContents.isDestroyed()) continue;
|
||||||
|
try {
|
||||||
|
win.webContents.send(ipcChannels.diceRoll.stateChanged, { state });
|
||||||
|
} catch {
|
||||||
|
/* teardown */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function syncInitiativePresenceFromSession(): void {
|
function syncInitiativePresenceFromSession(): void {
|
||||||
const project = projectStore.getOpenProject();
|
const project = projectStore.getOpenProject();
|
||||||
const sceneId = project?.currentSceneId ?? null;
|
const sceneId = project?.currentSceneId ?? null;
|
||||||
@@ -637,6 +653,7 @@ async function main() {
|
|||||||
tokenGridSnapSessionStore.reset();
|
tokenGridSnapSessionStore.reset();
|
||||||
timerSessionStore.clear();
|
timerSessionStore.clear();
|
||||||
initiativeSessionStore.clear();
|
initiativeSessionStore.clear();
|
||||||
|
diceRollSessionStore.clear();
|
||||||
if (USERS_BRANCH_FEATURES_ENABLED) {
|
if (USERS_BRANCH_FEATURES_ENABLED) {
|
||||||
const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : [];
|
const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : [];
|
||||||
if (playerIds.length > 0) {
|
if (playerIds.length > 0) {
|
||||||
@@ -665,15 +682,18 @@ async function main() {
|
|||||||
emitSessionTimerState();
|
emitSessionTimerState();
|
||||||
syncInitiativePresenceFromSession();
|
syncInitiativePresenceFromSession();
|
||||||
emitInitiativeState();
|
emitInitiativeState();
|
||||||
|
emitDiceRollState();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
|
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
|
||||||
closeMultiWindow();
|
closeMultiWindow();
|
||||||
timerSessionStore.clear();
|
timerSessionStore.clear();
|
||||||
initiativeSessionStore.clear();
|
initiativeSessionStore.clear();
|
||||||
|
diceRollSessionStore.clear();
|
||||||
tokenGridSnapSessionStore.reset();
|
tokenGridSnapSessionStore.reset();
|
||||||
emitSessionTimerState();
|
emitSessionTimerState();
|
||||||
emitInitiativeState();
|
emitInitiativeState();
|
||||||
|
emitDiceRollState();
|
||||||
emitTokenGridSnapState();
|
emitTokenGridSnapState();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
@@ -755,6 +775,14 @@ async function main() {
|
|||||||
emitInitiativeState();
|
emitInitiativeState();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
registerHandler(ipcChannels.windows.openDice, () => {
|
||||||
|
openDiceWindow();
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
registerHandler(ipcChannels.windows.closeDice, () => {
|
||||||
|
closeDiceWindow();
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
registerHandler(ipcChannels.windows.openNpcsEditor, () => {
|
registerHandler(ipcChannels.windows.openNpcsEditor, () => {
|
||||||
openNpcsEditorWindow();
|
openNpcsEditorWindow();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -827,6 +855,14 @@ async function main() {
|
|||||||
emitInitiativeState();
|
emitInitiativeState();
|
||||||
return { state: initiativeSessionStore.getState() };
|
return { state: initiativeSessionStore.getState() };
|
||||||
});
|
});
|
||||||
|
registerHandler(ipcChannels.diceRoll.getState, () => {
|
||||||
|
return { state: diceRollSessionStore.getState() };
|
||||||
|
});
|
||||||
|
registerHandler(ipcChannels.diceRoll.dispatch, ({ event }) => {
|
||||||
|
diceRollSessionStore.dispatch(event);
|
||||||
|
emitDiceRollState();
|
||||||
|
return { state: diceRollSessionStore.getState() };
|
||||||
|
});
|
||||||
|
|
||||||
registerHandler(ipcChannels.project.list, async () => {
|
registerHandler(ipcChannels.project.list, async () => {
|
||||||
const projects = await projectStore.listProjects();
|
const projects = await projectStore.listProjects();
|
||||||
@@ -870,6 +906,7 @@ async function main() {
|
|||||||
npcsOverlayStore.clear();
|
npcsOverlayStore.clear();
|
||||||
timerSessionStore.clear();
|
timerSessionStore.clear();
|
||||||
initiativeSessionStore.clear();
|
initiativeSessionStore.clear();
|
||||||
|
diceRollSessionStore.clear();
|
||||||
sceneDarknessStore.resetSession();
|
sceneDarknessStore.resetSession();
|
||||||
sceneMarkerStore.resetSession();
|
sceneMarkerStore.resetSession();
|
||||||
sceneTrapsStore.resetSession();
|
sceneTrapsStore.resetSession();
|
||||||
@@ -884,6 +921,7 @@ async function main() {
|
|||||||
emitNpcsOverlayState();
|
emitNpcsOverlayState();
|
||||||
emitSessionTimerState();
|
emitSessionTimerState();
|
||||||
emitInitiativeState();
|
emitInitiativeState();
|
||||||
|
emitDiceRollState();
|
||||||
emitSceneDarknessState();
|
emitSceneDarknessState();
|
||||||
emitSceneMarkerState();
|
emitSceneMarkerState();
|
||||||
emitSceneTrapsState();
|
emitSceneTrapsState();
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ void test('createWindows: окно инициативы закрывается
|
|||||||
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closeInitiativeWindow/);
|
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closeInitiativeWindow/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('createWindows: окно кубиков закрывается с multi-window', () => {
|
||||||
|
const src = readCreateWindows();
|
||||||
|
assert.ok(src.includes('openDiceWindow'));
|
||||||
|
assert.ok(src.includes('closeDiceWindow'));
|
||||||
|
assert.ok(src.includes("createWindow('dice'"));
|
||||||
|
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closeDiceWindow/);
|
||||||
|
});
|
||||||
|
|
||||||
void test('createWindows: окно НПС закрывается с multi-window', () => {
|
void test('createWindows: окно НПС закрывается с multi-window', () => {
|
||||||
const src = readCreateWindows();
|
const src = readCreateWindows();
|
||||||
assert.ok(src.includes('openNpcsWindow'));
|
assert.ok(src.includes('openNpcsWindow'));
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type WindowKind =
|
|||||||
| 'playlists'
|
| 'playlists'
|
||||||
| 'timer'
|
| 'timer'
|
||||||
| 'initiative'
|
| 'initiative'
|
||||||
|
| 'dice'
|
||||||
| 'npcsEditor'
|
| 'npcsEditor'
|
||||||
| 'sceneEditor'
|
| 'sceneEditor'
|
||||||
| 'tokenPathEditor'
|
| 'tokenPathEditor'
|
||||||
@@ -69,6 +70,10 @@ const TIMER_WINDOW_HEIGHT = 420;
|
|||||||
const INITIATIVE_WINDOW_WIDTH = 920;
|
const INITIATIVE_WINDOW_WIDTH = 920;
|
||||||
const INITIATIVE_WINDOW_HEIGHT = 420;
|
const INITIATIVE_WINDOW_HEIGHT = 420;
|
||||||
|
|
||||||
|
/** Компактное окно броска кубиков. */
|
||||||
|
const DICE_WINDOW_WIDTH = 380;
|
||||||
|
const DICE_WINDOW_HEIGHT = 420;
|
||||||
|
|
||||||
/** Редактор НПС — как основной редактор, шире инспектор. */
|
/** Редактор НПС — как основной редактор, шире инспектор. */
|
||||||
const NPCS_EDITOR_WINDOW_WIDTH = 1400;
|
const NPCS_EDITOR_WINDOW_WIDTH = 1400;
|
||||||
const NPCS_EDITOR_WINDOW_HEIGHT = 860;
|
const NPCS_EDITOR_WINDOW_HEIGHT = 860;
|
||||||
@@ -181,6 +186,7 @@ function closePlaySessionAuxiliaryWindows(): void {
|
|||||||
closePlaylistsWindow();
|
closePlaylistsWindow();
|
||||||
closeTimerWindow();
|
closeTimerWindow();
|
||||||
closeInitiativeWindow();
|
closeInitiativeWindow();
|
||||||
|
closeDiceWindow();
|
||||||
closeNpcsWindow();
|
closeNpcsWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +241,8 @@ function pageNameForKind(kind: WindowKind): string {
|
|||||||
return 'timer.html';
|
return 'timer.html';
|
||||||
case 'initiative':
|
case 'initiative':
|
||||||
return 'initiative.html';
|
return 'initiative.html';
|
||||||
|
case 'dice':
|
||||||
|
return 'dice.html';
|
||||||
case 'npcsEditor':
|
case 'npcsEditor':
|
||||||
return 'npcsEditor.html';
|
return 'npcsEditor.html';
|
||||||
case 'sceneEditor':
|
case 'sceneEditor':
|
||||||
@@ -313,6 +321,7 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number }
|
|||||||
if (kind === 'playlists') return { width: PLAYLISTS_WINDOW_WIDTH, height: PLAYLISTS_WINDOW_HEIGHT };
|
if (kind === 'playlists') return { width: PLAYLISTS_WINDOW_WIDTH, height: PLAYLISTS_WINDOW_HEIGHT };
|
||||||
if (kind === 'timer') return { width: TIMER_WINDOW_WIDTH, height: TIMER_WINDOW_HEIGHT };
|
if (kind === 'timer') return { width: TIMER_WINDOW_WIDTH, height: TIMER_WINDOW_HEIGHT };
|
||||||
if (kind === 'initiative') return { width: INITIATIVE_WINDOW_WIDTH, height: INITIATIVE_WINDOW_HEIGHT };
|
if (kind === 'initiative') return { width: INITIATIVE_WINDOW_WIDTH, height: INITIATIVE_WINDOW_HEIGHT };
|
||||||
|
if (kind === 'dice') return { width: DICE_WINDOW_WIDTH, height: DICE_WINDOW_HEIGHT };
|
||||||
if (kind === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT };
|
if (kind === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT };
|
||||||
if (kind === 'sceneEditor') return { width: 1280, height: 800 };
|
if (kind === 'sceneEditor') return { width: 1280, height: 800 };
|
||||||
if (kind === 'tokenPathEditor') return { width: 1100, height: 760 };
|
if (kind === 'tokenPathEditor') return { width: 1100, height: 760 };
|
||||||
@@ -636,6 +645,13 @@ export function closeInitiativeWindow(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function closeDiceWindow(): void {
|
||||||
|
const win = windows.get('dice');
|
||||||
|
if (win && !win.isDestroyed()) {
|
||||||
|
win.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function closeNpcsEditorWindow(): void {
|
export function closeNpcsEditorWindow(): void {
|
||||||
const win = windows.get('npcsEditor');
|
const win = windows.get('npcsEditor');
|
||||||
if (win && !win.isDestroyed()) {
|
if (win && !win.isDestroyed()) {
|
||||||
@@ -890,6 +906,36 @@ export function openInitiativeWindow(opts?: { onClosed?: () => void }): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function openDiceWindow(): void {
|
||||||
|
const existing = windows.get('dice');
|
||||||
|
if (existing && !existing.isDestroyed()) {
|
||||||
|
if (existing.isMinimized()) existing.restore();
|
||||||
|
existing.show();
|
||||||
|
existing.focus();
|
||||||
|
existing.moveTop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = windows.get('control') ?? windows.get('presentation');
|
||||||
|
const win = createWindow('dice', parent ? { parent } : undefined);
|
||||||
|
const { width, height } = win.getBounds();
|
||||||
|
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||||
|
const { x, y, width: dw, height: dh } = display.workArea;
|
||||||
|
win.setBounds({
|
||||||
|
x: Math.round(x + Math.max(0, (dw - width) / 2)),
|
||||||
|
y: Math.round(y + Math.max(0, (dh - height) / 2)),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
win.webContents.once('did-finish-load', () => {
|
||||||
|
if (!win.isDestroyed()) {
|
||||||
|
win.show();
|
||||||
|
win.focus();
|
||||||
|
win.moveTop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function positionNpcsEditorWindow(win: BrowserWindow): void {
|
function positionNpcsEditorWindow(win: BrowserWindow): void {
|
||||||
const parent = windows.get('editor');
|
const parent = windows.get('editor');
|
||||||
const { width, height } = win.getBounds();
|
const { width, height } = win.getBounds();
|
||||||
|
|||||||
@@ -1875,6 +1875,22 @@ export function ControlApp() {
|
|||||||
⚔
|
⚔
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
iconOnly
|
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.playlistsTool': 'Плейлисты',
|
||||||
'control.timerTool': 'Таймер',
|
'control.timerTool': 'Таймер',
|
||||||
'control.initiativeTool': 'Инициатива',
|
'control.initiativeTool': 'Инициатива',
|
||||||
|
'control.diceTool': 'Кубики',
|
||||||
'control.npcsTool': 'НПС',
|
'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.duration': 'Длительность (MM:SS)',
|
||||||
'sessionTimer.start': 'Старт',
|
'sessionTimer.start': 'Старт',
|
||||||
'sessionTimer.pause': 'Пауза',
|
'sessionTimer.pause': 'Пауза',
|
||||||
@@ -1392,8 +1404,20 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'control.playlistsTool': 'Playlists',
|
'control.playlistsTool': 'Playlists',
|
||||||
'control.timerTool': 'Timer',
|
'control.timerTool': 'Timer',
|
||||||
'control.initiativeTool': 'Initiative',
|
'control.initiativeTool': 'Initiative',
|
||||||
|
'control.diceTool': 'Dice',
|
||||||
'control.npcsTool': 'NPCs',
|
'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.duration': 'Duration (MM:SS)',
|
||||||
'sessionTimer.start': 'Start',
|
'sessionTimer.start': 'Start',
|
||||||
'sessionTimer.pause': 'Pause',
|
'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 |