feat(control): 3D-кубики в отдельном окне
Бросок синхронизирован на пульте и презентации; d100 как десятки; оверлей скрывается только кнопкой «Убрать». Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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