feat(control): 3D-кубики в отдельном окне
Бросок синхронизирован на пульте и презентации; d100 как десятки; оверлей скрывается только кнопкой «Убрать». Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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' },
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -22,4 +22,5 @@ export * from './sceneTraps';
|
||||
export * from './sceneView';
|
||||
export * from './sessionTimer';
|
||||
export * from './initiative';
|
||||
export * from './diceRoll';
|
||||
export * from './videoPlayback';
|
||||
|
||||
Reference in New Issue
Block a user