feat: save games and edit scene darkness

Persist show state to local saves; author map darkness in scene editor with bake-to-scene and help updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-10-06 11:41:42 +08:00
parent f6c301a12b
commit 877fbaa8e7
43 changed files with 2086 additions and 72 deletions
+104
View File
@@ -0,0 +1,104 @@
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<string, unknown>;
const out: Record<string, unknown> = {};
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<string, TokenPathPlaybackEntry>,
pathFor: (entry: TokenPathPlaybackEntry) => TokenPath | null,
nowMs: number,
): Record<string, TokenPathPlaybackEntry> {
const out: Record<string, TokenPathPlaybackEntry> = {};
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,
};
}