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
+20 -1
View File
@@ -15,7 +15,7 @@ export function appDisplayNameForLocale(localeTag: string): string {
/** Префикс заголовка окон: `TTRPG - Редактор`. */
export const APP_WINDOW_BRAND = 'TTRPG';
export type AppWindowKind = 'editor' | 'presentation' | 'control' | 'boot' | 'sceneDescription';
export type AppWindowKind = 'editor' | 'presentation' | 'control' | 'boot' | 'sceneDescription' | 'materials';
const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
editor: { ru: 'Редактор', en: 'Editor' },
@@ -23,6 +23,7 @@ const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
control: { ru: 'Пульт', en: 'Control' },
boot: { ru: 'Загрузка', en: 'Loading' },
sceneDescription: { ru: 'Описание сцены', en: 'Scene description' },
materials: { ru: 'Материалы', en: 'Materials' },
};
export function windowChromeTitle(kind: AppWindowKind, localeTag: string): string {
@@ -30,3 +31,21 @@ export function windowChromeTitle(kind: AppWindowKind, localeTag: string): strin
const suffix = tag.startsWith('ru') ? WINDOW_SUFFIX[kind].ru : WINDOW_SUFFIX[kind].en;
return `${APP_WINDOW_BRAND} - ${suffix}`;
}
/** Подписи фильтров в системных диалогах выбора файла (main process). */
export function openDialogFilterLabel(
kind: 'images' | 'imagesAndVideo' | 'audio' | 'videoAndAudio',
localeTag: string,
): string {
const ru = localeTag.trim().toLowerCase().startsWith('ru');
switch (kind) {
case 'imagesAndVideo':
return ru ? 'Изображения и видео' : 'Images and video';
case 'audio':
return ru ? 'Аудио' : 'Audio';
case 'videoAndAudio':
return ru ? 'Видео и аудио' : 'Video and audio';
default:
return ru ? 'Изображения' : 'Images';
}
}
@@ -61,12 +61,13 @@ function minimalProject(overrides: Partial<Project> = {}): Project {
updatedAt: '2020-01-01T00:00:00.000Z',
createdWithAppVersion: '1',
appVersion: '1',
schemaVersion: 5,
schemaVersion: 7,
},
scenes: {},
sceneListOrder: [],
assets: {},
campaignAudios: [],
materials: [],
currentSceneId: null,
currentGraphNodeId: null,
sceneGraphNodes: [],
+28 -1
View File
@@ -14,7 +14,7 @@ import type {
SceneId,
} from '../types';
import type { AssetId, ProjectId } from '../types/ids';
import { asAssetId, asGraphNodeId, asProjectId, asSceneId } from '../types/ids';
import { asAssetId, asGraphNodeId, asMaterialId, asProjectId, asSceneId } from '../types/ids';
export type StorylineKind = 'main' | 'side';
@@ -273,6 +273,7 @@ export function buildPartialExportProject(
...draft,
assets,
campaignAudios: source.campaignAudios.map((a) => ({ ...a })),
materials: (source.materials ?? []).map((m) => ({ ...m })),
currentSceneId: mainStart?.sceneId ?? firstSide?.sceneId ?? null,
currentGraphNodeId: mainStart?.id ?? firstSide?.id ?? null,
};
@@ -287,6 +288,7 @@ function collectReferencedAssetIdsForProject(p: Project): Set<AssetId> {
for (const au of sc.media.audios) refs.add(au.assetId);
}
for (const au of p.campaignAudios) refs.add(au.assetId);
for (const m of p.materials ?? []) refs.add(m.assetId);
return refs;
}
@@ -373,6 +375,7 @@ export function mergeStorylinesIntoProject(
for (const au of sc.media.audios) neededAssetIds.add(au.assetId);
}
for (const au of source.campaignAudios) neededAssetIds.add(au.assetId);
for (const m of source.materials ?? []) neededAssetIds.add(m.assetId);
const assetMap = new Map<AssetId, AssetId>();
const targetSha = new Map<string, AssetId>();
@@ -483,11 +486,35 @@ export function mergeStorylinesIntoProject(
}
}
const materials = [...(target.materials ?? [])];
const materialNameKeys = new Set(materials.map((m) => m.name.trim().toLowerCase()));
const materialAssetIds = new Set(materials.map((m) => m.assetId));
for (const m of source.materials ?? []) {
const mapped = assetMap.get(m.assetId) ?? m.assetId;
if (materialAssetIds.has(mapped)) continue;
let name = m.name.trim();
const baseKey = name.toLowerCase();
if (materialNameKeys.has(baseKey)) {
let n = 2;
while (materialNameKeys.has(`${baseKey} (${String(n)})`)) n += 1;
name = `${name} (${String(n)})`;
}
materials.push({
id: asMaterialId(`mat_${generateId()}`),
name,
assetId: mapped,
rotationDeg: m.rotationDeg ?? 0,
});
materialNameKeys.add(name.toLowerCase());
materialAssetIds.add(mapped);
}
let merged: Project = {
...target,
scenes,
assets,
campaignAudios,
materials,
sceneGraphNodes: [...target.sceneGraphNodes, ...newGraphNodes],
sceneGraphEdges: [...target.sceneGraphEdges, ...newEdges],
};
+55
View File
@@ -4,6 +4,9 @@ import type {
EffectsEvent,
EffectsState,
GraphNodeId,
MaterialId,
MaterialsOverlayEvent,
MaterialsOverlayState,
MediaAsset,
Project,
ProjectId,
@@ -46,6 +49,11 @@ export const ipcChannels = {
importMedia: 'project.importMedia',
importCampaignAudio: 'project.importCampaignAudio',
updateCampaignAudios: 'project.updateCampaignAudios',
upsertMaterial: 'project.upsertMaterial',
setMaterialRotation: 'project.setMaterialRotation',
deleteMaterial: 'project.deleteMaterial',
setMaterialsOrder: 'project.setMaterialsOrder',
pickMaterialImage: 'project.pickMaterialImage',
importScenePreview: 'project.importScenePreview',
clearScenePreview: 'project.clearScenePreview',
assetFileUrl: 'project.assetFileUrl',
@@ -85,6 +93,8 @@ export const ipcChannels = {
closeSceneDescription: 'windows.closeSceneDescription',
getSceneDescriptionContent: 'windows.getSceneDescriptionContent',
sceneDescriptionContent: 'windows.sceneDescriptionContent',
openMaterials: 'windows.openMaterials',
closeMaterials: 'windows.closeMaterials',
},
session: {
stateChanged: 'session.stateChanged',
@@ -94,6 +104,11 @@ export const ipcChannels = {
dispatch: 'effects.dispatch',
stateChanged: 'effects.stateChanged',
},
materialsOverlay: {
getState: 'materialsOverlay.getState',
dispatch: 'materialsOverlay.dispatch',
stateChanged: 'materialsOverlay.stateChanged',
},
sceneDarkness: {
getState: 'sceneDarkness.getState',
dispatch: 'sceneDarkness.dispatch',
@@ -156,6 +171,7 @@ export type UpdaterProgressEvent = {
export type IpcEventMap = {
[ipcChannels.session.stateChanged]: { state: SessionState };
[ipcChannels.effects.stateChanged]: { state: EffectsState };
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
@@ -236,6 +252,28 @@ export type IpcInvokeMap = {
req: { audios: Project['campaignAudios'] };
res: { project: Project };
};
[ipcChannels.project.upsertMaterial]: {
req: { materialId?: MaterialId; name: string; filePath?: string };
res: { project: Project };
};
[ipcChannels.project.setMaterialRotation]: {
req: { materialId: MaterialId; rotationDeg: 0 | 90 | 180 | 270 };
res: { project: Project };
};
[ipcChannels.project.deleteMaterial]: {
req: { materialId: MaterialId };
res: { project: Project };
};
[ipcChannels.project.setMaterialsOrder]: {
req: { materialIds: MaterialId[] };
res: { project: Project };
};
[ipcChannels.project.pickMaterialImage]: {
req: Record<string, never>;
res:
| { canceled: true }
| { canceled: false; filePath: string; previewDataUrl: string };
};
[ipcChannels.project.importScenePreview]: {
req: { sceneId: SceneId; filePath?: string };
res: { project: Project; assetId: AssetId | null; background: boolean };
@@ -390,6 +428,22 @@ export type IpcInvokeMap = {
req: Record<string, never>;
res: { html: string };
};
[ipcChannels.windows.openMaterials]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.windows.closeMaterials]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.materialsOverlay.getState]: {
req: Record<string, never>;
res: { state: MaterialsOverlayState };
};
[ipcChannels.materialsOverlay.dispatch]: {
req: { event: MaterialsOverlayEvent };
res: { ok: true };
};
[ipcChannels.effects.getState]: {
req: Record<string, never>;
res: { state: EffectsState };
@@ -440,6 +494,7 @@ export type SessionState = {
export type LegacyIpcEventMap = {
[ipcChannels.session.stateChanged]: { state: SessionState };
[ipcChannels.effects.stateChanged]: { state: EffectsState };
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: Record<string, never>;
+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,
});
}