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
+1
View File
@@ -23,6 +23,7 @@ function scene(id: string): Scene {
previewRotationDeg: 0,
darkenScene: false,
traps: [],
tokens: [],
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
@@ -28,6 +28,7 @@ function scene(id: string, title: string): Scene {
previewRotationDeg: 0,
darkenScene: false,
traps: [],
tokens: [],
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
+34 -1
View File
@@ -11,7 +11,7 @@ import type {
SceneGraphNode,
SceneId,
} from '../types';
import type { AssetId, NpcGroupId, ProjectId } from '../types/ids';
import type { AssetId, NpcGroupId, ProjectId, TokenId } from '../types/ids';
import {
asAssetId,
asGraphNodeId,
@@ -21,6 +21,7 @@ import {
asNpcRelationId,
asProjectId,
asSceneId,
asTokenId,
} from '../types/ids';
import {
@@ -812,3 +813,35 @@ function remapNpcBinding(
export function newExportBundleProjectId(): ProjectId {
return asProjectId(`p_${generateId()}`);
}
/** Id app-токенов, реально стоящих на сценах проекта (для экспорта с линией). */
export function collectTokenIdsFromProject(project: Project): TokenId[] {
const ids = new Set<string>();
for (const scene of Object.values(project.scenes)) {
for (const t of scene.tokens ?? []) {
if (t.tokenId) ids.add(t.tokenId);
}
}
return [...ids].map((id) => asTokenId(id));
}
/** Переписать tokenId на сценах после импорта в app-пул. */
export function remapProjectSceneTokenIds(
project: Project,
remap: ReadonlyMap<string, string>,
): Project {
if (remap.size === 0) return project;
const scenes: Record<SceneId, Scene> = { ...project.scenes };
for (const sid of Object.keys(scenes) as SceneId[]) {
const scene = scenes[sid];
if (!scene?.tokens?.length) continue;
scenes[sid] = {
...scene,
tokens: scene.tokens.map((t) => ({
...t,
tokenId: asTokenId(remap.get(t.tokenId) ?? t.tokenId),
})),
};
}
return { ...project, scenes };
}
+51
View File
@@ -17,16 +17,21 @@ import type {
NpcsOverlayState,
Project,
ProjectId,
AppToken,
Scene,
SceneDarknessEvent,
SceneDarknessState,
SceneGrid,
SceneId,
SceneToken,
SceneTokensSessionEvent,
SceneTokensSessionState,
SceneTrap,
SceneTrapsEvent,
SceneTrapsState,
SceneViewEvent,
SceneViewState,
TokenId,
VideoPlaybackEvent,
VideoPlaybackState,
} from '../types';
@@ -164,6 +169,19 @@ export const ipcChannels = {
dispatch: 'sceneView.dispatch',
stateChanged: 'sceneView.stateChanged',
},
tokens: {
list: 'tokens.list',
upsert: 'tokens.upsert',
delete: 'tokens.delete',
pickImage: 'tokens.pickImage',
imageUrl: 'tokens.imageUrl',
stateChanged: 'tokens.stateChanged',
},
sceneTokensSession: {
getState: 'sceneTokensSession.getState',
dispatch: 'sceneTokensSession.dispatch',
stateChanged: 'sceneTokensSession.stateChanged',
},
video: {
getState: 'video.getState',
dispatch: 'video.dispatch',
@@ -232,6 +250,8 @@ export type IpcEventMap = {
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
@@ -651,6 +671,34 @@ export type IpcInvokeMap = {
req: { event: SceneViewEvent };
res: { ok: true };
};
[ipcChannels.tokens.list]: {
req: Record<string, never>;
res: { tokens: AppToken[] };
};
[ipcChannels.tokens.upsert]: {
req: { id?: TokenId | null; name: string; filePath?: string | null };
res: { token: AppToken };
};
[ipcChannels.tokens.delete]: {
req: { id: TokenId };
res: { ok: true };
};
[ipcChannels.tokens.pickImage]: {
req: Record<string, never>;
res: { canceled: true } | { canceled: false; filePath: string; previewDataUrl: string };
};
[ipcChannels.tokens.imageUrl]: {
req: { id: TokenId };
res: { url: string | null };
};
[ipcChannels.sceneTokensSession.getState]: {
req: Record<string, never>;
res: { state: SceneTokensSessionState };
};
[ipcChannels.sceneTokensSession.dispatch]: {
req: { event: SceneTokensSessionEvent };
res: { ok: true };
};
[ipcChannels.video.getState]: {
req: Record<string, never>;
res: { state: VideoPlaybackState };
@@ -690,6 +738,8 @@ export type LegacyIpcEventMap = {
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: Record<string, never>;
};
@@ -704,6 +754,7 @@ export type ScenePatch = {
previewRotationDeg?: 0 | 90 | 180 | 270;
darkenScene?: boolean;
traps?: SceneTrap[];
tokens?: SceneToken[];
grid?: SceneGrid;
settings?: Partial<Scene['settings']>;
media?: Partial<Scene['media']>;
+71
View File
@@ -0,0 +1,71 @@
/** App-local библиотека неигровых токенов (userData, не в project zip). */
import type { SceneTokenId, TokenId } from './ids';
import { asSceneTokenId, asTokenId } from './ids';
export type { SceneTokenId, TokenId };
export { asSceneTokenId, asTokenId };
export type AppToken = {
id: TokenId;
name: string;
/** Относительный путь файла в каталоге `userData/tokens/`. */
imageRelPath: string;
/** sha256 содержимого файла для дедупа при импорте. */
sha256: string;
};
/** Расстановка токена на карте сцены (в проекте). */
export type SceneToken = {
id: SceneTokenId;
tokenId: TokenId;
nx: number;
ny: number;
sizeN: number;
/** Непрерывный угол поворота в градусах. */
rotationDeg: number;
};
export const DEFAULT_SCENE_TOKEN_SIZE_N = 0.08;
export const SCENE_TOKEN_SIZE_MIN = 0.02;
export const SCENE_TOKEN_SIZE_MAX = 0.45;
export type SceneTokensSessionState = {
revision: number;
/** Смещение позиции на текущую сессию (только nx/ny). */
byPlacementId: Record<string, { nx: number; ny: number }>;
};
export type SceneTokensSessionEvent =
| { kind: 'move'; placementId: string; nx: number; ny: number }
| { kind: 'clear' };
export function clampSceneTokenSizeN(sizeN: number): number {
if (!Number.isFinite(sizeN)) return DEFAULT_SCENE_TOKEN_SIZE_N;
return Math.max(SCENE_TOKEN_SIZE_MIN, Math.min(SCENE_TOKEN_SIZE_MAX, sizeN));
}
export function normalizeSceneToken(raw: unknown): SceneToken | null {
if (!raw || typeof raw !== 'object') return null;
const obj = raw as Partial<SceneToken>;
if (typeof obj.id !== 'string' || !obj.id) return null;
if (typeof obj.tokenId !== 'string' || !obj.tokenId) return null;
const nx = typeof obj.nx === 'number' && Number.isFinite(obj.nx) ? obj.nx : null;
const ny = typeof obj.ny === 'number' && Number.isFinite(obj.ny) ? obj.ny : null;
if (nx === null || ny === null) return null;
const sizeN = clampSceneTokenSizeN(typeof obj.sizeN === 'number' ? obj.sizeN : DEFAULT_SCENE_TOKEN_SIZE_N);
const rotationDeg =
typeof obj.rotationDeg === 'number' && Number.isFinite(obj.rotationDeg) ? obj.rotationDeg : 0;
return {
id: asSceneTokenId(obj.id),
tokenId: asTokenId(obj.tokenId),
nx: Math.max(0, Math.min(1, nx)),
ny: Math.max(0, Math.min(1, ny)),
sizeN,
rotationDeg,
};
}
export type AppTokensExportBundle = {
tokens: AppToken[];
};
+4 -1
View File
@@ -9,10 +9,11 @@ import type {
SceneId,
} from './ids';
import type { MaterialLegend } from './materialLegend';
import type { SceneToken } from './appTokens';
import type { SceneGrid } from './sceneGrid';
import type { SceneTrap } from './sceneTraps';
export const PROJECT_SCHEMA_VERSION = 9 as const;
export const PROJECT_SCHEMA_VERSION = 10 as const;
/** Материал кампании: изображение, показываемое поверх сцены во время игры. */
export type ProjectMaterial = {
@@ -163,6 +164,8 @@ export type Scene = {
darkenScene: boolean;
/** Ловушки на карте (только для image-превью); расстановка в проекте. */
traps: SceneTrap[];
/** Неигровые токены на карте (ссылки на app-local пул). */
tokens: SceneToken[];
/** Боевая сетка поверх превью (под ловушками/эффектами). */
grid: SceneGrid;
media: SceneMediaRefs;
+10
View File
@@ -8,6 +8,8 @@ export type MaterialId = Brand<string, 'MaterialId'>;
export type NpcId = Brand<string, 'NpcId'>;
export type NpcRelationId = Brand<string, 'NpcRelationId'>;
export type NpcGroupId = Brand<string, 'NpcGroupId'>;
export type TokenId = Brand<string, 'TokenId'>;
export type SceneTokenId = Brand<string, 'SceneTokenId'>;
export function asProjectId(value: string): ProjectId {
return value as ProjectId;
@@ -40,3 +42,11 @@ export function asNpcRelationId(value: string): NpcRelationId {
export function asNpcGroupId(value: string): NpcGroupId {
return value as NpcGroupId;
}
export function asTokenId(value: string): TokenId {
return value as TokenId;
}
export function asSceneTokenId(value: string): SceneTokenId {
return value as SceneTokenId;
}
+1
View File
@@ -1,3 +1,4 @@
export * from './appTokens';
export * from './domain';
export * from './effects';
export * from './ids';
+1 -1
View File
@@ -114,7 +114,7 @@ export function trapTypeLabelRu(type: SceneTrapType): string {
case 'laser':
return 'Лазер';
case 'freeform':
return 'Свободная';
return 'Метка';
default: {
const _x: never = type;
return String(_x);