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:
@@ -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 пул). */
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user