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
+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);