feat: кубики/таймер/кисть/спонсоры и задел игровых систем

Показ анимации кубиков игрокам, закрытие оверлеев с окнами, ручной ввод количества, меньший радиус кисти, блок спонсоров в «О программе»; плюс черновик game systems / character sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-13 15:37:44 +08:00
parent 3d0474fe97
commit 712086ddca
32 changed files with 1712 additions and 34 deletions
+40 -1
View File
@@ -31,6 +31,16 @@ export type AppPlayer = {
imageOffset: PlayerImageOffset;
/** Масштаб аватара внутри круга (1 = по умолчанию). */
imageScale: number;
/** Optional TTRPG system id from Game Systems catalog. */
systemId?: string | null;
/** Frozen sheet binding: snapshotHash + values (schema freeze on bind). */
characterSheet?: {
systemId: string;
snapshotHash: string;
schemaVersion: number;
revisionAtBind: number;
values: Record<string, unknown>;
} | null;
};
/** Расстановка кампанийного НПС на карте как игрового токена (в проекте). */
@@ -276,7 +286,7 @@ export function normalizeAppPlayer(raw: unknown, teamIds?: Set<string>): AppPlay
if (typeof obj.teamId === 'string' && obj.teamId) {
if (!teamIds || teamIds.has(obj.teamId)) teamId = asPlayerTeamId(obj.teamId);
}
return {
const out: AppPlayer = {
id: asPlayerId(obj.id),
name,
imageRelPath: obj.imageRelPath,
@@ -286,6 +296,35 @@ export function normalizeAppPlayer(raw: unknown, teamIds?: Set<string>): AppPlay
imageOffset: clampPlayerImageOffset(obj.imageOffset),
imageScale: clampPlayerImageScale((obj as { imageScale?: unknown }).imageScale),
};
if (obj.systemId === null) out.systemId = null;
else if (typeof obj.systemId === 'string') out.systemId = obj.systemId;
if (obj.characterSheet === null) out.characterSheet = null;
else if (obj.characterSheet && typeof obj.characterSheet === 'object') {
const cs = obj.characterSheet as {
systemId?: unknown;
snapshotHash?: unknown;
schemaVersion?: unknown;
revisionAtBind?: unknown;
values?: unknown;
};
if (
typeof cs.systemId === 'string' &&
typeof cs.snapshotHash === 'string' &&
typeof cs.schemaVersion === 'number' &&
typeof cs.revisionAtBind === 'number' &&
cs.values &&
typeof cs.values === 'object'
) {
out.characterSheet = {
systemId: cs.systemId,
snapshotHash: cs.snapshotHash,
schemaVersion: cs.schemaVersion,
revisionAtBind: cs.revisionAtBind,
values: cs.values as Record<string, unknown>,
};
}
}
return out;
}
export function normalizeSceneNpcToken(raw: unknown): SceneNpcToken | null {
+11 -3
View File
@@ -14,8 +14,10 @@ export type DiceRollRequest = {
export type DiceRollState = {
revision: number;
/** Идёт показ броска (оверлей виден). */
/** Идёт показ броска (оверлей виден на презентации). */
active: boolean;
/** Показывать 3D-анимацию игрокам на экране презентации. */
visibleToPlayers: boolean;
/** Например "2d20". */
notation: string;
/** Заранее выбранные исходы — для синхрона пульт/презентация. */
@@ -27,6 +29,7 @@ export type DiceRollState = {
export type DiceRollEvent =
| { kind: 'roll'; count: number; sides: DiceSides }
| { kind: 'setVisibleToPlayers'; visible: boolean }
| { kind: 'hideOverlay' }
| { kind: 'clear' };
@@ -34,6 +37,7 @@ export function createInitialDiceRollState(): DiceRollState {
return {
revision: 1,
active: false,
visibleToPlayers: true,
notation: '',
values: [],
total: 0,
@@ -41,9 +45,13 @@ export function createInitialDiceRollState(): DiceRollState {
};
}
/** Минимум / максимум количества кубиков в UI и store. */
export const DICE_COUNT_MIN = 0;
export const DICE_COUNT_MAX = 20;
export function clampDiceCount(n: number): number {
if (!Number.isFinite(n)) return 1;
return Math.max(1, Math.min(20, Math.round(n)));
if (!Number.isFinite(n)) return DICE_COUNT_MIN;
return Math.max(DICE_COUNT_MIN, Math.min(DICE_COUNT_MAX, Math.round(n)));
}
/** Грани d100 в dice-box-threejs: 10,20,…,90,100 (лицо «00»). */