feat(materials): add campaign materials overlay for sessions

Let GMs manage and show images over the scene from the editor and control panel, with zoom tools, help docs, and full ru/en i18n.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-17 11:05:38 +08:00
parent 195d4be086
commit 61875be857
39 changed files with 2521 additions and 22 deletions
+12 -2
View File
@@ -1,6 +1,14 @@
import type { AssetId, GraphNodeId, ProjectId, SceneId } from './ids';
import type { AssetId, GraphNodeId, MaterialId, ProjectId, SceneId } from './ids';
export const PROJECT_SCHEMA_VERSION = 5 as const;
export const PROJECT_SCHEMA_VERSION = 7 as const;
/** Материал кампании: изображение, показываемое поверх сцены во время игры. */
export type ProjectMaterial = {
id: MaterialId;
name: string;
assetId: AssetId;
rotationDeg: 0 | 90 | 180 | 270;
};
export type IsoDateTimeString = string;
@@ -135,6 +143,8 @@ export type Project = {
assets: Record<AssetId, MediaAsset>;
/** Аудио кампании: играет в пульте на протяжении всей презентации, если в сцене нет своей музыки. */
campaignAudios: SceneAudioRef[];
/** Материалы кампании: изображения для показа поверх сцены (порядок = порядок в списке). */
materials: ProjectMaterial[];
currentSceneId: SceneId | null;
/** Текущая нода графа (важно, когда одна сцена имеет несколько нод). */
currentGraphNodeId: GraphNodeId | null;
+5
View File
@@ -4,6 +4,7 @@ export type ProjectId = Brand<string, 'ProjectId'>;
export type SceneId = Brand<string, 'SceneId'>;
export type AssetId = Brand<string, 'AssetId'>;
export type GraphNodeId = Brand<string, 'GraphNodeId'>;
export type MaterialId = Brand<string, 'MaterialId'>;
export function asProjectId(value: string): ProjectId {
return value as ProjectId;
@@ -20,3 +21,7 @@ export function asAssetId(value: string): AssetId {
export function asGraphNodeId(value: string): GraphNodeId {
return value as GraphNodeId;
}
export function asMaterialId(value: string): MaterialId {
return value as MaterialId;
}
+1
View File
@@ -1,5 +1,6 @@
export * from './domain';
export * from './effects';
export * from './ids';
export * from './materials';
export * from './sceneDarkness';
export * from './videoPlayback';
+61
View File
@@ -0,0 +1,61 @@
import type { MaterialId } from './ids';
/** Нормированная раскладка материала в области показа (0..1). */
export type MaterialsOverlayLayout = {
/** Центр по X относительно вьюпорта. */
cx: number;
/** Центр по Y относительно вьюпорта. */
cy: number;
/** Масштаб относительно «базового contain» (1 = по умолчанию). */
scale: number;
};
export type MaterialsZoomTool = 'zoomIn' | 'zoomOut' | null;
/** Session-only: какой материал сейчас показан поверх сцены. */
export type MaterialsOverlayState = {
revision: number;
activeMaterialId: MaterialId | null;
layout: MaterialsOverlayLayout;
zoomTool: MaterialsZoomTool;
};
export const DEFAULT_MATERIALS_OVERLAY_LAYOUT: MaterialsOverlayLayout = {
cx: 0.5,
cy: 0.5,
scale: 1,
};
export type MaterialsOverlayEvent =
| { kind: 'show'; materialId: MaterialId }
| { kind: 'hide' }
| { kind: 'toggle'; materialId: MaterialId }
| { kind: 'layout.set'; layout: MaterialsOverlayLayout }
| { kind: 'zoomTool.set'; tool: MaterialsZoomTool }
| { kind: 'zoomAt'; nx: number; ny: number };
export function clampMaterialsLayout(layout: MaterialsOverlayLayout): MaterialsOverlayLayout {
return {
cx: Math.min(1.2, Math.max(-0.2, layout.cx)),
cy: Math.min(1.2, Math.max(-0.2, layout.cy)),
scale: Math.min(8, Math.max(0.15, layout.scale)),
};
}
/** Zoom around normalized point so that point stays fixed. */
export function zoomMaterialsLayoutAt(
layout: MaterialsOverlayLayout,
nx: number,
ny: number,
factor: number,
): MaterialsOverlayLayout {
const nextScale = layout.scale * factor;
const clamped = clampMaterialsLayout({ ...layout, scale: nextScale });
const ratio = clamped.scale / layout.scale;
return clampMaterialsLayout({
...layout,
cx: nx - (nx - layout.cx) * ratio,
cy: ny - (ny - layout.cy) * ratio,
scale: clamped.scale,
});
}