import { computePathPlaybackSample } from '../types/tokenPathPlayback'; import type { GameSaveBody } from '../types/gameSave'; import { GAME_SAVE_SCHEMA_VERSION } from '../types/gameSave'; import type { Project } from '../types/domain'; import type { TokenPath } from '../types/tokenPath'; import type { TokenPathPlaybackEntry } from '../types/tokenPathSession'; export function defaultGameSaveName(now = new Date()): string { const p = (n: number) => String(n).padStart(2, '0'); return `${p(now.getDate())}.${p(now.getMonth() + 1)}.${now.getFullYear()} ${p(now.getHours())}:${p(now.getMinutes())}`; } export function canonicalGameSave(body: GameSaveBody): string { return JSON.stringify(sortValue(body)); } function sortValue(value: unknown): unknown { if (Array.isArray(value)) return value.map(sortValue); if (value && typeof value === 'object') { const src = value as Record; const out: Record = {}; for (const key of Object.keys(src).sort()) out[key] = sortValue(src[key]); return out; } return value; } export function pathForPlacement(project: Project, kind: 'token' | 'npcToken', placementId: string): TokenPath | null { for (const scene of Object.values(project.scenes)) { const list = kind === 'token' ? scene.tokens : scene.npcTokens; const found = (list ?? []).find((token) => String(token.id) === placementId); if (found?.path && found.path.points.length >= 2) return found.path; } return null; } /** Движение и задержка становятся паузой в текущей точке пути. */ export function freezeTokenPathPlayback( playback: Record, pathFor: (entry: TokenPathPlaybackEntry) => TokenPath | null, nowMs: number, ): Record { const out: Record = {}; for (const [key, entry] of Object.entries(playback)) { out[key] = freezeEntry(entry, pathFor(entry), nowMs); } return out; } function freezeEntry(entry: TokenPathPlaybackEntry, path: TokenPath | null, nowMs: number): TokenPathPlaybackEntry { if (entry.phase === 'stopped' || entry.phase === 'done' || !path) { const phase = entry.phase === 'moving' || entry.phase === 'delay' ? 'stopped' : entry.phase; return { ...entry, phase, rejoinDist: null, segmentStartedAtMs: 0 }; } const sample = computePathPlaybackSample({ path, entry, nowMs }); if (!sample) { return { ...entry, phase: 'stopped', rejoinDist: null, segmentStartedAtMs: 0 }; } const phase = sample.markDone || sample.phase === 'done' ? 'done' : 'stopped'; return { ...entry, phase, baseDist: sample.sample.dist, rejoinDist: null, segmentStartedAtMs: 0, direction: 1, }; } export function emptyGameSaveBody(projectId: string): GameSaveBody { return { schemaVersion: GAME_SAVE_SCHEMA_VERSION, projectId, graphNodeId: null, sceneId: null, darknessByCard: {}, markersByCard: {}, trapsByCard: {}, effectsByCard: {}, sceneTokensByPlacementId: {}, npcTokens: { byPlacementId: {}, scale: 1, spawned: {} }, tokenPaths: { presentationVisible: {}, playback: {} }, timer: { durationMs: 0, remainingMs: 0, visibleToPlayers: false, layout: { cx: 0, cy: 0, scale: 1, rotationDeg: 0 }, }, playerTokens: { selectedPlayerIds: [], visible: false, byPlayerId: {} }, camera: { scale: 1, ox: 0.5, oy: 0.5 }, materials: { activeMaterialIds: [], layouts: {}, legendLayouts: {}, focusMaterialId: null }, npcAvatars: { activeNpcIds: [], layouts: {}, focusNpcId: null }, initiative: { enabled: false, entries: {}, order: [], reserve: [], activeId: null, conditions: {}, playersVisible: false, }, tokenGridSnap: false, }; }