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
+57
View File
@@ -18,9 +18,11 @@ import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension'
import type {
AssetId,
GraphNodeId,
MaterialId,
MediaAsset,
Project,
ProjectId,
ProjectMaterial,
SceneAudioRef,
SceneId,
} from '../../shared/types';
@@ -50,6 +52,7 @@ import {
import type { HelpSectionId } from './help/helpSections';
import { useEditorI18n } from './i18n/EditorI18nContext';
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
import { SceneDescriptionModal } from './SceneDescriptionModal';
import type { ProjectNoticeCode } from './state/projectState';
@@ -138,6 +141,8 @@ export function EditorApp() {
const [openKeyAfterEula, setOpenKeyAfterEula] = useState(false);
const licenseActive = licenseSnap?.active === true;
const [appNotice, setAppNotice] = useState<{ title?: string; message: string } | null>(null);
const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(null);
const onProjectNotice = useCallback(
(code: ProjectNoticeCode) => {
const handlers: Record<ProjectNoticeCode, () => void> = {
@@ -961,6 +966,8 @@ export function EditorApp() {
})();
}}
/>
<div className={styles.spacer6} />
<Button onClick={() => setMaterialsManagerOpen(true)}>{t('materials.open')}</Button>
<div className={styles.spacer18} />
<div className={styles.inspectorTitle}>{t('scenes.inspectorScene')}</div>
{state.selectedSceneId ? (
@@ -1411,6 +1418,56 @@ export function EditorApp() {
}}
/>
<CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} />
<MaterialsManagerModal
open={materialsManagerOpen}
materials={state.project?.materials ?? []}
onClose={() => setMaterialsManagerOpen(false)}
onAdd={() => setMaterialEdit('new')}
onEdit={(m) => setMaterialEdit(m)}
onDelete={async (materialId: MaterialId) => {
try {
await actions.deleteMaterial(materialId);
} catch (e) {
setAppNotice({
title: t('common.error'),
message: e instanceof Error ? e.message : String(e),
});
}
}}
onReorder={async (materialIds) => {
try {
await actions.setMaterialsOrder(materialIds);
} catch (e) {
setAppNotice({
title: t('common.error'),
message: e instanceof Error ? e.message : String(e),
});
}
}}
onRotate={(materialId, rotationDeg) => {
void actions.setMaterialRotation(materialId, rotationDeg).catch((e) => {
setAppNotice({
title: t('common.error'),
message: e instanceof Error ? e.message : String(e),
});
});
}}
/>
<MaterialEditModal
open={materialEdit !== null}
initial={materialEdit && materialEdit !== 'new' ? materialEdit : null}
existingNames={(state.project?.materials ?? []).map((m) => m.name)}
onClose={() => setMaterialEdit(null)}
onPickImage={() => actions.pickMaterialImage()}
onSave={async ({ name, filePath }) => {
const materialId = materialEdit && materialEdit !== 'new' ? materialEdit.id : undefined;
await actions.upsertMaterial({
...(materialId ? { materialId } : {}),
name,
...(filePath ? { filePath } : {}),
});
}}
/>
<SimpleMessageModal
open={appNotice !== null}
title={appNotice?.title ?? t('common.message')}