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:
@@ -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