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:
@@ -1,7 +1,13 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import { applyFieldEffectEraserStroke } from '../../shared/fieldEffectEraser';
|
||||
import type { EffectsEvent, EffectsState, EffectToolState } from '../../shared/types';
|
||||
import {
|
||||
isPersistentFieldEffect,
|
||||
type EffectInstance,
|
||||
type EffectsEvent,
|
||||
type EffectsState,
|
||||
type EffectToolState,
|
||||
} from '../../shared/types';
|
||||
|
||||
function nowMs(): number {
|
||||
return Date.now();
|
||||
@@ -23,6 +29,9 @@ export class EffectsStore {
|
||||
tool: defaultTool(),
|
||||
instances: [],
|
||||
};
|
||||
/** Постоянные эффекты по карточке (graphNodeId или sceneId) на время показа. */
|
||||
private cache = new Map<string, EffectInstance[]>();
|
||||
private currentKey: string | null = null;
|
||||
|
||||
getState(): EffectsState {
|
||||
// Всегда обновляем serverNowMs при чтении — это наш "таймкод" для рендереров.
|
||||
@@ -36,13 +45,70 @@ export class EffectsStore {
|
||||
serverNowMs: nowMs(),
|
||||
instances: [],
|
||||
};
|
||||
this.commitCurrent();
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Новый показ: и текущая карточка, и кэш остальных. */
|
||||
resetSession(): EffectsState {
|
||||
this.cache.clear();
|
||||
this.currentKey = null;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
serverNowMs: nowMs(),
|
||||
tool: this.state.tool,
|
||||
instances: [],
|
||||
};
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Смена карточки: туман/огонь/дождь/вода/лёд/пятно остаются на ушедшей
|
||||
* и поднимаются на новой. Разовые эффекты действий сбрасываются.
|
||||
*/
|
||||
switchScene(cacheKey: string | null): EffectsState {
|
||||
this.commitCurrent();
|
||||
this.currentKey = cacheKey;
|
||||
const instances = cacheKey ? [...(this.cache.get(cacheKey) ?? [])] : [];
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
serverNowMs: nowMs(),
|
||||
instances,
|
||||
};
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
exportByCard(): Record<string, EffectInstance[]> {
|
||||
this.commitCurrent();
|
||||
const out: Record<string, EffectInstance[]> = {};
|
||||
for (const [key, instances] of this.cache) {
|
||||
const kept = instances.filter((i) => isPersistentFieldEffect(i.type));
|
||||
if (kept.length > 0) out[key] = kept.map((i) => ({ ...i }));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
importByCard(byCard: Record<string, EffectInstance[]>): void {
|
||||
this.cache.clear();
|
||||
this.currentKey = null;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
serverNowMs: nowMs(),
|
||||
instances: [],
|
||||
};
|
||||
for (const [key, instances] of Object.entries(byCard)) {
|
||||
const kept = instances.filter((i) => isPersistentFieldEffect(i.type));
|
||||
if (kept.length > 0) this.cache.set(key, kept.map((i) => ({ ...i })));
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(event: EffectsEvent): EffectsState {
|
||||
const s = this.state;
|
||||
const next: EffectsState = applyEvent(s, event, (prefix) => this.makeId(prefix));
|
||||
this.state = next;
|
||||
this.commitCurrent();
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -78,9 +144,18 @@ export class EffectsStore {
|
||||
serverNowMs: now,
|
||||
instances: kept,
|
||||
};
|
||||
this.commitCurrent();
|
||||
return true;
|
||||
}
|
||||
|
||||
private commitCurrent(): void {
|
||||
if (this.currentKey === null) return;
|
||||
this.cache.set(
|
||||
this.currentKey,
|
||||
this.state.instances.filter((i) => isPersistentFieldEffect(i.type)).map((i) => ({ ...i })),
|
||||
);
|
||||
}
|
||||
|
||||
makeId(prefix: string): string {
|
||||
return `${prefix}_${crypto.randomBytes(6).toString('hex')}_${String(nowMs())}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { EffectsStore } from './effectsStore';
|
||||
import { GameSavesStore } from '../gameSave/gameSavesStore';
|
||||
import { canonicalGameSave, emptyGameSaveBody, freezeTokenPathPlayback } from '../../shared/gameSave/buildSnapshot';
|
||||
import type { EffectInstance } from '../../shared/types';
|
||||
import type { TokenPath } from '../../shared/types/tokenPath';
|
||||
import type { TokenPathPlaybackEntry } from '../../shared/types/tokenPathSession';
|
||||
|
||||
function fog(): EffectInstance {
|
||||
return {
|
||||
id: 'fog1',
|
||||
type: 'fog',
|
||||
seed: 1,
|
||||
createdAtMs: 1,
|
||||
points: [{ x: 0.2, y: 0.2, tMs: 1 }],
|
||||
radiusN: 0.1,
|
||||
opacity: 0.4,
|
||||
lifetimeMs: null,
|
||||
};
|
||||
}
|
||||
|
||||
function bolt(): EffectInstance {
|
||||
return {
|
||||
id: 'lt1',
|
||||
type: 'lightning',
|
||||
seed: 2,
|
||||
createdAtMs: 1,
|
||||
start: { x: 0, y: 0 },
|
||||
end: { x: 1, y: 1 },
|
||||
widthN: 0.02,
|
||||
intensity: 1,
|
||||
lifetimeMs: 800,
|
||||
};
|
||||
}
|
||||
|
||||
void test('effects stay on the card and action effects do not', () => {
|
||||
const store = new EffectsStore();
|
||||
store.switchScene('card-a');
|
||||
store.dispatch({ kind: 'instance.add', instance: fog() });
|
||||
store.dispatch({ kind: 'instance.add', instance: bolt() });
|
||||
store.switchScene('card-b');
|
||||
assert.equal(store.getState().instances.length, 0);
|
||||
store.switchScene('card-a');
|
||||
assert.deepEqual(
|
||||
store.getState().instances.map((i) => i.type),
|
||||
['fog'],
|
||||
);
|
||||
store.dispatch({ kind: 'instances.clear' });
|
||||
store.switchScene('card-b');
|
||||
store.switchScene('card-a');
|
||||
assert.equal(store.getState().instances.length, 0);
|
||||
});
|
||||
|
||||
void test('freezeTokenPathPlayback parks a moving token', () => {
|
||||
const pathLine: TokenPath = {
|
||||
points: [
|
||||
{ nx: 0, ny: 0.5 },
|
||||
{ nx: 1, ny: 0.5 },
|
||||
],
|
||||
closed: false,
|
||||
loopMode: 'once',
|
||||
durationSec: 2,
|
||||
startMode: 'onEnter',
|
||||
delaySec: 0,
|
||||
facingMode: 'tangentSmooth',
|
||||
fixedRotationDeg: 0,
|
||||
};
|
||||
const entry: TokenPathPlaybackEntry = {
|
||||
kind: 'token',
|
||||
placementId: 't1',
|
||||
phase: 'moving',
|
||||
segmentStartedAtMs: 0,
|
||||
baseDist: 0,
|
||||
direction: 1,
|
||||
rejoinDist: null,
|
||||
durationSec: 2,
|
||||
pathLength: 1,
|
||||
};
|
||||
const frozen = freezeTokenPathPlayback({ 'token:t1': entry }, () => pathLine, 1000);
|
||||
const next = frozen['token:t1'];
|
||||
assert.ok(next);
|
||||
assert.equal(next.phase, 'stopped');
|
||||
assert.ok(next.baseDist > 0.4 && next.baseDist < 0.6);
|
||||
});
|
||||
|
||||
void test('game save file keeps id on overwrite and lists one campaign', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-saves-'));
|
||||
const store = new GameSavesStore(root);
|
||||
const body = emptyGameSaveBody('p_one');
|
||||
const first = await store.write('p_one', body, { id: null, name: 'Вечер' });
|
||||
const again = await store.write('p_one', body, { id: first.id, name: 'Вечер 2' });
|
||||
assert.equal(again.id, first.id);
|
||||
assert.equal(again.name, 'Вечер 2');
|
||||
const list = await store.list('p_one');
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(list[0]?.name, 'Вечер 2');
|
||||
const other = emptyGameSaveBody('p_two');
|
||||
await store.write('p_two', other, { id: null, name: 'Другая' });
|
||||
assert.equal((await store.list('p_one')).length, 1);
|
||||
const loaded = await store.read('p_one', first.id);
|
||||
const { id: _id, name: _name, savedAt: _savedAt, ...rest } = loaded;
|
||||
assert.equal(canonicalGameSave(rest), canonicalGameSave(body));
|
||||
});
|
||||
@@ -67,6 +67,66 @@ void test('SceneDarknessStore: draft синхронизируется и сбр
|
||||
assert.equal(store.getState().strokes.length, 1);
|
||||
});
|
||||
|
||||
void test('SceneDarknessStore: cache miss берёт seedStrokes из сцены', () => {
|
||||
const store = new SceneDarknessStore();
|
||||
const seed = [
|
||||
{
|
||||
id: 'base1',
|
||||
seed: 7,
|
||||
createdAtMs: 50,
|
||||
points: [{ x: 0.3, y: 0.4, tMs: 50 }],
|
||||
radiusN: 0.06,
|
||||
mode: 'reveal' as const,
|
||||
},
|
||||
];
|
||||
store.switchScene('card_a', true, seed);
|
||||
assert.equal(store.getState().strokes.length, 1);
|
||||
assert.equal(store.getState().strokes[0]?.id, 'base1');
|
||||
store.dispatch({
|
||||
kind: 'stroke.add',
|
||||
stroke: {
|
||||
id: 'session1',
|
||||
seed: 2,
|
||||
createdAtMs: 100,
|
||||
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
|
||||
radiusN: 0.08,
|
||||
mode: 'cover',
|
||||
},
|
||||
});
|
||||
store.switchScene('card_b', true, []);
|
||||
store.switchScene('card_a', true, seed);
|
||||
assert.equal(store.getState().strokes.length, 2);
|
||||
assert.equal(store.getState().strokes[1]?.id, 'session1');
|
||||
});
|
||||
|
||||
void test('SceneDarknessStore: resetSession снова берёт seed', () => {
|
||||
const store = new SceneDarknessStore();
|
||||
const seed = [
|
||||
{
|
||||
id: 'base1',
|
||||
seed: 1,
|
||||
createdAtMs: 10,
|
||||
points: [{ x: 0.1, y: 0.1, tMs: 10 }],
|
||||
radiusN: 0.05,
|
||||
},
|
||||
];
|
||||
store.switchScene('card_a', true, seed);
|
||||
store.dispatch({
|
||||
kind: 'stroke.add',
|
||||
stroke: {
|
||||
id: 's1',
|
||||
seed: 2,
|
||||
createdAtMs: 20,
|
||||
points: [{ x: 0.2, y: 0.2, tMs: 20 }],
|
||||
radiusN: 0.05,
|
||||
},
|
||||
});
|
||||
store.resetSession();
|
||||
store.switchScene('card_a', true, seed);
|
||||
assert.equal(store.getState().strokes.length, 1);
|
||||
assert.equal(store.getState().strokes[0]?.id, 'base1');
|
||||
});
|
||||
|
||||
void test('SceneDarknessStore: cover-штрих сохраняется в кэше сцены', () => {
|
||||
const store = new SceneDarknessStore();
|
||||
store.switchScene('scene_a', true);
|
||||
|
||||
@@ -29,14 +29,26 @@ export class SceneDarknessStore {
|
||||
|
||||
/**
|
||||
* Переключение сцены: сохраняем текущие штрихи в кэш и загружаем состояние новой сцены.
|
||||
* Если ключа ещё не было в сессии — берём seed (база из файла партии).
|
||||
*/
|
||||
switchScene(cacheKey: string | null, enabled: boolean): SceneDarknessState {
|
||||
switchScene(
|
||||
cacheKey: string | null,
|
||||
enabled: boolean,
|
||||
seedStrokes: readonly SceneDarknessRevealStroke[] = [],
|
||||
): SceneDarknessState {
|
||||
if (this.currentKey !== null) {
|
||||
this.cache.set(this.currentKey, [...this.state.strokes]);
|
||||
this.cache.set(this.currentKey, this.state.strokes.map(cloneStroke));
|
||||
}
|
||||
this.currentKey = cacheKey;
|
||||
const strokes =
|
||||
cacheKey && enabled ? [...(this.cache.get(cacheKey) ?? [])] : [];
|
||||
let strokes: SceneDarknessRevealStroke[] = [];
|
||||
if (cacheKey && enabled) {
|
||||
if (this.cache.has(cacheKey)) {
|
||||
strokes = (this.cache.get(cacheKey) ?? []).map(cloneStroke);
|
||||
} else {
|
||||
strokes = seedStrokes.map(cloneStroke);
|
||||
this.cache.set(cacheKey, strokes.map(cloneStroke));
|
||||
}
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
enabled,
|
||||
@@ -77,4 +89,32 @@ export class SceneDarknessStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Кэш по карточкам, включая текущую. Пустые карточки не входят. */
|
||||
exportByCard(): Record<string, SceneDarknessRevealStroke[]> {
|
||||
if (this.currentKey !== null) {
|
||||
this.cache.set(this.currentKey, this.state.strokes.map(cloneStroke));
|
||||
}
|
||||
const out: Record<string, SceneDarknessRevealStroke[]> = {};
|
||||
for (const [key, strokes] of this.cache) {
|
||||
if (strokes.length === 0) continue;
|
||||
out[key] = strokes.map(cloneStroke);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Кладёт кэш, не переключая карточку. Дальше вызывают switchScene. */
|
||||
importByCard(byCard: Record<string, SceneDarknessRevealStroke[]>): void {
|
||||
this.cache.clear();
|
||||
this.currentKey = null;
|
||||
this.state = emptyState();
|
||||
for (const [key, strokes] of Object.entries(byCard)) {
|
||||
if (!Array.isArray(strokes) || strokes.length === 0) continue;
|
||||
this.cache.set(key, strokes.map(cloneStroke));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cloneStroke(stroke: SceneDarknessRevealStroke): SceneDarknessRevealStroke {
|
||||
return { ...stroke, points: stroke.points.map((p) => ({ ...p })) };
|
||||
}
|
||||
|
||||
@@ -105,4 +105,30 @@ export class SceneMarkerStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exportByCard(): Record<string, SceneMarkerStroke[]> {
|
||||
if (this.currentKey !== null) {
|
||||
this.cache.set(this.currentKey, this.state.strokes.map(cloneStroke));
|
||||
}
|
||||
const out: Record<string, SceneMarkerStroke[]> = {};
|
||||
for (const [key, strokes] of this.cache) {
|
||||
if (strokes.length === 0) continue;
|
||||
out[key] = strokes.map(cloneStroke);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
importByCard(byCard: Record<string, SceneMarkerStroke[]>): void {
|
||||
this.cache.clear();
|
||||
this.currentKey = null;
|
||||
this.state = emptyState();
|
||||
for (const [key, strokes] of Object.entries(byCard)) {
|
||||
if (!Array.isArray(strokes) || strokes.length === 0) continue;
|
||||
this.cache.set(key, strokes.map(cloneStroke));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cloneStroke(stroke: SceneMarkerStroke): SceneMarkerStroke {
|
||||
return { ...stroke, points: stroke.points.map((p) => ({ ...p })) };
|
||||
}
|
||||
|
||||
@@ -392,6 +392,7 @@ export async function buildProjectFromFoundryDocuments(
|
||||
previewVideoAutostart: previewAssetType === 'video',
|
||||
previewRotationDeg: 0,
|
||||
darkenScene: false,
|
||||
darknessStrokes: [],
|
||||
traps: [],
|
||||
tokens: [],
|
||||
npcTokens: [],
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { defaultGameSaveName } from '../../shared/gameSave/buildSnapshot';
|
||||
import { GAME_SAVE_SCHEMA_VERSION, type GameSaveBody, type GameSaveFile, type GameSaveListItem } from '../../shared/types/gameSave';
|
||||
|
||||
function safeSegment(raw: string): string {
|
||||
const cleaned = raw.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
if (!cleaned || cleaned === '.' || cleaned === '..') throw new Error('GAME_SAVE_BAD_ID');
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export class GameSavesStore {
|
||||
private readonly rootDir: string;
|
||||
|
||||
constructor(userData: string) {
|
||||
this.rootDir = path.join(userData, 'game-saves');
|
||||
}
|
||||
|
||||
async list(projectId: string): Promise<GameSaveListItem[]> {
|
||||
const dir = this.projectDir(projectId);
|
||||
let names: string[] = [];
|
||||
try {
|
||||
names = await fs.readdir(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const items: GameSaveListItem[] = [];
|
||||
for (const name of names) {
|
||||
if (!name.endsWith('.json')) continue;
|
||||
try {
|
||||
const file = await this.readFile(path.join(dir, name));
|
||||
if (file.projectId !== projectId) continue;
|
||||
items.push({ id: file.id, name: file.name, savedAt: file.savedAt });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
items.sort((a, b) => (a.savedAt < b.savedAt ? 1 : a.savedAt > b.savedAt ? -1 : 0));
|
||||
return items;
|
||||
}
|
||||
|
||||
async read(projectId: string, id: string): Promise<GameSaveFile> {
|
||||
const filePath = this.filePath(projectId, id);
|
||||
try {
|
||||
const file = await this.readFile(filePath);
|
||||
if (file.projectId !== projectId || file.id !== id) throw new Error('GAME_SAVE_NOT_FOUND');
|
||||
return file;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.startsWith('GAME_SAVE_')) throw err;
|
||||
throw new Error('GAME_SAVE_NOT_FOUND');
|
||||
}
|
||||
}
|
||||
|
||||
async write(projectId: string, body: GameSaveBody, opts: { id: string | null; name: string }): Promise<GameSaveListItem> {
|
||||
if (body.projectId !== projectId) throw new Error('GAME_SAVE_PROJECT_MISMATCH');
|
||||
const id = opts.id ? safeSegment(opts.id) : `gs_${crypto.randomBytes(8).toString('hex')}`;
|
||||
const name = opts.name.trim() || defaultGameSaveName();
|
||||
const savedAt = new Date().toISOString();
|
||||
const file: GameSaveFile = { ...body, id, name, savedAt, schemaVersion: GAME_SAVE_SCHEMA_VERSION };
|
||||
const filePath = this.filePath(projectId, id);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, JSON.stringify(file), 'utf8');
|
||||
return { id, name, savedAt };
|
||||
}
|
||||
|
||||
private projectDir(projectId: string): string {
|
||||
return path.join(this.rootDir, safeSegment(projectId));
|
||||
}
|
||||
|
||||
private filePath(projectId: string, id: string): string {
|
||||
return path.join(this.projectDir(projectId), `${safeSegment(id)}.json`);
|
||||
}
|
||||
|
||||
private async readFile(filePath: string): Promise<GameSaveFile> {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as GameSaveFile;
|
||||
if (!parsed || parsed.schemaVersion !== GAME_SAVE_SCHEMA_VERSION || typeof parsed.id !== 'string') {
|
||||
throw new Error('GAME_SAVE_NOT_FOUND');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
+226
-14
@@ -20,7 +20,9 @@ import {
|
||||
stripProjectZipExtension,
|
||||
} from '../shared/project/projectZipExtension';
|
||||
import type { Project } from '../shared/types';
|
||||
import { asNpcId } from '../shared/types/ids';
|
||||
import { canonicalGameSave, freezeTokenPathPlayback, pathForPlacement } from '../shared/gameSave/buildSnapshot';
|
||||
import { GAME_SAVE_SCHEMA_VERSION, type GameSaveBody, type GameSaveFile } from '../shared/types/gameSave';
|
||||
import { asNpcId, asSceneId, type MaterialId } from '../shared/types/ids';
|
||||
import { listVisibleSceneNpcTokens } from '../shared/types/appPlayers';
|
||||
import { remapProjectSceneTokenIds } from '../shared/graph/storylineExportImport';
|
||||
import {
|
||||
@@ -28,7 +30,9 @@ import {
|
||||
previewRotationStepsCw,
|
||||
} from '../shared/types/scenePreviewRotation';
|
||||
|
||||
import { getSessionTimerRemainingMs } from '../shared/types/sessionTimer';
|
||||
import { EffectsStore, effectsDefaultTool } from './effects/effectsStore';
|
||||
import { GameSavesStore } from './gameSave/gameSavesStore';
|
||||
import { SceneDarknessStore } from './effects/sceneDarknessStore';
|
||||
import { SceneMarkerStore } from './effects/sceneMarkerStore';
|
||||
import { SceneTrapsStore } from './sceneTraps/sceneTrapsStore';
|
||||
@@ -204,6 +208,9 @@ const tokenPathSessionStore = new TokenPathSessionStore();
|
||||
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
|
||||
const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore();
|
||||
const tokenGridSnapSessionStore = new TokenGridSnapSessionStore();
|
||||
let gameSavesStore: GameSavesStore | null = null;
|
||||
/** Канонический слепок последнего сохранения этого показа. null — ещё не сохраняли. */
|
||||
let lastSaveCanonical: string | null = null;
|
||||
let tokensStore: TokensStore | null = null;
|
||||
let playersStore: PlayersStore | null = null;
|
||||
let gameSystemsStore: GameSystemsStore | null = null;
|
||||
@@ -514,7 +521,168 @@ function syncSceneDarknessForProject(project: Project): void {
|
||||
const enabled =
|
||||
Boolean(scene?.darkenScene) &&
|
||||
(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video');
|
||||
sceneDarknessStore.switchScene(cacheKey, enabled);
|
||||
sceneDarknessStore.switchScene(cacheKey, enabled, scene?.darknessStrokes ?? []);
|
||||
}
|
||||
|
||||
function syncSceneEffectsForProject(project: Project): void {
|
||||
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
|
||||
effectsStore.switchScene(cacheKey);
|
||||
}
|
||||
|
||||
function captureGameSaveBody(project: Project): GameSaveBody {
|
||||
const now = Date.now();
|
||||
const timer = timerSessionStore.getState();
|
||||
const players = scenePlayerTokensSessionStore.getState();
|
||||
const initiative = initiativeSessionStore.getState();
|
||||
const materials = materialsOverlayStore.getState();
|
||||
const npcs = npcsOverlayStore.getState();
|
||||
const camera = sceneViewStore.getState();
|
||||
const npcTokens = sceneNpcTokensSessionStore.getState();
|
||||
const paths = tokenPathSessionStore.getState();
|
||||
return {
|
||||
schemaVersion: GAME_SAVE_SCHEMA_VERSION,
|
||||
projectId: String(project.id),
|
||||
graphNodeId: project.currentGraphNodeId ? String(project.currentGraphNodeId) : null,
|
||||
sceneId: project.currentSceneId ? String(project.currentSceneId) : null,
|
||||
darknessByCard: sceneDarknessStore.exportByCard(),
|
||||
markersByCard: sceneMarkerStore.exportByCard(),
|
||||
trapsByCard: sceneTrapsStore.exportByCard(),
|
||||
effectsByCard: effectsStore.exportByCard(),
|
||||
sceneTokensByPlacementId: { ...sceneTokensSessionStore.getState().byPlacementId },
|
||||
npcTokens: {
|
||||
byPlacementId: { ...npcTokens.byPlacementId },
|
||||
scale: npcTokens.scale,
|
||||
spawned: { ...npcTokens.spawned },
|
||||
},
|
||||
tokenPaths: {
|
||||
presentationVisible: { ...paths.presentationVisible },
|
||||
playback: freezeTokenPathPlayback(
|
||||
paths.playback,
|
||||
(entry) => pathForPlacement(project, entry.kind, entry.placementId),
|
||||
now,
|
||||
),
|
||||
},
|
||||
timer: {
|
||||
durationMs: timer.durationMs,
|
||||
remainingMs: getSessionTimerRemainingMs(timer, now),
|
||||
visibleToPlayers: timer.visibleToPlayers,
|
||||
layout: { ...timer.layout },
|
||||
},
|
||||
playerTokens: {
|
||||
selectedPlayerIds: [...players.selectedPlayerIds],
|
||||
visible: players.visible,
|
||||
byPlayerId: players.visible ? { ...players.byPlayerId } : {},
|
||||
},
|
||||
camera: { scale: camera.scale, ox: camera.ox, oy: camera.oy },
|
||||
materials: {
|
||||
activeMaterialIds: materials.activeMaterialIds.map(String),
|
||||
layouts: { ...materials.layouts },
|
||||
legendLayouts: { ...materials.legendLayouts },
|
||||
focusMaterialId: materials.focusMaterialId ? String(materials.focusMaterialId) : null,
|
||||
},
|
||||
npcAvatars: {
|
||||
activeNpcIds: npcs.activeNpcIds.map(String),
|
||||
layouts: { ...npcs.layouts },
|
||||
focusNpcId: npcs.focusNpcId ? String(npcs.focusNpcId) : null,
|
||||
},
|
||||
initiative: {
|
||||
enabled: initiative.enabled,
|
||||
entries: { ...initiative.entries },
|
||||
order: [...initiative.order],
|
||||
reserve: [...initiative.reserve],
|
||||
activeId: initiative.activeId,
|
||||
conditions: { ...initiative.conditions },
|
||||
playersVisible: initiative.playersVisible,
|
||||
},
|
||||
tokenGridSnap: tokenGridSnapSessionStore.getState().enabled,
|
||||
};
|
||||
}
|
||||
|
||||
function sessionIsDirty(project: Project | null): boolean {
|
||||
if (!project) return false;
|
||||
if (lastSaveCanonical === null) return true;
|
||||
return canonicalGameSave(captureGameSaveBody(project)) !== lastSaveCanonical;
|
||||
}
|
||||
|
||||
async function applyGameSave(project: Project, file: GameSaveFile): Promise<Project> {
|
||||
if (file.projectId !== String(project.id)) throw new Error('GAME_SAVE_PROJECT_MISMATCH');
|
||||
let next = project;
|
||||
if (file.graphNodeId) {
|
||||
const gn = project.sceneGraphNodes.find((n) => String(n.id) === file.graphNodeId);
|
||||
if (!gn) throw new Error('GAME_SAVE_CARD_MISSING');
|
||||
next = projectStore.setSessionPointer(gn.id, gn.sceneId);
|
||||
} else if (file.sceneId && project.scenes[asSceneId(file.sceneId)]) {
|
||||
next = projectStore.setSessionPointer(null, asSceneId(file.sceneId));
|
||||
} else if (file.sceneId) {
|
||||
throw new Error('GAME_SAVE_CARD_MISSING');
|
||||
}
|
||||
|
||||
sceneDarknessStore.importByCard(file.darknessByCard ?? {});
|
||||
sceneMarkerStore.importByCard(file.markersByCard ?? {});
|
||||
sceneTrapsStore.importByCard(file.trapsByCard ?? {});
|
||||
effectsStore.importByCard(file.effectsByCard ?? {});
|
||||
syncSceneDarknessForProject(next);
|
||||
syncSceneMarkerForProject(next);
|
||||
syncSceneTrapsForProject(next);
|
||||
syncSceneEffectsForProject(next);
|
||||
|
||||
sceneTokensSessionStore.load(file.sceneTokensByPlacementId ?? {});
|
||||
const npcIds = new Set((next.npcs ?? []).map((n) => String(n.id)));
|
||||
const spawned: GameSaveBody['npcTokens']['spawned'] = {};
|
||||
for (const [id, spawn] of Object.entries(file.npcTokens?.spawned ?? {})) {
|
||||
if (npcIds.has(String(spawn.npcId))) spawned[id] = spawn;
|
||||
}
|
||||
sceneNpcTokensSessionStore.load({
|
||||
byPlacementId: file.npcTokens?.byPlacementId ?? {},
|
||||
scale: file.npcTokens?.scale ?? 1,
|
||||
spawned,
|
||||
});
|
||||
|
||||
let selected = [...(file.playerTokens?.selectedPlayerIds ?? [])];
|
||||
if (playersStore) {
|
||||
const known = new Set(playersStore.listPlayers().map((p) => String(p.id)));
|
||||
selected = selected.filter((id) => known.has(id));
|
||||
}
|
||||
const byPlayerId: GameSaveBody['playerTokens']['byPlayerId'] = {};
|
||||
if (file.playerTokens?.visible) {
|
||||
for (const id of selected) {
|
||||
const placement = file.playerTokens.byPlayerId?.[id];
|
||||
if (placement) byPlayerId[id] = placement;
|
||||
}
|
||||
}
|
||||
scenePlayerTokensSessionStore.load({
|
||||
selectedPlayerIds: selected,
|
||||
visible: Boolean(file.playerTokens?.visible) && selected.length > 0,
|
||||
byPlayerId,
|
||||
});
|
||||
|
||||
tokenPathSessionStore.load(file.tokenPaths?.playback ?? {}, file.tokenPaths?.presentationVisible ?? {});
|
||||
timerSessionStore.loadPaused(file.timer);
|
||||
sceneViewStore.dispatch({ kind: 'set', camera: file.camera });
|
||||
|
||||
const materialIds = new Set((next.materials ?? []).map((m) => String(m.id)));
|
||||
const activeMaterials = (file.materials?.activeMaterialIds ?? []).filter((id) => materialIds.has(id));
|
||||
materialsOverlayStore.load({
|
||||
activeMaterialIds: activeMaterials as MaterialId[],
|
||||
layouts: file.materials?.layouts ?? {},
|
||||
legendLayouts: file.materials?.legendLayouts ?? {},
|
||||
focusMaterialId:
|
||||
file.materials?.focusMaterialId && materialIds.has(file.materials.focusMaterialId)
|
||||
? (file.materials.focusMaterialId as MaterialId)
|
||||
: null,
|
||||
});
|
||||
const activeNpcs = (file.npcAvatars?.activeNpcIds ?? []).filter((id) => npcIds.has(id));
|
||||
npcsOverlayStore.load({
|
||||
activeNpcIds: activeNpcs.map((id) => asNpcId(id)),
|
||||
layouts: file.npcAvatars?.layouts ?? {},
|
||||
focusNpcId:
|
||||
file.npcAvatars?.focusNpcId && npcIds.has(file.npcAvatars.focusNpcId)
|
||||
? asNpcId(file.npcAvatars.focusNpcId)
|
||||
: null,
|
||||
});
|
||||
tokenGridSnapSessionStore.setEnabled(Boolean(file.tokenGridSnap));
|
||||
if (file.initiative) initiativeSessionStore.load(file.initiative);
|
||||
return next;
|
||||
}
|
||||
|
||||
function syncSceneMarkerForProject(project: Project): void {
|
||||
@@ -667,6 +835,7 @@ async function main() {
|
||||
await playersStore.ensureLoaded();
|
||||
}
|
||||
gameSystemsStore = new GameSystemsStore(app.getPath('userData'));
|
||||
gameSavesStore = new GameSavesStore(app.getPath('userData'));
|
||||
await gameSystemsStore.ensureLoaded();
|
||||
playlistsStore = new PlaylistsStore(app.getPath('userData'));
|
||||
await playlistsStore.ensureLoaded();
|
||||
@@ -701,7 +870,7 @@ async function main() {
|
||||
registerHandler(ipcChannels.license.setToken, async ({ token }) => licenseService.setToken(token));
|
||||
registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken());
|
||||
registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version));
|
||||
registerHandler(ipcChannels.windows.openMultiWindow, (req) => {
|
||||
registerHandler(ipcChannels.windows.openMultiWindow, async (req) => {
|
||||
sceneDarknessStore.resetSession();
|
||||
sceneMarkerStore.resetSession();
|
||||
sceneTrapsStore.resetSession();
|
||||
@@ -714,23 +883,38 @@ async function main() {
|
||||
timerSessionStore.clear();
|
||||
initiativeSessionStore.clear();
|
||||
diceRollSessionStore.clear();
|
||||
if (USERS_BRANCH_FEATURES_ENABLED) {
|
||||
materialsOverlayStore.clear();
|
||||
npcsOverlayStore.clear();
|
||||
sceneViewStore.reset();
|
||||
effectsStore.resetSession();
|
||||
effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() });
|
||||
lastSaveCanonical = null;
|
||||
const saveId = typeof req?.saveId === 'string' ? req.saveId.trim() : '';
|
||||
let project = projectStore.getOpenProject();
|
||||
if (saveId) {
|
||||
if (!project || !gameSavesStore) throw new Error('GAME_SAVE_NOT_FOUND');
|
||||
const file = await gameSavesStore.read(String(project.id), saveId);
|
||||
project = await applyGameSave(project, file);
|
||||
} else if (USERS_BRANCH_FEATURES_ENABLED) {
|
||||
const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : [];
|
||||
if (playerIds.length > 0) {
|
||||
scenePlayerTokensSessionStore.dispatch({ kind: 'setSelection', playerIds });
|
||||
}
|
||||
}
|
||||
effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() });
|
||||
openMultiWindow();
|
||||
const project = projectStore.getOpenProject();
|
||||
if (project) {
|
||||
project = projectStore.getOpenProject();
|
||||
if (project && !saveId) {
|
||||
syncSceneDarknessForProject(project);
|
||||
syncSceneMarkerForProject(project);
|
||||
syncSceneTrapsForProject(project);
|
||||
syncSceneEffectsForProject(project);
|
||||
seedTokenPathPlaybackForProject(project);
|
||||
} else {
|
||||
} else if (!project) {
|
||||
emitTokenPathSessionState();
|
||||
}
|
||||
if (project && saveId) {
|
||||
lastSaveCanonical = canonicalGameSave(captureGameSaveBody(project));
|
||||
}
|
||||
emitSceneDarknessState();
|
||||
emitSceneMarkerState();
|
||||
emitSceneTrapsState();
|
||||
@@ -739,11 +923,31 @@ async function main() {
|
||||
emitScenePlayerTokensSessionState();
|
||||
emitTokenGridSnapState();
|
||||
emitEffectsState();
|
||||
emitMaterialsOverlayState();
|
||||
emitNpcsOverlayState();
|
||||
emitSceneViewState();
|
||||
emitSessionTimerState();
|
||||
syncInitiativePresenceFromSession();
|
||||
if (!saveId) syncInitiativePresenceFromSession();
|
||||
emitInitiativeState();
|
||||
emitDiceRollState();
|
||||
return { ok: true };
|
||||
emitTokenPathSessionState();
|
||||
return { ok: true as const, project: projectStore.getOpenProject() };
|
||||
});
|
||||
registerHandler(ipcChannels.gameSave.list, async () => {
|
||||
const project = projectStore.getOpenProject();
|
||||
if (!project || !gameSavesStore) return { saves: [] };
|
||||
return { saves: await gameSavesStore.list(String(project.id)) };
|
||||
});
|
||||
registerHandler(ipcChannels.gameSave.write, async ({ id, name }) => {
|
||||
const project = projectStore.getOpenProject();
|
||||
if (!project || !gameSavesStore) throw new Error('GAME_SAVE_NOT_FOUND');
|
||||
const body = captureGameSaveBody(project);
|
||||
const save = await gameSavesStore.write(String(project.id), body, { id, name });
|
||||
lastSaveCanonical = canonicalGameSave(body);
|
||||
return { save };
|
||||
});
|
||||
registerHandler(ipcChannels.gameSave.isDirty, () => {
|
||||
return { dirty: sessionIsDirty(projectStore.getOpenProject()) };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
|
||||
closeMultiWindow();
|
||||
@@ -961,6 +1165,8 @@ async function main() {
|
||||
sceneNpcTokensSessionStore.reset();
|
||||
clearNpcMapSpawnDrag();
|
||||
scenePlayerTokensSessionStore.reset();
|
||||
effectsStore.resetSession();
|
||||
lastSaveCanonical = null;
|
||||
syncSceneMarkerForProject(project);
|
||||
emitSceneViewState();
|
||||
emitSceneTokensSessionState();
|
||||
@@ -974,7 +1180,8 @@ async function main() {
|
||||
registerHandler(ipcChannels.project.close, async () => {
|
||||
closeNpcsEditorWindow();
|
||||
await projectStore.closeOpenProject();
|
||||
effectsStore.clear();
|
||||
effectsStore.resetSession();
|
||||
lastSaveCanonical = null;
|
||||
materialsOverlayStore.clear();
|
||||
npcsOverlayStore.clear();
|
||||
timerSessionStore.clear();
|
||||
@@ -1015,7 +1222,8 @@ async function main() {
|
||||
});
|
||||
registerHandler(ipcChannels.project.setCurrentScene, async ({ sceneId }) => {
|
||||
await projectStore.updateProject((p) => ({ ...p, currentSceneId: sceneId, currentGraphNodeId: null }));
|
||||
effectsStore.clear();
|
||||
const sceneProject = projectStore.getOpenProject();
|
||||
if (sceneProject) syncSceneEffectsForProject(sceneProject);
|
||||
materialsOverlayStore.clear();
|
||||
npcsOverlayStore.clear();
|
||||
sceneViewStore.reset();
|
||||
@@ -1046,7 +1254,8 @@ async function main() {
|
||||
if (!open) throw new Error('No open project');
|
||||
const gn = graphNodeId ? open.sceneGraphNodes.find((n) => n.id === graphNodeId) : null;
|
||||
projectStore.setSessionPointer(graphNodeId, gn ? gn.sceneId : null);
|
||||
effectsStore.clear();
|
||||
const switched = projectStore.getOpenProject();
|
||||
if (switched) syncSceneEffectsForProject(switched);
|
||||
materialsOverlayStore.clear();
|
||||
npcsOverlayStore.clear();
|
||||
sceneViewStore.reset();
|
||||
@@ -1080,7 +1289,10 @@ async function main() {
|
||||
const before = projectStore.getOpenProject()?.scenes[sceneId];
|
||||
const next = await projectStore.updateScene(sceneId, patch);
|
||||
const project = projectStore.getOpenProject();
|
||||
if (project?.currentSceneId === sceneId && patch.darkenScene !== undefined) {
|
||||
if (
|
||||
project?.currentSceneId === sceneId &&
|
||||
(patch.darkenScene !== undefined || patch.darknessStrokes !== undefined)
|
||||
) {
|
||||
syncSceneDarknessForProject(project);
|
||||
emitSceneDarknessState();
|
||||
}
|
||||
|
||||
@@ -315,4 +315,16 @@ export class InitiativeSessionStore {
|
||||
this.state = next;
|
||||
return this.state;
|
||||
}
|
||||
|
||||
load(next: Omit<InitiativeState, 'revision'>): InitiativeState {
|
||||
this.state = ensureActiveValid({
|
||||
...next,
|
||||
revision: this.state.revision + 1,
|
||||
entries: { ...next.entries },
|
||||
order: [...next.order],
|
||||
reserve: [...next.reserve],
|
||||
conditions: { ...next.conditions },
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,4 +225,16 @@ export class MaterialsOverlayStore {
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
load(next: Omit<MaterialsOverlayState, 'revision' | 'zoomTool'>): MaterialsOverlayState {
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeMaterialIds: [...next.activeMaterialIds],
|
||||
layouts: { ...next.layouts },
|
||||
legendLayouts: { ...next.legendLayouts },
|
||||
focusMaterialId: next.focusMaterialId,
|
||||
zoomTool: null,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,4 +256,17 @@ export class NpcsOverlayStore {
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
load(next: Pick<NpcsOverlayState, 'activeNpcIds' | 'layouts' | 'focusNpcId'>): NpcsOverlayState {
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeNpcIds: [...next.activeNpcIds],
|
||||
layouts: { ...next.layouts },
|
||||
focusNpcId: next.focusNpcId,
|
||||
zoomTool: null,
|
||||
inspectNpcIds: [],
|
||||
inspectFocusNpcId: null,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,4 +310,14 @@ export class SceneNpcTokensSessionStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load(next: Omit<SceneNpcTokensSessionState, 'revision'>): SceneNpcTokensSessionState {
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: { ...next.byPlacementId },
|
||||
scale: clampNpcTokenSessionScale(next.scale),
|
||||
spawned: { ...next.spawned },
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,4 +179,14 @@ export class ScenePlayerTokensSessionStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load(next: Omit<ScenePlayerTokensSessionState, 'revision'>): ScenePlayerTokensSessionState {
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
selectedPlayerIds: [...next.selectedPlayerIds],
|
||||
visible: next.visible,
|
||||
byPlayerId: { ...next.byPlayerId },
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ import type {
|
||||
SceneTrap,
|
||||
} from '../../shared/types';
|
||||
import { normalizeSceneAudioRef, PROJECT_SCHEMA_VERSION } from '../../shared/types';
|
||||
import { normalizeSceneDarknessStrokes } from '../../shared/types/sceneDarkness';
|
||||
import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
|
||||
import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens';
|
||||
import {
|
||||
@@ -772,6 +773,7 @@ export class ZipProjectStore {
|
||||
previewVideoAutostart: false,
|
||||
previewRotationDeg: 0,
|
||||
darkenScene: false,
|
||||
darknessStrokes: [],
|
||||
traps: [],
|
||||
tokens: [],
|
||||
npcTokens: [],
|
||||
@@ -798,6 +800,9 @@ export class ZipProjectStore {
|
||||
? { previewRotationDeg: patch.previewRotationDeg }
|
||||
: null),
|
||||
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
||||
...(patch.darknessStrokes !== undefined
|
||||
? { darknessStrokes: normalizeSceneDarknessStrokes(patch.darknessStrokes) }
|
||||
: null),
|
||||
...(patch.traps !== undefined
|
||||
? {
|
||||
traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)),
|
||||
@@ -2619,6 +2624,9 @@ function normalizeScene(s: Scene): Scene {
|
||||
const previewThumbAssetId =
|
||||
(s as unknown as { previewThumbAssetId?: AssetId | null }).previewThumbAssetId ?? null;
|
||||
const darkenScene = Boolean((s as unknown as { darkenScene?: boolean }).darkenScene);
|
||||
const darknessStrokes = normalizeSceneDarknessStrokes(
|
||||
(s as unknown as { darknessStrokes?: unknown }).darknessStrokes,
|
||||
);
|
||||
const rawTraps = (s as unknown as { traps?: unknown[] }).traps;
|
||||
const traps = (Array.isArray(rawTraps) ? rawTraps : [])
|
||||
.map((t) => normalizeSceneTrap(t))
|
||||
@@ -2666,6 +2674,7 @@ function normalizeScene(s: Scene): Scene {
|
||||
previewVideoAutostart,
|
||||
previewRotationDeg,
|
||||
darkenScene,
|
||||
darknessStrokes,
|
||||
traps,
|
||||
tokens,
|
||||
npcTokens,
|
||||
|
||||
@@ -131,4 +131,29 @@ export class SceneTrapsStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exportByCard(): Record<string, Record<string, SceneTrapRuntime>> {
|
||||
if (this.currentKey !== null) {
|
||||
this.cache.set(this.currentKey, cloneById(this.state.byId));
|
||||
}
|
||||
const out: Record<string, Record<string, SceneTrapRuntime>> = {};
|
||||
for (const [key, byId] of this.cache) {
|
||||
const kept: Record<string, SceneTrapRuntime> = {};
|
||||
for (const [id, runtime] of Object.entries(byId)) {
|
||||
if (runtime.status === 'inactive' && !runtime.revealed) continue;
|
||||
kept[id] = { ...runtime };
|
||||
}
|
||||
if (Object.keys(kept).length > 0) out[key] = kept;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
importByCard(byCard: Record<string, Record<string, SceneTrapRuntime>>): void {
|
||||
this.cache.clear();
|
||||
this.currentKey = null;
|
||||
this.state = emptyState();
|
||||
for (const [key, byId] of Object.entries(byCard)) {
|
||||
this.cache.set(key, cloneById(byId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,4 +128,20 @@ export class TimerSessionStore {
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Слепок: таймер на паузе, без абсолютного момента конца. */
|
||||
loadPaused(next: Pick<SessionTimerState, 'durationMs' | 'remainingMs' | 'visibleToPlayers' | 'layout'>): SessionTimerState {
|
||||
const durationMs = clampSessionTimerDurationMs(next.durationMs);
|
||||
const remainingMs = Math.max(0, Math.min(durationMs, Math.round(next.remainingMs)));
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
durationMs,
|
||||
remainingMs,
|
||||
running: false,
|
||||
endsAt: null,
|
||||
visibleToPlayers: Boolean(next.visibleToPlayers),
|
||||
layout: clampSessionTimerLayout(next.layout),
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,4 +112,12 @@ export class SceneTokensSessionStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load(byPlacementId: SceneTokensSessionState['byPlacementId']): SceneTokensSessionState {
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: { ...byPlacementId },
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,19 @@ export class TokenPathSessionStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load(
|
||||
playback: TokenPathSessionState['playback'],
|
||||
presentationVisible: TokenPathSessionState['presentationVisible'],
|
||||
): TokenPathSessionState {
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
playback: { ...playback },
|
||||
presentationVisible: { ...presentationVisible },
|
||||
serverNowMs: Date.now(),
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
|
||||
export type { TokenPathTargetRef };
|
||||
|
||||
@@ -160,6 +160,50 @@
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.modalFooterWrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.saveList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.saveRow {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.saveRowSelected {
|
||||
border-color: var(--scene-list-selected-border);
|
||||
background: var(--scene-list-selected-bg);
|
||||
}
|
||||
|
||||
.saveNameInput {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-surface);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.saveError {
|
||||
color: var(--color-danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.descriptionViewBody {
|
||||
min-height: 360px;
|
||||
max-height: min(560px, calc(100vh - 200px));
|
||||
|
||||
@@ -5,6 +5,7 @@ import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
|
||||
import { fitAspectRect } from '../../shared/geometry/fitAspectRect';
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import type { SessionState } from '../../shared/ipc/contracts';
|
||||
import type { GameSaveListItem } from '../../shared/types/gameSave';
|
||||
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
|
||||
import {
|
||||
isNodeInMainStoryline,
|
||||
@@ -204,6 +205,14 @@ export function ControlApp() {
|
||||
placementId: string;
|
||||
} | null>(null);
|
||||
const [stopConfirmOpen, setStopConfirmOpen] = useState(false);
|
||||
const [stopDirty, setStopDirty] = useState(false);
|
||||
const [saveOpen, setSaveOpen] = useState(false);
|
||||
const [saveEndAfter, setSaveEndAfter] = useState(false);
|
||||
const [saveItems, setSaveItems] = useState<GameSaveListItem[]>([]);
|
||||
const [saveName, setSaveName] = useState('');
|
||||
const [saveSelectedId, setSaveSelectedId] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState('');
|
||||
const [saveBusy, setSaveBusy] = useState(false);
|
||||
const [sceneView, sceneViewApi] = useSceneViewState();
|
||||
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
|
||||
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
|
||||
@@ -1156,26 +1165,74 @@ export function ControlApp() {
|
||||
};
|
||||
}, [npcMapSpawnDrag.dragging, npcMapSpawnDragApi, snapNormActive]);
|
||||
|
||||
const openStopConfirm = useCallback(() => {
|
||||
void api.invoke(ipcChannels.gameSave.isDirty, {}).then(
|
||||
({ dirty }) => {
|
||||
setStopDirty(dirty);
|
||||
setStopConfirmOpen(true);
|
||||
},
|
||||
() => {
|
||||
setStopDirty(true);
|
||||
setStopConfirmOpen(true);
|
||||
},
|
||||
);
|
||||
}, [api]);
|
||||
|
||||
const openSaveModal = useCallback((endAfter: boolean) => {
|
||||
setSaveEndAfter(endAfter);
|
||||
setSaveName('');
|
||||
setSaveSelectedId(null);
|
||||
setSaveError('');
|
||||
setSaveOpen(true);
|
||||
void api.invoke(ipcChannels.gameSave.list, {}).then(
|
||||
({ saves }) => setSaveItems(saves),
|
||||
() => setSaveItems([]),
|
||||
);
|
||||
}, [api]);
|
||||
|
||||
const commitSave = useCallback(() => {
|
||||
if (saveBusy) return;
|
||||
setSaveBusy(true);
|
||||
setSaveError('');
|
||||
void api
|
||||
.invoke(ipcChannels.gameSave.write, { id: saveSelectedId, name: saveName })
|
||||
.then(() => {
|
||||
setSaveOpen(false);
|
||||
setSaveBusy(false);
|
||||
if (saveEndAfter) {
|
||||
void api.invoke(ipcChannels.windows.closeMultiWindow, {});
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setSaveBusy(false);
|
||||
setSaveError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
}, [api, saveBusy, saveEndAfter, saveName, saveSelectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
return api.on(ipcChannels.windows.closeMultiWindowConfirmRequested, () => {
|
||||
setStopConfirmOpen(true);
|
||||
openStopConfirm();
|
||||
});
|
||||
}, [api]);
|
||||
}, [api, openStopConfirm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (npcMapSpawnDrag.dragging) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
e.preventDefault();
|
||||
if (saveOpen) {
|
||||
setSaveOpen(false);
|
||||
return;
|
||||
}
|
||||
if (stopConfirmOpen) {
|
||||
setStopConfirmOpen(false);
|
||||
return;
|
||||
}
|
||||
setStopConfirmOpen(true);
|
||||
openStopConfirm();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [npcMapSpawnDrag.dragging, stopConfirmOpen]);
|
||||
}, [npcMapSpawnDrag.dragging, openStopConfirm, saveOpen, stopConfirmOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!markersInteractive) {
|
||||
@@ -2142,6 +2199,21 @@ export function ControlApp() {
|
||||
<span className={styles.iconGlyph}>⬛</span>
|
||||
</Button>
|
||||
</div>
|
||||
{session?.currentSceneId ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
const sceneId = session.currentSceneId;
|
||||
if (!sceneId) return;
|
||||
void api.invoke(ipcChannels.project.updateScene, {
|
||||
sceneId,
|
||||
patch: { darknessStrokes: sdState.strokes },
|
||||
});
|
||||
}}
|
||||
data-testid="bake-darkness-to-scene"
|
||||
>
|
||||
{t('control.bakeDarknessToScene')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.radiusRow}>
|
||||
@@ -2514,7 +2586,24 @@ export function ControlApp() {
|
||||
: t('control.showPlayers')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button onClick={() => setStopConfirmOpen(true)}>{t('control.stopPresentation')}</Button>
|
||||
<Button onClick={openStopConfirm}>{t('control.stopPresentation')}</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
title={t('control.saveGame')}
|
||||
ariaLabel={t('control.saveGame')}
|
||||
data-testid="control-save-game"
|
||||
onClick={() => openSaveModal(false)}
|
||||
>
|
||||
<span className={styles.iconGlyph} aria-hidden>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M4 3h11.2L20 7.8V20a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm2 2v5h9V6.6L13.4 5H6zm1 9v5h10v-5H7zm2 1h6v3H9v-3z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.spacer10} />
|
||||
@@ -3138,7 +3227,7 @@ export function ControlApp() {
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!session?.project?.currentGraphNodeId}
|
||||
onClick={() => setStopConfirmOpen(true)}
|
||||
onClick={openStopConfirm}
|
||||
>
|
||||
{t('control.stopPresentation')}
|
||||
</Button>
|
||||
@@ -3749,18 +3838,112 @@ export function ControlApp() {
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div>{t('control.stopConfirm.body')}</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<div>{stopDirty ? t('control.stopConfirm.dirtyBody') : t('control.stopConfirm.body')}</div>
|
||||
<div className={[styles.modalFooter, stopDirty ? styles.modalFooterWrap : ''].filter(Boolean).join(' ')}>
|
||||
<Button onClick={() => setStopConfirmOpen(false)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
data-testid="stop-confirm-accept"
|
||||
{stopDirty ? (
|
||||
<Button
|
||||
data-testid="stop-confirm-discard"
|
||||
onClick={() => {
|
||||
setStopConfirmOpen(false);
|
||||
void api.invoke(ipcChannels.windows.closeMultiWindow, {});
|
||||
}}
|
||||
>
|
||||
{t('control.stopConfirm.discard')}
|
||||
</Button>
|
||||
) : null}
|
||||
{stopDirty ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
data-testid="stop-confirm-save-and-end"
|
||||
onClick={() => {
|
||||
setStopConfirmOpen(false);
|
||||
openSaveModal(true);
|
||||
}}
|
||||
>
|
||||
{t('control.stopConfirm.saveAndEnd')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
data-testid="stop-confirm-accept"
|
||||
onClick={() => {
|
||||
setStopConfirmOpen(false);
|
||||
void api.invoke(ipcChannels.windows.closeMultiWindow, {});
|
||||
}}
|
||||
>
|
||||
{t('control.stopConfirm.confirm')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
{saveOpen
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={styles.modalBackdrop}
|
||||
onClick={() => {
|
||||
if (!saveBusy) setSaveOpen(false);
|
||||
}}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog} data-testid="save-game-modal">
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('control.saveGame.title')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={styles.modalClose}
|
||||
onClick={() => {
|
||||
setStopConfirmOpen(false);
|
||||
void api.invoke(ipcChannels.windows.closeMultiWindow, {});
|
||||
if (!saveBusy) setSaveOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('control.stopConfirm.confirm')}
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className={styles.saveNameInput}
|
||||
value={saveName}
|
||||
placeholder={t('control.saveGame.name')}
|
||||
aria-label={t('control.saveGame.name')}
|
||||
onChange={(e) => {
|
||||
setSaveName(e.currentTarget.value);
|
||||
setSaveSelectedId(null);
|
||||
}}
|
||||
/>
|
||||
<div className={styles.saveList}>
|
||||
{saveItems.length === 0 ? <div>{t('control.saveGame.empty')}</div> : null}
|
||||
{saveItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={[
|
||||
styles.saveRow,
|
||||
saveSelectedId === item.id ? styles.saveRowSelected : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => {
|
||||
setSaveSelectedId(item.id);
|
||||
setSaveName(item.name);
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{saveError ? <div className={styles.saveError}>{saveError}</div> : null}
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={() => setSaveOpen(false)} disabled={saveBusy}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" data-testid="save-game-commit" disabled={saveBusy} onClick={commitSave}>
|
||||
{t('control.saveGame.commit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -97,6 +97,8 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
|
||||
assert.ok(src.includes("t('control.darknessControl')"));
|
||||
assert.ok(src.includes("t('control.explorerBrush')"));
|
||||
assert.ok(src.includes("t('control.closerBrush')"));
|
||||
assert.ok(src.includes("t('control.bakeDarknessToScene')"));
|
||||
assert.ok(src.includes('bake-darkness-to-scene'));
|
||||
assert.ok(src.includes("tool: 'closeBrush'") || src.includes("selectEffectTool('closeBrush')"));
|
||||
assert.ok(src.includes("mode: b.tool === 'closeBrush' ? 'cover' : 'reveal'") || src.includes("'cover'"));
|
||||
assert.ok(src.includes('SceneDarknessOverlay'));
|
||||
@@ -326,7 +328,10 @@ void test('ControlApp: Escape → своя модалка подтвержден
|
||||
assert.ok(src.includes('data-testid="control-help-tool"'));
|
||||
assert.match(src, /data-testid="control-help-tool"[\s\S]{0,180}iconOnly|iconOnly[\s\S]{0,180}data-testid="control-help-tool"/);
|
||||
assert.ok(src.includes("section: 'controlPanel'"));
|
||||
assert.equal((src.match(/windows\.closeMultiWindow,/g) ?? []).length, 1);
|
||||
assert.equal((src.match(/windows\.closeMultiWindow,/g) ?? []).length, 3);
|
||||
assert.ok(src.includes('data-testid="control-save-game"'));
|
||||
assert.ok(src.includes('data-testid="stop-confirm-save-and-end"'));
|
||||
assert.ok(src.includes('data-testid="stop-confirm-discard"'));
|
||||
assert.ok(!src.includes('showMessageBox'));
|
||||
assert.ok(!src.includes('window.confirm'));
|
||||
});
|
||||
|
||||
@@ -385,6 +385,11 @@
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.fileMenuItem:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.fileMenuItemDanger {
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
@@ -525,6 +530,30 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.saveList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.saveRow {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.saveRowSelected {
|
||||
border-color: var(--scene-list-selected-border);
|
||||
background: var(--scene-list-selected-bg);
|
||||
}
|
||||
|
||||
.licenseBlockTitle {
|
||||
font-weight: 900;
|
||||
font-size: var(--text-lg);
|
||||
@@ -921,6 +950,11 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actionsRowHalf > button {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actionsRowHalf > span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '../../shared/ipc/contracts';
|
||||
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
|
||||
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
|
||||
import type { GameSaveListItem } from '../../shared/types/gameSave';
|
||||
import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension';
|
||||
import type {
|
||||
AssetId,
|
||||
@@ -162,6 +163,10 @@ export function EditorApp() {
|
||||
const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
|
||||
const [playersManagerOpen, setPlayersManagerOpen] = useState(false);
|
||||
const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false);
|
||||
const [gameSaves, setGameSaves] = useState<GameSaveListItem[]>([]);
|
||||
const [loadSaveOpen, setLoadSaveOpen] = useState(false);
|
||||
const [loadSaveId, setLoadSaveId] = useState<string | null>(null);
|
||||
const [loadSaveError, setLoadSaveError] = useState('');
|
||||
const [playlistsManagerOpen, setPlaylistsManagerOpen] = useState(false);
|
||||
const [presentationSettingsOpen, setPresentationSettingsOpen] = useState(false);
|
||||
const [gameSystemsOpen, setGameSystemsOpen] = useState(false);
|
||||
@@ -387,6 +392,47 @@ export function EditorApp() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const refreshGameSaves = useCallback(() => {
|
||||
if (!state.project) {
|
||||
setGameSaves([]);
|
||||
return;
|
||||
}
|
||||
void getDndApi()
|
||||
.invoke(ipcChannels.gameSave.list, {})
|
||||
.then(
|
||||
({ saves }) => setGameSaves(saves),
|
||||
() => setGameSaves([]),
|
||||
);
|
||||
}, [state.project]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshGameSaves();
|
||||
}, [refreshGameSaves]);
|
||||
|
||||
const launchFromSave = useCallback(
|
||||
(saveId: string) => {
|
||||
if (!licenseActive || launching) return;
|
||||
stopAllEditorAudio();
|
||||
setLaunching(true);
|
||||
setRunMenuOpen(false);
|
||||
setLoadSaveError('');
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await getDndApi().invoke(ipcChannels.windows.openMultiWindow, { saveId });
|
||||
if (res.project) actions.noteProject(res.project);
|
||||
setLoadSaveOpen(false);
|
||||
} catch (err) {
|
||||
setLaunching(false);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setLoadSaveError(
|
||||
message.includes('GAME_SAVE_CARD_MISSING') ? t('top.loadSave.cardMissing') : message,
|
||||
);
|
||||
}
|
||||
})();
|
||||
},
|
||||
[licenseActive, launching, t, actions],
|
||||
);
|
||||
|
||||
const launchFromGraphNode = useCallback(
|
||||
(graphNodeId: GraphNodeId, playerIds?: string[]) => {
|
||||
if (!licenseActive || launching) return;
|
||||
@@ -943,6 +989,7 @@ export function EditorApp() {
|
||||
data-testid="run-menu-btn"
|
||||
onClick={() => {
|
||||
if (runDisabled || launching) return;
|
||||
refreshGameSaves();
|
||||
setRunMenuOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
@@ -1219,7 +1266,6 @@ export function EditorApp() {
|
||||
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
|
||||
previewVideoLoop={sc?.settings.loopVideo ?? false}
|
||||
previewRotationDeg={sc?.previewRotationDeg ?? 0}
|
||||
darkenScene={sc?.darkenScene ?? false}
|
||||
previewBusy={previewBusy}
|
||||
previewBusyText={previewBusyText}
|
||||
mediaAssets={sceneMediaAssets}
|
||||
@@ -1233,7 +1279,6 @@ export function EditorApp() {
|
||||
onPreviewVideoLoopChange={(next) =>
|
||||
void actions.updateScene(sid, { settings: { loopVideo: next } })
|
||||
}
|
||||
onDarkenSceneChange={(next) => void actions.updateScene(sid, { darkenScene: next })}
|
||||
onTitleChange={(title) => void actions.updateScene(sid, { title })}
|
||||
onDescriptionChange={(description) =>
|
||||
void actions.updateScene(sid, { description })
|
||||
@@ -1692,6 +1737,69 @@ export function EditorApp() {
|
||||
launchFromGraphNode(graphStartGraphNodeId, playerIds);
|
||||
}}
|
||||
/>
|
||||
{loadSaveOpen
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={styles.modalBackdrop}
|
||||
onClick={() => {
|
||||
if (!launching) setLoadSaveOpen(false);
|
||||
}}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog} data-testid="load-save-modal">
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('top.loadSave.title')}</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.modalClose}
|
||||
aria-label={t('common.close')}
|
||||
onClick={() => {
|
||||
if (!launching) setLoadSaveOpen(false);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.saveList}>
|
||||
{gameSaves.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={[
|
||||
styles.saveRow,
|
||||
loadSaveId === item.id ? styles.saveRowSelected : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => setLoadSaveId(item.id)}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{loadSaveError ? <div className={styles.fieldError}>{loadSaveError}</div> : null}
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={() => setLoadSaveOpen(false)} disabled={launching}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
data-testid="load-save-commit"
|
||||
disabled={!loadSaveId || launching}
|
||||
onClick={() => {
|
||||
if (loadSaveId) launchFromSave(loadSaveId);
|
||||
}}
|
||||
>
|
||||
{t('top.loadSave.action')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
) : null}
|
||||
{USERS_BRANCH_FEATURES_ENABLED && runMenuOpen && runMenuPos
|
||||
@@ -1714,6 +1822,22 @@ export function EditorApp() {
|
||||
>
|
||||
{t('top.runWithPlayers')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.fileMenuItem}
|
||||
role="menuitem"
|
||||
data-testid="run-load-save-item"
|
||||
disabled={gameSaves.length === 0}
|
||||
onClick={() => {
|
||||
if (gameSaves.length === 0) return;
|
||||
setRunMenuOpen(false);
|
||||
setLoadSaveId(null);
|
||||
setLoadSaveError('');
|
||||
setLoadSaveOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('top.loadSave')}
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
@@ -2494,7 +2618,6 @@ type SceneInspectorProps = {
|
||||
previewVideoAutostart: boolean;
|
||||
previewVideoLoop: boolean;
|
||||
previewRotationDeg: 0 | 90 | 180 | 270;
|
||||
darkenScene: boolean;
|
||||
previewBusy: boolean;
|
||||
previewBusyText: string;
|
||||
mediaAssets: MediaAsset[];
|
||||
@@ -2502,7 +2625,6 @@ type SceneInspectorProps = {
|
||||
onAudioRefsChange: (next: SceneAudioRef[]) => void;
|
||||
onPreviewVideoAutostartChange: (next: boolean) => void;
|
||||
onPreviewVideoLoopChange: (next: boolean) => void;
|
||||
onDarkenSceneChange: (next: boolean) => void;
|
||||
onTitleChange: (v: string) => void;
|
||||
onDescriptionChange: (v: string) => void;
|
||||
onImportPreview: () => void;
|
||||
@@ -2575,7 +2697,6 @@ function SceneInspector({
|
||||
previewVideoAutostart,
|
||||
previewVideoLoop,
|
||||
previewRotationDeg,
|
||||
darkenScene,
|
||||
previewBusy,
|
||||
previewBusyText,
|
||||
mediaAssets,
|
||||
@@ -2583,7 +2704,6 @@ function SceneInspector({
|
||||
onAudioRefsChange,
|
||||
onPreviewVideoAutostartChange,
|
||||
onPreviewVideoLoopChange,
|
||||
onDarkenSceneChange,
|
||||
onTitleChange,
|
||||
onDescriptionChange,
|
||||
onImportPreview,
|
||||
@@ -2737,8 +2857,7 @@ function SceneInspector({
|
||||
</div>
|
||||
) : null}
|
||||
{previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? (
|
||||
<>
|
||||
<div className={styles.spacer6} />
|
||||
<div className={styles.actionsRowHalf}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const next = ((previewRotationDeg + 90) % 360) as 0 | 90 | 180 | 270;
|
||||
@@ -2747,20 +2866,6 @@ function SceneInspector({
|
||||
>
|
||||
{t('scene.rotate')}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? (
|
||||
<>
|
||||
<div className={styles.spacer6} />
|
||||
<label className={styles.checkboxLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={darkenScene}
|
||||
onChange={(e) => onDarkenSceneChange(e.target.checked)}
|
||||
/>
|
||||
<span className={styles.spanSm}>{t('scene.darkenScene')}</span>
|
||||
</label>
|
||||
<div className={styles.spacer6} />
|
||||
<Button
|
||||
onClick={() => {
|
||||
void getDndApi().invoke(ipcChannels.windows.openSceneEditor, {});
|
||||
@@ -2768,7 +2873,7 @@ function SceneInspector({
|
||||
>
|
||||
{t('sceneEditor.open')}
|
||||
</Button>
|
||||
</>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.spacer6} />
|
||||
<div className={styles.labelSm}>{t('scene.audio')}</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ export const HELP_SECTION_LINK_ALIASES: Partial<Record<HelpSectionId, readonly s
|
||||
effects: ['Эффекты', 'Effects'],
|
||||
presentation: ['Презентация', 'Presentation'],
|
||||
grid: ['Сетка', 'Grid'],
|
||||
sceneEditor: ['Затемнение сцены', 'Scene darkness', 'Редактор сцены', 'Scene editor'],
|
||||
controlPanel: ['Пульт'],
|
||||
players: ['Игроки', 'Players'],
|
||||
tokens: ['Токены', 'Tokens'],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -43,6 +43,7 @@ type Actions = {
|
||||
createProject: (name: string) => Promise<void>;
|
||||
openProject: (id: ProjectId) => Promise<void>;
|
||||
closeProject: () => Promise<void>;
|
||||
noteProject: (project: Project) => void;
|
||||
createScene: () => Promise<void>;
|
||||
createScenesFromMediaPaths: (
|
||||
items: { filePath: string; title: string }[],
|
||||
@@ -71,6 +72,7 @@ type Actions = {
|
||||
previewVideoAutostart?: boolean;
|
||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
||||
darkenScene?: boolean;
|
||||
darknessStrokes?: Scene['darknessStrokes'];
|
||||
settings?: Partial<Scene['settings']>;
|
||||
media?: Partial<Scene['media']>;
|
||||
layout?: { x: number; y: number };
|
||||
@@ -354,6 +356,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
previewVideoAutostart: false,
|
||||
previewRotationDeg: 0,
|
||||
darkenScene: false,
|
||||
darknessStrokes: [],
|
||||
traps: [],
|
||||
tokens: [],
|
||||
npcTokens: [],
|
||||
@@ -564,6 +567,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
previewVideoAutostart?: boolean;
|
||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
||||
darkenScene?: boolean;
|
||||
darknessStrokes?: Scene['darknessStrokes'];
|
||||
traps?: import('../../../shared/types').SceneTrap[];
|
||||
tokens?: import('../../../shared/types').SceneToken[];
|
||||
npcTokens?: import('../../../shared/types').SceneNpcToken[];
|
||||
@@ -594,6 +598,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
? { previewRotationDeg: patch.previewRotationDeg }
|
||||
: null),
|
||||
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
||||
...(patch.darknessStrokes !== undefined ? { darknessStrokes: patch.darknessStrokes } : null),
|
||||
...(patch.traps !== undefined ? { traps: patch.traps } : null),
|
||||
...(patch.tokens !== undefined ? { tokens: patch.tokens } : null),
|
||||
...(patch.npcTokens !== undefined ? { npcTokens: patch.npcTokens } : null),
|
||||
@@ -918,6 +923,14 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
}
|
||||
};
|
||||
|
||||
const noteProject = (project: Project) => {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
project,
|
||||
selectedSceneId: project.currentSceneId ?? s.selectedSceneId,
|
||||
}));
|
||||
};
|
||||
|
||||
const deleteProject = async (projectId: ProjectId) => {
|
||||
projectDataEpochRef.current += 1;
|
||||
await api.invoke(ipcChannels.project.deleteProject, { projectId });
|
||||
@@ -936,6 +949,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
createProject,
|
||||
openProject,
|
||||
closeProject,
|
||||
noteProject,
|
||||
createScene,
|
||||
createScenesFromMediaPaths,
|
||||
selectScene,
|
||||
|
||||
@@ -86,6 +86,39 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.darknessTools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.darknessBrushLayer {
|
||||
position: absolute;
|
||||
z-index: 29;
|
||||
cursor: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.brushCursor {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
will-change: left, top;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.3),
|
||||
0 0 18px rgba(255, 140, 40, 0.12),
|
||||
inset 0 0 18px rgba(255, 140, 40, 0.1);
|
||||
background: rgba(255, 160, 60, 0.03);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brushCursorSquare {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
|
||||
@@ -7,14 +7,21 @@ import type {
|
||||
NpcDisposition,
|
||||
NpcId,
|
||||
ProjectNpc,
|
||||
Scene,
|
||||
SceneDarknessBrushShape,
|
||||
SceneDarknessDraft,
|
||||
SceneDarknessRevealStroke,
|
||||
SceneDarknessState,
|
||||
SceneGrid,
|
||||
SceneGridTemplate,
|
||||
SceneId,
|
||||
SceneNpcToken,
|
||||
SceneToken,
|
||||
SceneTrap,
|
||||
SceneTrapType,
|
||||
TokenId,
|
||||
} from '../../shared/types';
|
||||
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
|
||||
import {
|
||||
asSceneNpcTokenId,
|
||||
clampSceneNpcTokenSizeN,
|
||||
@@ -237,6 +244,24 @@ function SceneNpcMarker({
|
||||
);
|
||||
}
|
||||
|
||||
function sameDarknessStrokes(
|
||||
a: readonly SceneDarknessRevealStroke[],
|
||||
b: readonly SceneDarknessRevealStroke[],
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
const left = a[i];
|
||||
const right = b[i];
|
||||
if (!left || !right) return false;
|
||||
if (left.id !== right.id || left.points.length !== right.points.length) return false;
|
||||
if (left.radiusN !== right.radiusN || left.mode !== right.mode || left.shape !== right.shape) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function NpcPaletteTile({ npc }: { npc: ProjectNpc }) {
|
||||
const imageUrl = useAssetUrl(npc.avatarAssetId);
|
||||
const disposition = normalizeNpcDisposition(npc.disposition);
|
||||
@@ -270,8 +295,28 @@ export function SceneEditorApp() {
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [trapsOpen, setTrapsOpen] = useState(false);
|
||||
const [gridOpen, setGridOpen] = useState(false);
|
||||
const [darknessOpen, setDarknessOpen] = useState(false);
|
||||
const [tokensOpen, setTokensOpen] = useState(false);
|
||||
const [npcsOpen, setNpcsOpen] = useState(false);
|
||||
const [localDarken, setLocalDarken] = useState(false);
|
||||
const [localDarknessStrokes, setLocalDarknessStrokes] = useState<SceneDarknessRevealStroke[]>([]);
|
||||
const [darknessDraft, setDarknessDraft] = useState<SceneDarknessDraft | null>(null);
|
||||
const [darknessTool, setDarknessTool] = useState<'none' | 'explore' | 'close'>('none');
|
||||
const [darknessRadiusN, setDarknessRadiusN] = useState(0.08);
|
||||
const [darknessBrushShape, setDarknessBrushShape] = useState<SceneDarknessBrushShape>('circle');
|
||||
const darknessBrushRef = useRef<{
|
||||
points: { x: number; y: number; tMs: number }[];
|
||||
createdAtMs: number;
|
||||
seed: number;
|
||||
} | null>(null);
|
||||
const brushCursorElRef = useRef<HTMLDivElement | null>(null);
|
||||
const darknessCursorPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const darknessToolRef = useRef(darknessTool);
|
||||
const darknessRadiusNRef = useRef(darknessRadiusN);
|
||||
const darknessBrushShapeRef = useRef(darknessBrushShape);
|
||||
darknessToolRef.current = darknessTool;
|
||||
darknessRadiusNRef.current = darknessRadiusN;
|
||||
darknessBrushShapeRef.current = darknessBrushShape;
|
||||
const [tokenSearch, setTokenSearch] = useState('');
|
||||
const [npcSearch, setNpcSearch] = useState('');
|
||||
const [tokenModal, setTokenModal] = useState<
|
||||
@@ -291,16 +336,23 @@ export function SceneEditorApp() {
|
||||
const [selected, setSelected] = useState<Selection>(null);
|
||||
const [view, setView] = useState<LocalView>({ scale: 1, ox: 0.5, oy: 0.5 });
|
||||
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||
const contentRectRef = useRef(contentRect);
|
||||
contentRectRef.current = contentRect;
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragRef = useRef<DragMode>(null);
|
||||
const saveTrapsTimerRef = useRef(0);
|
||||
const saveTokensTimerRef = useRef(0);
|
||||
const saveNpcTokensTimerRef = useRef(0);
|
||||
const saveGridTimerRef = useRef(0);
|
||||
const saveDarknessTimerRef = useRef(0);
|
||||
const pendingDarknessStrokesRef = useRef<SceneDarknessRevealStroke[] | null>(null);
|
||||
const darknessDirtyRef = useRef(false);
|
||||
const spaceDownRef = useRef(false);
|
||||
const sceneIdRef = useRef<SceneId | null>(null);
|
||||
|
||||
const project = session?.project ?? null;
|
||||
const sceneId = project?.currentSceneId ?? null;
|
||||
sceneIdRef.current = sceneId;
|
||||
const scene = sceneId && project ? project.scenes[sceneId] : undefined;
|
||||
const url = useAssetUrl(scene?.previewAssetId ?? null);
|
||||
const rot = scene?.previewRotationDeg ?? 0;
|
||||
@@ -316,6 +368,7 @@ export function SceneEditorApp() {
|
||||
const trapsRef = useRef<SceneTrap[]>([]);
|
||||
const tokensRef = useRef<SceneToken[]>([]);
|
||||
const npcTokensRef = useRef<SceneNpcToken[]>([]);
|
||||
const localDarknessStrokesRef = useRef<SceneDarknessRevealStroke[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
trapsRef.current = localTraps;
|
||||
@@ -324,12 +377,28 @@ export function SceneEditorApp() {
|
||||
}, [localNpcTokens, localTokens, localTraps]);
|
||||
|
||||
useEffect(() => {
|
||||
localDarknessStrokesRef.current = localDarknessStrokes;
|
||||
}, [localDarknessStrokes]);
|
||||
|
||||
useEffect(() => {
|
||||
darknessDirtyRef.current = false;
|
||||
pendingDarknessStrokesRef.current = null;
|
||||
if (saveDarknessTimerRef.current) {
|
||||
window.clearTimeout(saveDarknessTimerRef.current);
|
||||
saveDarknessTimerRef.current = 0;
|
||||
}
|
||||
setLocalTraps(scene?.traps ?? []);
|
||||
setLocalTokens((scene?.tokens ?? []).filter((t) => appTokens.some((a) => a.id === t.tokenId)));
|
||||
setLocalNpcTokens(
|
||||
(scene?.npcTokens ?? []).filter((t) => project?.npcs.some((npc) => npc.id === t.npcId)),
|
||||
);
|
||||
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
|
||||
setLocalDarken(Boolean(scene?.darkenScene));
|
||||
const strokes = scene?.darknessStrokes ?? [];
|
||||
localDarknessStrokesRef.current = strokes;
|
||||
setLocalDarknessStrokes(strokes);
|
||||
setDarknessDraft(null);
|
||||
setDarknessTool('none');
|
||||
setSelected(null);
|
||||
setView({ scale: 1, ox: 0.5, oy: 0.5 });
|
||||
}, [sceneId, scene?.previewAssetId]);
|
||||
@@ -356,6 +425,16 @@ export function SceneEditorApp() {
|
||||
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
|
||||
}, [scene?.grid]);
|
||||
|
||||
useEffect(() => {
|
||||
// IPC session clones arrays — do not clobber unsaved local darkness edits.
|
||||
if (darknessBrushRef.current || darknessDirtyRef.current || saveDarknessTimerRef.current) return;
|
||||
setLocalDarken(Boolean(scene?.darkenScene));
|
||||
const incoming = scene?.darknessStrokes ?? [];
|
||||
if (sameDarknessStrokes(incoming, localDarknessStrokesRef.current)) return;
|
||||
localDarknessStrokesRef.current = incoming;
|
||||
setLocalDarknessStrokes(incoming);
|
||||
}, [scene?.darkenScene, scene?.darknessStrokes]);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.project.get, {}).then(({ project: p }) => {
|
||||
setSession({ project: p, currentSceneId: p?.currentSceneId ?? null });
|
||||
@@ -404,6 +483,99 @@ export function SceneEditorApp() {
|
||||
[api, sceneId],
|
||||
);
|
||||
|
||||
const mergeSceneIntoSession = useCallback((updated: Scene) => {
|
||||
setSession((prev) => {
|
||||
if (!prev?.project?.scenes[updated.id]) return prev;
|
||||
return {
|
||||
...prev,
|
||||
project: {
|
||||
...prev.project,
|
||||
scenes: { ...prev.project.scenes, [updated.id]: updated },
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const flushDarknessStrokes = useCallback(async () => {
|
||||
const sid = sceneIdRef.current;
|
||||
const payload = pendingDarknessStrokesRef.current;
|
||||
if (saveDarknessTimerRef.current) {
|
||||
window.clearTimeout(saveDarknessTimerRef.current);
|
||||
saveDarknessTimerRef.current = 0;
|
||||
}
|
||||
if (!sid || !payload) return;
|
||||
pendingDarknessStrokesRef.current = null;
|
||||
try {
|
||||
const res = await api.invoke(ipcChannels.project.updateScene, {
|
||||
sceneId: sid,
|
||||
patch: { darknessStrokes: payload },
|
||||
});
|
||||
darknessDirtyRef.current = false;
|
||||
mergeSceneIntoSession(res.scene);
|
||||
} catch {
|
||||
// Keep dirty so a later flush / session sync can retry from local ref.
|
||||
pendingDarknessStrokesRef.current = payload;
|
||||
darknessDirtyRef.current = true;
|
||||
}
|
||||
}, [api, mergeSceneIntoSession]);
|
||||
|
||||
const persistDarken = useCallback(
|
||||
(next: boolean) => {
|
||||
const sid = sceneIdRef.current;
|
||||
if (!sid) return;
|
||||
setLocalDarken(next);
|
||||
if (!next) setDarknessTool('none');
|
||||
darknessDirtyRef.current = true;
|
||||
void (async () => {
|
||||
await flushDarknessStrokes();
|
||||
try {
|
||||
const res = await api.invoke(ipcChannels.project.updateScene, {
|
||||
sceneId: sid,
|
||||
patch: { darkenScene: next },
|
||||
});
|
||||
darknessDirtyRef.current = false;
|
||||
mergeSceneIntoSession(res.scene);
|
||||
} catch {
|
||||
darknessDirtyRef.current = true;
|
||||
}
|
||||
})();
|
||||
},
|
||||
[api, flushDarknessStrokes, mergeSceneIntoSession],
|
||||
);
|
||||
|
||||
const persistDarknessStrokes = useCallback(
|
||||
(next: SceneDarknessRevealStroke[]) => {
|
||||
if (!sceneIdRef.current) return;
|
||||
localDarknessStrokesRef.current = next;
|
||||
setLocalDarknessStrokes(next);
|
||||
pendingDarknessStrokesRef.current = next;
|
||||
darknessDirtyRef.current = true;
|
||||
if (saveDarknessTimerRef.current) window.clearTimeout(saveDarknessTimerRef.current);
|
||||
saveDarknessTimerRef.current = window.setTimeout(() => {
|
||||
void flushDarknessStrokes();
|
||||
}, 120);
|
||||
},
|
||||
[flushDarknessStrokes],
|
||||
);
|
||||
|
||||
const flushDarknessStrokesRef = useRef(flushDarknessStrokes);
|
||||
flushDarknessStrokesRef.current = flushDarknessStrokes;
|
||||
useEffect(() => {
|
||||
const flush = () => {
|
||||
void flushDarknessStrokesRef.current();
|
||||
};
|
||||
const onVis = () => {
|
||||
if (document.hidden) flush();
|
||||
};
|
||||
window.addEventListener('blur', flush);
|
||||
document.addEventListener('visibilitychange', onVis);
|
||||
return () => {
|
||||
window.removeEventListener('blur', flush);
|
||||
document.removeEventListener('visibilitychange', onVis);
|
||||
flush();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const persistGrid = useCallback(
|
||||
(next: SceneGrid) => {
|
||||
if (!sceneId) return;
|
||||
@@ -504,6 +676,34 @@ export function SceneEditorApp() {
|
||||
};
|
||||
};
|
||||
|
||||
const layoutDarknessBrushCursor = () => {
|
||||
const el = brushCursorElRef.current;
|
||||
const p = darknessCursorPosRef.current;
|
||||
const cr = contentRectRef.current;
|
||||
if (!el) return;
|
||||
if (!p || !cr || darknessToolRef.current === 'none') {
|
||||
el.style.visibility = 'hidden';
|
||||
return;
|
||||
}
|
||||
el.style.visibility = 'visible';
|
||||
el.className = [
|
||||
styles.brushCursor,
|
||||
darknessBrushShapeRef.current === 'square' ? styles.brushCursorSquare : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const minDim = Math.min(cr.w, cr.h);
|
||||
const size = Math.max(1, darknessRadiusNRef.current * minDim * 2);
|
||||
el.style.left = `${String(cr.x + p.x * cr.w)}px`;
|
||||
el.style.top = `${String(cr.y + p.y * cr.h)}px`;
|
||||
el.style.width = `${String(size)}px`;
|
||||
el.style.height = `${String(size)}px`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
layoutDarknessBrushCursor();
|
||||
}, [darknessBrushShape, darknessRadiusN, darknessTool, contentRect]);
|
||||
|
||||
const viewCamera = useMemo(() => view, [view]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -632,6 +832,17 @@ export function SceneEditorApp() {
|
||||
const isVideo = scene?.previewAssetType === 'video' && Boolean(url);
|
||||
const hasMapMedia = isImage || isVideo;
|
||||
|
||||
const darknessOverlayState = useMemo((): SceneDarknessState | null => {
|
||||
if (!localDarken) return null;
|
||||
return {
|
||||
revision: 1,
|
||||
enabled: true,
|
||||
cacheKey: sceneId,
|
||||
strokes: localDarknessStrokes,
|
||||
draft: darknessDraft,
|
||||
};
|
||||
}, [darknessDraft, localDarken, localDarknessStrokes, sceneId]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<aside className={styles.sidebar}>
|
||||
@@ -828,6 +1039,98 @@ export function SceneEditorApp() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.accordion} data-testid="scene-darkness-accordion">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.accordionHead}
|
||||
onClick={() => setDarknessOpen((v) => !v)}
|
||||
>
|
||||
{t('sceneEditor.darkness')} {darknessOpen ? '▾' : '▸'}
|
||||
</button>
|
||||
{darknessOpen ? (
|
||||
<div className={styles.gridPanel}>
|
||||
<label className={styles.checkRow}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localDarken}
|
||||
disabled={!hasMapMedia}
|
||||
onChange={(e) => persistDarken(e.target.checked)}
|
||||
data-testid="scene-darken-toggle"
|
||||
/>
|
||||
<span>{t('scene.darkenScene')}</span>
|
||||
</label>
|
||||
{localDarken && hasMapMedia ? (
|
||||
<>
|
||||
<div className={styles.darknessTools}>
|
||||
<Button
|
||||
variant={darknessTool === 'explore' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title={t('control.explorerBrush')}
|
||||
ariaLabel={t('control.explorerBrush')}
|
||||
onClick={() =>
|
||||
setDarknessTool((prev) => (prev === 'explore' ? 'none' : 'explore'))
|
||||
}
|
||||
>
|
||||
<span aria-hidden>🔦</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={darknessTool === 'close' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title={t('control.closerBrush')}
|
||||
ariaLabel={t('control.closerBrush')}
|
||||
onClick={() =>
|
||||
setDarknessTool((prev) => (prev === 'close' ? 'none' : 'close'))
|
||||
}
|
||||
>
|
||||
<span aria-hidden>⬛</span>
|
||||
</Button>
|
||||
</div>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.fieldLabel}>
|
||||
{t('control.brushRadius')}{' '}
|
||||
<span className={styles.fieldValue}>
|
||||
{Math.max(1, Math.min(36, Math.round(darknessRadiusN / 0.005)))}
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
className={styles.range}
|
||||
min={1}
|
||||
max={36}
|
||||
step={1}
|
||||
value={Math.max(1, Math.min(36, Math.round(darknessRadiusN / 0.005)))}
|
||||
onChange={(e) => {
|
||||
const ui = Number(e.currentTarget.value);
|
||||
setDarknessRadiusN(Math.max(1, Math.min(36, Math.round(ui))) * 0.005);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className={styles.darknessTools}>
|
||||
<Button
|
||||
variant={darknessBrushShape === 'circle' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title={t('control.brushShapeCircle')}
|
||||
ariaLabel={t('control.brushShapeCircle')}
|
||||
onClick={() => setDarknessBrushShape('circle')}
|
||||
>
|
||||
<span aria-hidden>○</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={darknessBrushShape === 'square' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title={t('control.brushShapeSquare')}
|
||||
ariaLabel={t('control.brushShapeSquare')}
|
||||
onClick={() => setDarknessBrushShape('square')}
|
||||
>
|
||||
<span aria-hidden>□</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{USERS_BRANCH_FEATURES_ENABLED ? (
|
||||
<div className={styles.accordion} data-testid="scene-npc-accordion">
|
||||
<button type="button" className={styles.accordionHead} onClick={() => setNpcsOpen((v) => !v)}>
|
||||
@@ -1071,6 +1374,105 @@ export function SceneEditorApp() {
|
||||
/>
|
||||
)}
|
||||
<SceneGridOverlay grid={localGrid} viewport={contentRect} />
|
||||
{localDarken && contentRect ? (
|
||||
<SceneDarknessOverlay
|
||||
state={darknessOverlayState}
|
||||
overlayAlpha={0.55}
|
||||
viewport={contentRect}
|
||||
style={{ zIndex: 28 }}
|
||||
/>
|
||||
) : null}
|
||||
{localDarken && darknessTool !== 'none' && contentRect ? (
|
||||
<>
|
||||
<div
|
||||
ref={brushCursorElRef}
|
||||
className={styles.brushCursor}
|
||||
style={{ visibility: 'hidden' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className={styles.darknessBrushLayer}
|
||||
style={{
|
||||
left: contentRect.x,
|
||||
top: contentRect.y,
|
||||
width: contentRect.w,
|
||||
height: contentRect.h,
|
||||
}}
|
||||
onPointerEnter={(e) => {
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
darknessCursorPosRef.current = p;
|
||||
layoutDarknessBrushCursor();
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
darknessCursorPosRef.current = null;
|
||||
layoutDarknessBrushCursor();
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0 || spaceDownRef.current) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
darknessCursorPosRef.current = p;
|
||||
layoutDarknessBrushCursor();
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
const createdAtMs = Date.now();
|
||||
const seed = Math.floor(Math.random() * 1_000_000_000);
|
||||
const pt = { x: p.x, y: p.y, tMs: createdAtMs };
|
||||
darknessBrushRef.current = { points: [pt], createdAtMs, seed };
|
||||
setDarknessDraft({
|
||||
points: [pt],
|
||||
radiusN: darknessRadiusN,
|
||||
mode: darknessTool === 'close' ? 'cover' : 'reveal',
|
||||
shape: darknessBrushShape,
|
||||
});
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
if (p) {
|
||||
darknessCursorPosRef.current = p;
|
||||
layoutDarknessBrushCursor();
|
||||
}
|
||||
const brush = darknessBrushRef.current;
|
||||
if (!brush || (e.buttons & 1) === 0) return;
|
||||
if (!p) return;
|
||||
const last = brush.points[brush.points.length - 1];
|
||||
if (last && Math.hypot(p.x - last.x, p.y - last.y) < 0.002) return;
|
||||
const pt = { x: p.x, y: p.y, tMs: Date.now() };
|
||||
brush.points.push(pt);
|
||||
setDarknessDraft({
|
||||
points: [...brush.points],
|
||||
radiusN: darknessRadiusN,
|
||||
mode: darknessTool === 'close' ? 'cover' : 'reveal',
|
||||
shape: darknessBrushShape,
|
||||
});
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
const brush = darknessBrushRef.current;
|
||||
darknessBrushRef.current = null;
|
||||
setDarknessDraft(null);
|
||||
if (!brush || brush.points.length === 0) return;
|
||||
const stroke: SceneDarknessRevealStroke = {
|
||||
id: `sd_${String(brush.createdAtMs)}_${String(brush.seed)}`,
|
||||
seed: brush.seed,
|
||||
createdAtMs: brush.createdAtMs,
|
||||
points: brush.points,
|
||||
radiusN: darknessRadiusN,
|
||||
mode: darknessTool === 'close' ? 'cover' : 'reveal',
|
||||
shape: darknessBrushShape,
|
||||
};
|
||||
persistDarknessStrokes([...localDarknessStrokesRef.current, stroke]);
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
darknessBrushRef.current = null;
|
||||
setDarknessDraft(null);
|
||||
darknessCursorPosRef.current = null;
|
||||
layoutDarknessBrushCursor();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<TokenPathsOverlay
|
||||
tokens={localTokens}
|
||||
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? localNpcTokens : []}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -22,6 +22,7 @@ function scene(id: string): Scene {
|
||||
previewVideoAutostart: false,
|
||||
previewRotationDeg: 0,
|
||||
darkenScene: false,
|
||||
darknessStrokes: [],
|
||||
traps: [],
|
||||
tokens: [],
|
||||
npcTokens: [],
|
||||
|
||||
@@ -43,6 +43,7 @@ function scene(id: string, title: string): Scene {
|
||||
previewVideoAutostart: false,
|
||||
previewRotationDeg: 0,
|
||||
darkenScene: false,
|
||||
darknessStrokes: [],
|
||||
traps: [],
|
||||
tokens: [],
|
||||
npcTokens: [],
|
||||
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
DisplayInfo,
|
||||
Scene,
|
||||
SceneDarknessEvent,
|
||||
SceneDarknessRevealStroke,
|
||||
SceneDarknessState,
|
||||
SceneMarkerEvent,
|
||||
SceneMarkerState,
|
||||
@@ -166,6 +167,11 @@ export const ipcChannels = {
|
||||
materialUpsertProgress: 'project.materialUpsertProgress',
|
||||
npcUpsertProgress: 'project.npcUpsertProgress',
|
||||
},
|
||||
gameSave: {
|
||||
list: 'gameSave.list',
|
||||
write: 'gameSave.write',
|
||||
isDirty: 'gameSave.isDirty',
|
||||
},
|
||||
windows: {
|
||||
openMultiWindow: 'windows.openMultiWindow',
|
||||
closeMultiWindow: 'windows.closeMultiWindow',
|
||||
@@ -787,8 +793,20 @@ export type IpcInvokeMap = {
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.windows.openMultiWindow]: {
|
||||
req: { playerIds?: string[] };
|
||||
res: { ok: true };
|
||||
req: { playerIds?: string[]; saveId?: string };
|
||||
res: { ok: true; project: Project | null };
|
||||
};
|
||||
[ipcChannels.gameSave.list]: {
|
||||
req: Record<string, never>;
|
||||
res: { saves: { id: string; name: string; savedAt: string }[] };
|
||||
};
|
||||
[ipcChannels.gameSave.write]: {
|
||||
req: { id: string | null; name: string };
|
||||
res: { save: { id: string; name: string; savedAt: string } };
|
||||
};
|
||||
[ipcChannels.gameSave.isDirty]: {
|
||||
req: Record<string, never>;
|
||||
res: { dirty: boolean };
|
||||
};
|
||||
[ipcChannels.windows.closeMultiWindow]: {
|
||||
req: Record<string, never>;
|
||||
@@ -1268,6 +1286,7 @@ export type ScenePatch = {
|
||||
previewVideoAutostart?: boolean;
|
||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
||||
darkenScene?: boolean;
|
||||
darknessStrokes?: SceneDarknessRevealStroke[];
|
||||
traps?: SceneTrap[];
|
||||
tokens?: SceneToken[];
|
||||
npcTokens?: SceneNpcToken[];
|
||||
|
||||
@@ -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