feat(control): 3D-кубики в отдельном окне

Бросок синхронизирован на пульте и презентации; d100 как десятки; оверлей скрывается только кнопкой «Убрать».

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-04 13:01:34 +08:00
parent 86d6d157f0
commit 9ac01d217f
137 changed files with 958 additions and 1 deletions
@@ -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);
});
+64
View File
@@ -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;
}
}
}
}
+38
View File
@@ -37,6 +37,7 @@ import { MaterialsOverlayStore } from './materials/materialsOverlayStore';
import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
import { TimerSessionStore } from './sessionTimer/timerSessionStore';
import { InitiativeSessionStore } from './initiative/initiativeSessionStore';
import { DiceRollSessionStore } from './diceRoll/diceRollSessionStore';
import { ZipProjectStore } from './project/zipStore';
import { SceneViewStore } from './sceneView/sceneViewStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
@@ -72,6 +73,7 @@ import {
openPlaylistsWindow,
openTimerWindow,
openInitiativeWindow,
openDiceWindow,
openMultiWindow,
openNpcsEditorWindow,
openSceneEditorWindow,
@@ -82,6 +84,7 @@ import {
closePlaylistsWindow,
closeTimerWindow,
closeInitiativeWindow,
closeDiceWindow,
closeNpcsEditorWindow,
closeSceneEditorWindow,
closeTokenPathEditorWindow,
@@ -187,6 +190,7 @@ const materialsOverlayStore = new MaterialsOverlayStore();
const npcsOverlayStore = new NpcsOverlayStore();
const timerSessionStore = new TimerSessionStore();
const initiativeSessionStore = new InitiativeSessionStore();
const diceRollSessionStore = new DiceRollSessionStore();
const sceneTokensSessionStore = new SceneTokensSessionStore();
const tokenPathSessionStore = new TokenPathSessionStore();
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 {
const project = projectStore.getOpenProject();
const sceneId = project?.currentSceneId ?? null;
@@ -637,6 +653,7 @@ async function main() {
tokenGridSnapSessionStore.reset();
timerSessionStore.clear();
initiativeSessionStore.clear();
diceRollSessionStore.clear();
if (USERS_BRANCH_FEATURES_ENABLED) {
const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : [];
if (playerIds.length > 0) {
@@ -665,15 +682,18 @@ async function main() {
emitSessionTimerState();
syncInitiativePresenceFromSession();
emitInitiativeState();
emitDiceRollState();
return { ok: true };
});
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
closeMultiWindow();
timerSessionStore.clear();
initiativeSessionStore.clear();
diceRollSessionStore.clear();
tokenGridSnapSessionStore.reset();
emitSessionTimerState();
emitInitiativeState();
emitDiceRollState();
emitTokenGridSnapState();
return { ok: true };
});
@@ -755,6 +775,14 @@ async function main() {
emitInitiativeState();
return { ok: true };
});
registerHandler(ipcChannels.windows.openDice, () => {
openDiceWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.closeDice, () => {
closeDiceWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.openNpcsEditor, () => {
openNpcsEditorWindow();
return { ok: true };
@@ -827,6 +855,14 @@ async function main() {
emitInitiativeState();
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 () => {
const projects = await projectStore.listProjects();
@@ -870,6 +906,7 @@ async function main() {
npcsOverlayStore.clear();
timerSessionStore.clear();
initiativeSessionStore.clear();
diceRollSessionStore.clear();
sceneDarknessStore.resetSession();
sceneMarkerStore.resetSession();
sceneTrapsStore.resetSession();
@@ -884,6 +921,7 @@ async function main() {
emitNpcsOverlayState();
emitSessionTimerState();
emitInitiativeState();
emitDiceRollState();
emitSceneDarknessState();
emitSceneMarkerState();
emitSceneTrapsState();
@@ -68,6 +68,14 @@ void test('createWindows: окно инициативы закрывается
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', () => {
const src = readCreateWindows();
assert.ok(src.includes('openNpcsWindow'));
+46
View File
@@ -23,6 +23,7 @@ export type WindowKind =
| 'playlists'
| 'timer'
| 'initiative'
| 'dice'
| 'npcsEditor'
| 'sceneEditor'
| 'tokenPathEditor'
@@ -69,6 +70,10 @@ const TIMER_WINDOW_HEIGHT = 420;
const INITIATIVE_WINDOW_WIDTH = 920;
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_HEIGHT = 860;
@@ -181,6 +186,7 @@ function closePlaySessionAuxiliaryWindows(): void {
closePlaylistsWindow();
closeTimerWindow();
closeInitiativeWindow();
closeDiceWindow();
closeNpcsWindow();
}
@@ -235,6 +241,8 @@ function pageNameForKind(kind: WindowKind): string {
return 'timer.html';
case 'initiative':
return 'initiative.html';
case 'dice':
return 'dice.html';
case 'npcsEditor':
return 'npcsEditor.html';
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 === 'timer') return { width: TIMER_WINDOW_WIDTH, height: TIMER_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 === 'sceneEditor') return { width: 1280, height: 800 };
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 {
const win = windows.get('npcsEditor');
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 {
const parent = windows.get('editor');
const { width, height } = win.getBounds();