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
+6
View File
@@ -13,6 +13,7 @@ import type { SceneToken } from './appTokens';
import type { SceneNpcToken, PlayerImageOffset } from './appPlayers';
import type { NpcDisposition } from './npcDisposition';
import type { SceneGrid, SceneGridTemplate } from './sceneGrid';
import type { SceneDarknessRevealStroke } from './sceneDarkness';
import type { SceneTrap } from './sceneTraps';
export const PROJECT_SCHEMA_VERSION = 12 as const;
@@ -185,6 +186,11 @@ export type Scene = {
previewRotationDeg: 0 | 90 | 180 | 270;
/** В режиме показа: сцена начинается полностью затемнённой; мастер «раскрывает» кистью. */
darkenScene: boolean;
/**
* Стартовые штрихи затемнения (база в файле партии).
* При входе на карточку без сессионного кэша поднимаются в показ.
*/
darknessStrokes: SceneDarknessRevealStroke[];
/** Ловушки на карте (только для image-превью); расстановка в проекте. */
traps: SceneTrap[];
/** Неигровые токены на карте (ссылки на app-local пул). */
+12
View File
@@ -199,3 +199,15 @@ export type EffectsEvent =
*/
hitMode?: 'inclusive' | 'brush';
};
/** Туман, огонь, дождь, вода и следы льда/тьмы живут на карточке, пока идёт показ. */
export function isPersistentFieldEffect(type: EffectInstanceType): boolean {
return (
type === 'fog' ||
type === 'fire' ||
type === 'rain' ||
type === 'water' ||
type === 'ice' ||
type === 'shadow'
);
}
+74
View File
@@ -0,0 +1,74 @@
import type { SceneNpcSessionSpawn, SceneNpcTokensSessionPlacement, ScenePlayerTokenPlacement } from './appPlayers';
import type { SceneTokensSessionPlacement } from './appTokens';
import type { EffectInstance } from './effects';
import type { InitiativeState } from './initiative';
import type { MaterialsOverlayLayout } from './materials';
import type { NpcsOverlayLayout } from './npcs';
import type { SceneDarknessRevealStroke } from './sceneDarkness';
import type { SceneMarkerStroke } from './sceneMarker';
import type { SceneTrapRuntime } from './sceneTraps';
import type { SceneViewCamera } from './sceneView';
import type { SessionTimerLayout } from './sessionTimer';
import type { TokenPathPlaybackEntry } from './tokenPathSession';
export const GAME_SAVE_SCHEMA_VERSION = 1;
export type GameSaveListItem = {
id: string;
name: string;
savedAt: string;
};
/** Тело слепка без id/имени/времени записи: по нему сравнивают «стол изменился». */
export type GameSaveBody = {
schemaVersion: typeof GAME_SAVE_SCHEMA_VERSION;
projectId: string;
graphNodeId: string | null;
sceneId: string | null;
darknessByCard: Record<string, SceneDarknessRevealStroke[]>;
markersByCard: Record<string, SceneMarkerStroke[]>;
trapsByCard: Record<string, Record<string, SceneTrapRuntime>>;
effectsByCard: Record<string, EffectInstance[]>;
sceneTokensByPlacementId: Record<string, SceneTokensSessionPlacement>;
npcTokens: {
byPlacementId: Record<string, SceneNpcTokensSessionPlacement>;
scale: number;
spawned: Record<string, SceneNpcSessionSpawn>;
};
tokenPaths: {
presentationVisible: Record<string, true>;
playback: Record<string, TokenPathPlaybackEntry>;
};
timer: {
durationMs: number;
remainingMs: number;
visibleToPlayers: boolean;
layout: SessionTimerLayout;
};
/** null — токены игроков в момент сохранения не были показаны. */
playerTokens: {
selectedPlayerIds: string[];
visible: boolean;
byPlayerId: Record<string, ScenePlayerTokenPlacement>;
};
camera: SceneViewCamera;
materials: {
activeMaterialIds: string[];
layouts: Record<string, MaterialsOverlayLayout>;
legendLayouts: Record<string, MaterialsOverlayLayout>;
focusMaterialId: string | null;
};
npcAvatars: {
activeNpcIds: string[];
layouts: Record<string, NpcsOverlayLayout>;
focusNpcId: string | null;
};
initiative: Omit<InitiativeState, 'revision'>;
tokenGridSnap: boolean;
};
export type GameSaveFile = GameSaveBody & {
id: string;
name: string;
savedAt: string;
};
+1
View File
@@ -3,6 +3,7 @@ export * from './appPlaylists';
export * from './appTokens';
export * from './domain';
export * from './effects';
export * from './gameSave';
export * from './ids';
export * from './materialLegend';
export * from './materials';
+43
View File
@@ -3,7 +3,9 @@ import test from 'node:test';
import {
normalizeSceneDarknessBrushShape,
normalizeSceneDarknessRevealStroke,
normalizeSceneDarknessStrokeMode,
normalizeSceneDarknessStrokes,
} from './sceneDarkness';
void test('normalizeSceneDarknessStrokeMode: reveal по умолчанию', () => {
@@ -18,3 +20,44 @@ void test('normalizeSceneDarknessBrushShape: circle по умолчанию', ()
assert.equal(normalizeSceneDarknessBrushShape('square'), 'square');
assert.equal(normalizeSceneDarknessBrushShape('triangle'), 'circle');
});
void test('normalizeSceneDarknessStrokes: отбрасывает мусор, нормализует точки', () => {
assert.deepEqual(normalizeSceneDarknessStrokes(null), []);
assert.deepEqual(normalizeSceneDarknessStrokes('x'), []);
const out = normalizeSceneDarknessStrokes([
{ id: 'bad' },
{
id: 'ok',
seed: 1,
createdAtMs: 10,
radiusN: 2,
mode: 'cover',
shape: 'square',
points: [
{ x: -1, y: 2, tMs: 10 },
{ x: 0.5, y: 0.5 },
],
},
]);
assert.equal(out.length, 1);
assert.equal(out[0]?.id, 'ok');
assert.equal(out[0]?.mode, 'cover');
assert.equal(out[0]?.shape, 'square');
assert.equal(out[0]?.radiusN, 1);
assert.equal(out[0]?.points[0]?.x, 0);
assert.equal(out[0]?.points[0]?.y, 1);
assert.equal(out[0]?.points[1]?.tMs, 0);
});
void test('normalizeSceneDarknessRevealStroke: null без точек', () => {
assert.equal(
normalizeSceneDarknessRevealStroke({
id: 'x',
seed: 1,
createdAtMs: 1,
radiusN: 0.1,
points: [],
}),
null,
);
});
+40
View File
@@ -44,3 +44,43 @@ export function normalizeSceneDarknessStrokeMode(raw: unknown): SceneDarknessStr
export function normalizeSceneDarknessBrushShape(raw: unknown): SceneDarknessBrushShape {
return raw === 'square' ? 'square' : 'circle';
}
export function normalizeSceneDarknessRevealStroke(raw: unknown): SceneDarknessRevealStroke | null {
if (!raw || typeof raw !== 'object') return null;
const o = raw as Record<string, unknown>;
if (typeof o.id !== 'string' || !o.id) return null;
if (typeof o.seed !== 'number' || !Number.isFinite(o.seed)) return null;
if (typeof o.createdAtMs !== 'number' || !Number.isFinite(o.createdAtMs)) return null;
if (typeof o.radiusN !== 'number' || !Number.isFinite(o.radiusN)) return null;
if (!Array.isArray(o.points) || o.points.length === 0) return null;
const points: NPoint[] = [];
for (const p of o.points) {
if (!p || typeof p !== 'object') continue;
const pt = p as Record<string, unknown>;
if (typeof pt.x !== 'number' || typeof pt.y !== 'number') continue;
const tMs = typeof pt.tMs === 'number' && Number.isFinite(pt.tMs) ? pt.tMs : 0;
points.push({
x: Math.max(0, Math.min(1, pt.x)),
y: Math.max(0, Math.min(1, pt.y)),
tMs,
...(typeof pt.pressure === 'number' ? { pressure: pt.pressure } : {}),
});
}
if (points.length === 0) return null;
return {
id: o.id,
seed: o.seed,
createdAtMs: o.createdAtMs,
points,
radiusN: Math.max(0.001, Math.min(1, o.radiusN)),
mode: normalizeSceneDarknessStrokeMode(o.mode),
shape: normalizeSceneDarknessBrushShape(o.shape),
};
}
export function normalizeSceneDarknessStrokes(raw: unknown): SceneDarknessRevealStroke[] {
if (!Array.isArray(raw)) return [];
return raw
.map((s) => normalizeSceneDarknessRevealStroke(s))
.filter((s): s is SceneDarknessRevealStroke => Boolean(s));
}