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
+2
View File
@@ -25,6 +25,7 @@ export type AppWindowKind =
| 'playlists'
| 'timer'
| 'initiative'
| 'dice'
| 'npcsEditor'
| 'sceneEditor'
| 'tokenPathEditor'
@@ -40,6 +41,7 @@ const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
playlists: { ru: 'Плейлисты', en: 'Playlists' },
timer: { ru: 'Таймер', en: 'Timer' },
initiative: { ru: 'Инициатива', en: 'Initiative' },
dice: { ru: 'Кубики', en: 'Dice' },
npcsEditor: { ru: 'НПС', en: 'NPCs' },
sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' },
tokenPathEditor: { ru: 'Движение токена', en: 'Token path' },
+27
View File
@@ -59,6 +59,8 @@ import type {
SessionTimerState,
InitiativeEvent,
InitiativeState,
DiceRollEvent,
DiceRollState,
TokenId,
TokenPathSessionEvent,
TokenPathSessionState,
@@ -171,6 +173,8 @@ export const ipcChannels = {
closeTimer: 'windows.closeTimer',
openInitiative: 'windows.openInitiative',
closeInitiative: 'windows.closeInitiative',
openDice: 'windows.openDice',
closeDice: 'windows.closeDice',
openNpcsEditor: 'windows.openNpcsEditor',
closeNpcsEditor: 'windows.closeNpcsEditor',
openNpcs: 'windows.openNpcs',
@@ -218,6 +222,11 @@ export const ipcChannels = {
dispatch: 'initiative.dispatch',
stateChanged: 'initiative.stateChanged',
},
diceRoll: {
getState: 'diceRoll.getState',
dispatch: 'diceRoll.dispatch',
stateChanged: 'diceRoll.stateChanged',
},
sceneDarkness: {
getState: 'sceneDarkness.getState',
dispatch: 'sceneDarkness.dispatch',
@@ -377,6 +386,7 @@ export type IpcEventMap = {
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
[ipcChannels.sessionTimer.stateChanged]: { state: SessionTimerState };
[ipcChannels.initiative.stateChanged]: { state: InitiativeState };
[ipcChannels.diceRoll.stateChanged]: { state: DiceRollState };
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
[ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState };
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
@@ -822,6 +832,14 @@ export type IpcInvokeMap = {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.windows.openDice]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.windows.closeDice]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.windows.openNpcsEditor]: {
req: Record<string, never>;
res: { ok: true };
@@ -894,6 +912,14 @@ export type IpcInvokeMap = {
req: { event: InitiativeEvent };
res: { state: InitiativeState };
};
[ipcChannels.diceRoll.getState]: {
req: Record<string, never>;
res: { state: DiceRollState };
};
[ipcChannels.diceRoll.dispatch]: {
req: { event: DiceRollEvent };
res: { state: DiceRollState };
};
[ipcChannels.effects.getState]: {
req: Record<string, never>;
res: { state: EffectsState };
@@ -1148,6 +1174,7 @@ export type LegacyIpcEventMap = {
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
[ipcChannels.sessionTimer.stateChanged]: { state: SessionTimerState };
[ipcChannels.initiative.stateChanged]: { state: InitiativeState };
[ipcChannels.diceRoll.stateChanged]: { state: DiceRollState };
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
[ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState };
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
+88
View File
@@ -0,0 +1,88 @@
/** Поддерживаемые грани кубиков (как в dice-box-threejs / Major's). */
export const DICE_SIDES = [4, 6, 8, 10, 12, 20, 100] as const;
export type DiceSides = (typeof DICE_SIDES)[number];
export function isDiceSides(n: number): n is DiceSides {
return (DICE_SIDES as readonly number[]).includes(n);
}
export type DiceRollRequest = {
/** Количество кубиков одного типа. */
count: number;
sides: DiceSides;
};
export type DiceRollState = {
revision: number;
/** Идёт показ броска (оверлей виден). */
active: boolean;
/** Например "2d20". */
notation: string;
/** Заранее выбранные исходы — для синхрона пульт/презентация. */
values: number[];
total: number;
/** Когда бросок начат (для перезапуска анимации при том же revision нельзя — revision растёт). */
startedAt: number | null;
};
export type DiceRollEvent =
| { kind: 'roll'; count: number; sides: DiceSides }
| { kind: 'hideOverlay' }
| { kind: 'clear' };
export function createInitialDiceRollState(): DiceRollState {
return {
revision: 1,
active: false,
notation: '',
values: [],
total: 0,
startedAt: null,
};
}
export function clampDiceCount(n: number): number {
if (!Number.isFinite(n)) return 1;
return Math.max(1, Math.min(20, Math.round(n)));
}
/** Грани d100 в dice-box-threejs: 10,20,…,90,100 (лицо «00»). */
export const D100_FACE_VALUES = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100] as const;
/** Привести значение к ближайшей грани кубика десятков. */
export function snapD100Value(value: number): number {
const n = Math.round(value);
if (!Number.isFinite(n)) return 10;
if (n <= 0) return 100;
const tens = Math.ceil(n / 10) * 10;
if (tens <= 0) return 100;
return Math.min(100, Math.max(10, tens));
}
export function rollDiceValues(count: number, sides: DiceSides): number[] {
const c = clampDiceCount(count);
const out: number[] = [];
for (let i = 0; i < c; i += 1) {
if (sides === 100) {
out.push(D100_FACE_VALUES[Math.floor(Math.random() * D100_FACE_VALUES.length)]!);
} else {
out.push(1 + Math.floor(Math.random() * sides));
}
}
return out;
}
/** Нотация для dice-box-threejs с predetermined outcomes: `2d20@15,7`. */
export function formatPredeterminedDiceNotation(count: number, sides: DiceSides, values: readonly number[]): string {
const c = clampDiceCount(count);
const vals =
sides === 100
? values.slice(0, c).map(snapD100Value)
: values.slice(0, c).map((v) => Math.max(1, Math.min(sides, Math.round(v))));
while (vals.length < c) vals.push(sides === 100 ? 10 : 1);
return `${c}d${sides}@${vals.join(',')}`;
}
export function formatDiceNotation(count: number, sides: DiceSides): string {
return `${clampDiceCount(count)}d${sides}`;
}
+1
View File
@@ -22,4 +22,5 @@ export * from './sceneTraps';
export * from './sceneView';
export * from './sessionTimer';
export * from './initiative';
export * from './diceRoll';
export * from './videoPlayback';