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
+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';