feat(tokens): app-local non-player tokens with session moves and UI polish

Add token library/placements, keep play-time moves for the session, lock presentation interactions, and fix export/import modal layout plus freeform trap label.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-27 11:04:12 +08:00
parent bdeb64e356
commit f270812219
44 changed files with 2617 additions and 341 deletions
+34 -2
View File
@@ -15,11 +15,13 @@ import {
} from '../../shared/graph/sceneListOrder';
import {
buildPartialExportProject,
collectTokenIdsFromProject,
computeGraphImportOffsetX,
listExportableStorylines,
listImportableStorylines,
mergeStorylinesIntoProject,
newExportBundleProjectId,
remapProjectSceneTokenIds,
type NpcImportResolution,
type SceneImportResolution,
type StorylineImportMergeReport,
@@ -51,6 +53,7 @@ import type {
} from '../../shared/types';
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens';
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
import { normalizeSceneTrap } from '../../shared/types/sceneTraps';
import type { AssetId, GraphNodeId, MaterialId, NpcGroupId, NpcId, NpcRelationId } from '../../shared/types/ids';
@@ -617,12 +620,14 @@ export class ZipProjectStore {
previewRotationDeg: 0,
darkenScene: false,
traps: [],
tokens: [],
grid: { ...DEFAULT_SCENE_GRID },
} satisfies Scene);
const next: Scene = {
...base,
traps: base.traps ?? [],
tokens: base.tokens ?? [],
grid: base.grid ?? { ...DEFAULT_SCENE_GRID },
...(patch.title !== undefined ? { title: patch.title } : null),
...(patch.description !== undefined ? { description: patch.description } : null),
@@ -643,6 +648,13 @@ export class ZipProjectStore {
.filter((t): t is SceneTrap => Boolean(t)),
}
: null),
...(patch.tokens !== undefined
? {
tokens: patch.tokens
.map((t) => normalizeSceneToken(t))
.filter((t): t is SceneToken => Boolean(t)),
}
: null),
...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null),
...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
@@ -1992,6 +2004,7 @@ export class ZipProjectStore {
destinationPath: string,
labels: StorylineLabels,
onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void,
packAppTokens?: (tokenIds: string[], exportRoot: string) => Promise<void>,
): Promise<void> {
if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия');
await this.ensureRoots();
@@ -2016,10 +2029,14 @@ export class ZipProjectStore {
await fs.mkdir(path.dirname(destAbs), { recursive: true });
await fs.copyFile(srcAbs, destAbs);
if (onProgress) {
const pct = Math.round(((i + 1) / Math.max(1, assetIds.length)) * 80);
const pct = Math.round(((i + 1) / Math.max(1, assetIds.length)) * 75);
onProgress({ stage: 'zip', percent: pct, detail: 'Сборка архива…' });
}
}
if (packAppTokens) {
const tokenIds = collectTokenIdsFromProject(partial);
await packAppTokens(tokenIds, exportCache);
}
const now = new Date().toISOString();
const toWrite: Project = {
...partial,
@@ -2116,12 +2133,18 @@ export class ZipProjectStore {
sceneResolutions: SceneImportResolution[],
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
npcResolutions?: NpcImportResolution[],
importAppTokens?: (sourceCacheDir: string) => Promise<Map<string, string>>,
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
if (!this.openProject) throw new Error('Нет открытого проекта');
let sourceForMerge = source;
if (importAppTokens) {
const remap = await importAppTokens(sourceCache);
sourceForMerge = remapProjectSceneTokenIds(source, remap);
}
const offsetX = computeGraphImportOffsetX(this.openProject.project);
const { project: merged, report, assetCopies } = mergeStorylinesIntoProject(
this.openProject.project,
source,
sourceForMerge,
selections,
sceneResolutions,
{
@@ -2162,6 +2185,7 @@ export class ZipProjectStore {
sceneResolutions: SceneImportResolution[],
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
npcResolutions?: NpcImportResolution[],
importAppTokens?: (sourceCacheDir: string) => Promise<Map<string, string>>,
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
this.assertStorylineMergeAllowed(selections);
const snap = await this.loadProjectSnapshot(sourceProjectId);
@@ -2173,6 +2197,7 @@ export class ZipProjectStore {
sceneResolutions,
onProgress,
npcResolutions,
importAppTokens,
);
} finally {
if (snap.ownsCache) {
@@ -2187,6 +2212,7 @@ export class ZipProjectStore {
sceneResolutions: SceneImportResolution[],
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
npcResolutions?: NpcImportResolution[],
importAppTokens?: (sourceCacheDir: string) => Promise<Map<string, string>>,
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
this.assertStorylineMergeAllowed(selections);
const source = await this.readExternalProjectForImport(sourcePath);
@@ -2201,6 +2227,7 @@ export class ZipProjectStore {
sceneResolutions,
onProgress,
npcResolutions,
importAppTokens,
);
} finally {
await fs.rm(sourceCache, { recursive: true, force: true }).catch(() => undefined);
@@ -2329,6 +2356,10 @@ function normalizeScene(s: Scene): Scene {
const traps = (Array.isArray(rawTraps) ? rawTraps : [])
.map((t) => normalizeSceneTrap(t))
.filter((t): t is SceneTrap => Boolean(t));
const rawTokens = (s as unknown as { tokens?: unknown[] }).tokens;
const tokens = (Array.isArray(rawTokens) ? rawTokens : [])
.map((t) => normalizeSceneToken(t))
.filter((t): t is SceneToken => Boolean(t));
const grid = normalizeSceneGrid((s as unknown as { grid?: unknown }).grid);
const rawAudios = Array.isArray(raw.audios) ? raw.audios : [];
@@ -2359,6 +2390,7 @@ function normalizeScene(s: Scene): Scene {
previewRotationDeg,
darkenScene,
traps,
tokens,
grid,
layout: layoutIn ?? { x: 0, y: 0 },
media: {