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:
@@ -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 },
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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']>;
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from './appTokens';
|
||||
export * from './domain';
|
||||
export * from './effects';
|
||||
export * from './ids';
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user