Compare commits
12 Commits
586166cacd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c0c72892d | |||
| 877fbaa8e7 | |||
| f6c301a12b | |||
| 060bbd471a | |||
| d228b9aab7 | |||
| 143d141aa6 | |||
| a29c72b307 | |||
| 712086ddca | |||
| 3d0474fe97 | |||
| 48a9c933af | |||
| 34dd39791b | |||
| fdccc0deb5 |
@@ -13,12 +13,37 @@ void test('diceRoll: roll produces values in range and notation', () => {
|
||||
store.dispatch({ kind: 'roll', count: 3, sides: 6 });
|
||||
const st = store.getState();
|
||||
assert.equal(st.active, true);
|
||||
assert.equal(st.visibleToPlayers, true);
|
||||
assert.equal(st.notation, '3d6');
|
||||
assert.equal(st.values.length, 3);
|
||||
assert.ok(st.values.every((v) => v >= 1 && v <= 6));
|
||||
assert.equal(st.total, st.values.reduce((a, b) => a + b, 0));
|
||||
});
|
||||
|
||||
void test('diceRoll: roll without visibleToPlayers keeps master result only', () => {
|
||||
const store = new DiceRollSessionStore();
|
||||
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
|
||||
store.dispatch({ kind: 'roll', count: 2, sides: 8 });
|
||||
const st = store.getState();
|
||||
assert.equal(st.visibleToPlayers, false);
|
||||
assert.equal(st.active, false);
|
||||
assert.equal(st.notation, '2d8');
|
||||
assert.equal(st.values.length, 2);
|
||||
assert.ok(st.values.every((v) => v >= 1 && v <= 8));
|
||||
assert.equal(st.startedAt, null);
|
||||
});
|
||||
|
||||
void test('diceRoll: unchecking visibleToPlayers hides overlay', () => {
|
||||
const store = new DiceRollSessionStore();
|
||||
store.dispatch({ kind: 'roll', count: 1, sides: 20 });
|
||||
assert.equal(store.getState().active, true);
|
||||
const values = [...store.getState().values];
|
||||
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
|
||||
assert.equal(store.getState().active, false);
|
||||
assert.equal(store.getState().visibleToPlayers, false);
|
||||
assert.deepEqual(store.getState().values, values);
|
||||
});
|
||||
|
||||
void test('diceRoll: d100 values are tens faces only', () => {
|
||||
const store = new DiceRollSessionStore();
|
||||
store.dispatch({ kind: 'roll', count: 5, sides: 100 });
|
||||
@@ -30,12 +55,14 @@ void test('diceRoll: d100 values are tens faces only', () => {
|
||||
assert.equal(st.total, st.values.reduce((a, b) => a + b, 0));
|
||||
});
|
||||
|
||||
void test('diceRoll: clear resets', () => {
|
||||
void test('diceRoll: clear resets but keeps visibleToPlayers', () => {
|
||||
const store = new DiceRollSessionStore();
|
||||
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
|
||||
store.dispatch({ kind: 'roll', count: 1, sides: 20 });
|
||||
store.dispatch({ kind: 'clear' });
|
||||
assert.equal(store.getState().active, false);
|
||||
assert.deepEqual(store.getState().values, []);
|
||||
assert.equal(store.getState().visibleToPlayers, false);
|
||||
});
|
||||
|
||||
void test('diceRoll: hideOverlay keeps values for master UI', () => {
|
||||
@@ -48,6 +75,16 @@ void test('diceRoll: hideOverlay keeps values for master UI', () => {
|
||||
assert.deepEqual(store.getState().values, values);
|
||||
assert.equal(store.getState().total, total);
|
||||
assert.equal(store.getState().notation, '2d6');
|
||||
assert.equal(store.getState().visibleToPlayers, true);
|
||||
});
|
||||
|
||||
void test('diceRoll: roll with zero count is no-op', () => {
|
||||
const store = new DiceRollSessionStore();
|
||||
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
|
||||
const before = store.getState().revision;
|
||||
store.dispatch({ kind: 'roll', count: 0, sides: 6 });
|
||||
assert.equal(store.getState().revision, before);
|
||||
assert.deepEqual(store.getState().values, []);
|
||||
});
|
||||
|
||||
void test('formatPredeterminedDiceNotation', () => {
|
||||
|
||||
@@ -27,6 +27,19 @@ export class DiceRollSessionStore {
|
||||
this.state = {
|
||||
...createInitialDiceRollState(),
|
||||
revision: this.state.revision + 1,
|
||||
visibleToPlayers: this.state.visibleToPlayers,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'setVisibleToPlayers': {
|
||||
if (this.state.visibleToPlayers === event.visible) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
visibleToPlayers: event.visible,
|
||||
...(event.visible === false && this.state.active
|
||||
? { active: false, startedAt: null }
|
||||
: {}),
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
@@ -43,15 +56,18 @@ export class DiceRollSessionStore {
|
||||
case 'roll': {
|
||||
if (!isDiceSides(event.sides)) return this.state;
|
||||
const count = clampDiceCount(event.count);
|
||||
if (count < 1) return this.state;
|
||||
const values = rollDiceValues(count, event.sides);
|
||||
const total = values.reduce((a, b) => a + b, 0);
|
||||
const showToPlayers = this.state.visibleToPlayers;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
active: true,
|
||||
active: showToPlayers,
|
||||
visibleToPlayers: showToPlayers,
|
||||
notation: formatDiceNotation(count, event.sides),
|
||||
values,
|
||||
total,
|
||||
startedAt: Date.now(),
|
||||
startedAt: showToPlayers ? Date.now() : null,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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 { GameSystemsStore } from './gameSystemsStore';
|
||||
import type { SystemDefinition } from '../../shared/characterSheet/types';
|
||||
import { FORMAT_VERSION, SYSTEM_FORMAT } from '../../shared/characterSheet/types';
|
||||
|
||||
function sampleSystem(): SystemDefinition {
|
||||
return {
|
||||
format: SYSTEM_FORMAT,
|
||||
formatVersion: FORMAT_VERSION,
|
||||
id: 'shadowdark',
|
||||
name: 'Shadowdark',
|
||||
schemaVersion: 1,
|
||||
revision: 1,
|
||||
bricks: { attributes: [{ key: 'str', label: 'STR', scale: 'numeric', default: 10 }] },
|
||||
creationRules: [],
|
||||
sheetLayout: { pageSize: 'fluid', grid: { size: 8 }, pages: [{ id: 'main', label: 'Main', widgets: [] }] },
|
||||
};
|
||||
}
|
||||
|
||||
test('GameSystemsStore import list delete', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gs-'));
|
||||
const store = new GameSystemsStore(dir);
|
||||
await store.importSystem(sampleSystem());
|
||||
const list = await store.list();
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(list[0]!.id, 'shadowdark');
|
||||
const bind = await store.bindSnapshot('shadowdark');
|
||||
assert.ok(bind.snapshotHash.length === 64);
|
||||
const snap = await store.getSnapshot(bind.snapshotHash);
|
||||
assert.equal(snap?.id, 'shadowdark');
|
||||
await store.delete('shadowdark');
|
||||
assert.equal((await store.list()).length, 0);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import type { GameSystemListItem } from '../../shared/ipc/contracts';
|
||||
import type { SystemDefinition } from '../../shared/characterSheet/types';
|
||||
import { validateSystemDefinition } from '../../shared/characterSheet/systemValidate';
|
||||
import { snapshotHash } from '../../shared/characterSheet/snapshot';
|
||||
|
||||
export type { GameSystemListItem };
|
||||
|
||||
type Manifest = {
|
||||
systems: GameSystemListItem[];
|
||||
};
|
||||
|
||||
export class GameSystemsStore {
|
||||
private readonly rootDir: string;
|
||||
private readonly manifestPath: string;
|
||||
private readonly snapshotsDir: string;
|
||||
private manifest: Manifest = { systems: [] };
|
||||
private loaded = false;
|
||||
|
||||
constructor(userData: string) {
|
||||
this.rootDir = path.join(userData, 'game-systems');
|
||||
this.manifestPath = path.join(this.rootDir, 'manifest.json');
|
||||
this.snapshotsDir = path.join(this.rootDir, 'snapshots');
|
||||
}
|
||||
|
||||
async ensureLoaded(): Promise<void> {
|
||||
if (this.loaded) return;
|
||||
await fs.mkdir(this.snapshotsDir, { recursive: true });
|
||||
try {
|
||||
const raw = await fs.readFile(this.manifestPath, 'utf8');
|
||||
this.manifest = JSON.parse(raw) as Manifest;
|
||||
if (!Array.isArray(this.manifest.systems)) this.manifest.systems = [];
|
||||
} catch {
|
||||
this.manifest = { systems: [] };
|
||||
}
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
async list(): Promise<GameSystemListItem[]> {
|
||||
await this.ensureLoaded();
|
||||
return [...this.manifest.systems];
|
||||
}
|
||||
|
||||
private systemPath(id: string): string {
|
||||
return path.join(this.rootDir, id, 'system.json');
|
||||
}
|
||||
|
||||
async get(id: string): Promise<SystemDefinition | null> {
|
||||
await this.ensureLoaded();
|
||||
try {
|
||||
const raw = await fs.readFile(this.systemPath(id), 'utf8');
|
||||
return JSON.parse(raw) as SystemDefinition;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async importSystem(system: SystemDefinition): Promise<GameSystemListItem> {
|
||||
await this.ensureLoaded();
|
||||
const check = validateSystemDefinition(system);
|
||||
if (!check.ok) throw new Error(check.errors.map((e) => e.message).join('; '));
|
||||
|
||||
const dir = path.join(this.rootDir, system.id);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(this.systemPath(system.id), JSON.stringify(system, null, 2), 'utf8');
|
||||
|
||||
const hash = snapshotHash(system);
|
||||
await fs.writeFile(path.join(this.snapshotsDir, `${hash}.json`), JSON.stringify(system), 'utf8');
|
||||
|
||||
const item: GameSystemListItem = {
|
||||
id: system.id,
|
||||
name: system.name,
|
||||
edition: system.edition,
|
||||
schemaVersion: system.schemaVersion,
|
||||
revision: system.revision,
|
||||
};
|
||||
this.manifest.systems = this.manifest.systems.filter((s) => s.id !== system.id).concat(item);
|
||||
await this.saveManifest();
|
||||
return item;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<string[]> {
|
||||
await this.ensureLoaded();
|
||||
this.manifest.systems = this.manifest.systems.filter((s) => s.id !== id);
|
||||
await this.saveManifest();
|
||||
try {
|
||||
await fs.rm(path.join(this.rootDir, id), { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// Caller clears player bindings; return id for convenience
|
||||
return [id];
|
||||
}
|
||||
|
||||
async getSnapshot(hash: string): Promise<SystemDefinition | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(this.snapshotsDir, `${hash}.json`), 'utf8');
|
||||
return JSON.parse(raw) as SystemDefinition;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async bindSnapshot(systemId: string): Promise<{ systemId: string; snapshotHash: string; system: SystemDefinition }> {
|
||||
const system = await this.get(systemId);
|
||||
if (!system) throw new Error(`System not found: ${systemId}`);
|
||||
const hash = snapshotHash(system);
|
||||
const snapPath = path.join(this.snapshotsDir, `${hash}.json`);
|
||||
try {
|
||||
await fs.access(snapPath);
|
||||
} catch {
|
||||
await fs.writeFile(snapPath, JSON.stringify(system), 'utf8');
|
||||
}
|
||||
return { systemId, snapshotHash: hash, system };
|
||||
}
|
||||
|
||||
private async saveManifest(): Promise<void> {
|
||||
await fs.mkdir(this.rootDir, { recursive: true });
|
||||
await fs.writeFile(this.manifestPath, JSON.stringify(this.manifest, null, 2), 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
/** Test helper — content hash of arbitrary buffer. */
|
||||
export function sha256Hex(data: string): string {
|
||||
return createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
+359
-22
@@ -1,13 +1,14 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { app, BrowserWindow, dialog, Menu, protocol } from 'electron';
|
||||
import { app, BrowserWindow, dialog, Menu, protocol, shell } from 'electron';
|
||||
|
||||
import { installStdoutEpipeGuards } from './safeConsole';
|
||||
|
||||
installStdoutEpipeGuards();
|
||||
|
||||
import { openDialogFilterLabel } from '../shared/appBranding';
|
||||
import type { SystemDefinition } from '../shared/characterSheet/types';
|
||||
import { USERS_BRANCH_FEATURES_ENABLED } from '../shared/features/usersBranchFeatures';
|
||||
import { ipcChannels, type ScenePreviewImportEvent, type SessionState } from '../shared/ipc/contracts';
|
||||
import {
|
||||
@@ -19,14 +20,19 @@ 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 {
|
||||
asPreviewRotationDeg,
|
||||
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';
|
||||
@@ -42,6 +48,7 @@ import { ZipProjectStore } from './project/zipStore';
|
||||
import { SceneViewStore } from './sceneView/sceneViewStore';
|
||||
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
|
||||
import { PlayersStore } from './players/playersStore';
|
||||
import { GameSystemsStore } from './gameSystems/gameSystemsStore';
|
||||
import { PlaylistsStore } from './playlists/playlistsStore';
|
||||
import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore';
|
||||
import { ScenePlayerTokensSessionStore } from './players/scenePlayerTokensSessionStore';
|
||||
@@ -80,6 +87,8 @@ import {
|
||||
openTokenPathEditorWindow,
|
||||
openNpcsWindow,
|
||||
openSceneDescriptionWindow,
|
||||
openInstructionsWindow,
|
||||
getInstructionsSection,
|
||||
closeMaterialsWindow,
|
||||
closePlaylistsWindow,
|
||||
closeTimerWindow,
|
||||
@@ -89,7 +98,10 @@ import {
|
||||
closeSceneEditorWindow,
|
||||
closeTokenPathEditorWindow,
|
||||
closeNpcsWindow,
|
||||
requestCloseMultiWindowConfirm,
|
||||
sendToAppWindows,
|
||||
setDiceWindowClosedListener,
|
||||
setTimerWindowClosedListener,
|
||||
syncAllWindowChromeTitles,
|
||||
togglePresentationFullscreen,
|
||||
waitForEditorWindowReady,
|
||||
@@ -196,8 +208,12 @@ 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;
|
||||
let playlistsStore: PlaylistsStore | null = null;
|
||||
|
||||
function emitTokenPathSessionState(): void {
|
||||
@@ -292,6 +308,22 @@ function emitSessionTimerState(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function dismissSessionTimerWhenWindowCloses(): void {
|
||||
const st = timerSessionStore.getState();
|
||||
let changed = false;
|
||||
if (st.running) {
|
||||
timerSessionStore.dispatch({ kind: 'pause' });
|
||||
changed = true;
|
||||
}
|
||||
if (st.visibleToPlayers) {
|
||||
timerSessionStore.dispatch({ kind: 'setVisibleToPlayers', visible: false });
|
||||
changed = true;
|
||||
}
|
||||
if (changed) emitSessionTimerState();
|
||||
}
|
||||
|
||||
setTimerWindowClosedListener(dismissSessionTimerWhenWindowCloses);
|
||||
|
||||
function emitInitiativeState(): void {
|
||||
const state = initiativeSessionStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
@@ -316,6 +348,15 @@ function emitDiceRollState(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function hideDiceOverlayWhenWindowCloses(): void {
|
||||
const st = diceRollSessionStore.getState();
|
||||
if (!st.visibleToPlayers || !st.active) return;
|
||||
diceRollSessionStore.dispatch({ kind: 'hideOverlay' });
|
||||
emitDiceRollState();
|
||||
}
|
||||
|
||||
setDiceWindowClosedListener(hideDiceOverlayWhenWindowCloses);
|
||||
|
||||
function syncInitiativePresenceFromSession(): void {
|
||||
const project = projectStore.getOpenProject();
|
||||
const sceneId = project?.currentSceneId ?? null;
|
||||
@@ -390,6 +431,18 @@ function emitTokensState(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Подтянуть `app-tokens/` из открытого кэша (экспорт сюжетной линии / полный zip). */
|
||||
async function importAppTokensFromOpenProjectCache(): Promise<Project | null> {
|
||||
const cacheDir = projectStore.getOpenCacheDir();
|
||||
if (!tokensStore || !cacheDir) return null;
|
||||
const remap = await tokensStore.importFromExportDir(cacheDir);
|
||||
if (remap.size === 0) return null;
|
||||
emitTokensState();
|
||||
const needsRemap = [...remap.entries()].some(([from, to]) => from !== to);
|
||||
if (!needsRemap) return null;
|
||||
return projectStore.updateProject((p) => remapProjectSceneTokenIds(p, remap));
|
||||
}
|
||||
|
||||
function emitSceneTokensSessionState(): void {
|
||||
const state = sceneTokensSessionStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
@@ -468,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 {
|
||||
@@ -620,6 +834,9 @@ async function main() {
|
||||
playersStore = new PlayersStore(app.getPath('userData'));
|
||||
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();
|
||||
setLicenseAssert(() => {
|
||||
@@ -637,11 +854,23 @@ async function main() {
|
||||
buildNumber: getOptionalBuildNumber(),
|
||||
packaged: app.isPackaged,
|
||||
}));
|
||||
registerHandler(ipcChannels.app.openExternal, async ({ url }) => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return { ok: false as const, error: 'Only http(s) URLs are allowed' };
|
||||
}
|
||||
await shell.openExternal(parsed.toString());
|
||||
return { ok: true as const };
|
||||
} catch (err) {
|
||||
return { ok: false as const, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
});
|
||||
registerHandler(ipcChannels.license.getStatus, () => licenseService.getStatus());
|
||||
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();
|
||||
@@ -654,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();
|
||||
@@ -679,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();
|
||||
@@ -697,6 +961,10 @@ async function main() {
|
||||
emitTokenGridSnapState();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.requestCloseMultiWindowConfirm, () => {
|
||||
requestCloseMultiWindowConfirm();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.syncChromeTitles, ({ localeTag }) => {
|
||||
syncAllWindowChromeTitles(localeTag);
|
||||
return { ok: true };
|
||||
@@ -726,6 +994,13 @@ async function main() {
|
||||
openSceneDescriptionWindow(html);
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.openInstructions, ({ section }) => {
|
||||
openInstructionsWindow(section);
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.getInstructionsSection, () => {
|
||||
return { section: getInstructionsSection() };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.closeSceneDescription, () => {
|
||||
closeSceneDescriptionWindow();
|
||||
return { ok: true };
|
||||
@@ -882,12 +1157,16 @@ async function main() {
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.open, async ({ projectId }) => {
|
||||
const project = await projectStore.openProjectById(projectId);
|
||||
let project = await projectStore.openProjectById(projectId);
|
||||
const remapped = await importAppTokensFromOpenProjectCache();
|
||||
if (remapped) project = remapped;
|
||||
sceneViewStore.reset();
|
||||
sceneTokensSessionStore.reset();
|
||||
sceneNpcTokensSessionStore.reset();
|
||||
clearNpcMapSpawnDrag();
|
||||
scenePlayerTokensSessionStore.reset();
|
||||
effectsStore.resetSession();
|
||||
lastSaveCanonical = null;
|
||||
syncSceneMarkerForProject(project);
|
||||
emitSceneViewState();
|
||||
emitSceneTokensSessionState();
|
||||
@@ -901,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();
|
||||
@@ -942,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();
|
||||
@@ -973,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();
|
||||
@@ -1007,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();
|
||||
}
|
||||
@@ -1445,7 +1730,7 @@ async function main() {
|
||||
}
|
||||
const srcPath = filePaths[0];
|
||||
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
|
||||
const project = await projectStore.importProjectFromExternalZip(srcPath, (p) => {
|
||||
let project = await projectStore.importProjectFromExternalZip(srcPath, (p) => {
|
||||
emitZipProgress({
|
||||
kind: 'import',
|
||||
stage: p.stage,
|
||||
@@ -1454,6 +1739,8 @@ async function main() {
|
||||
});
|
||||
});
|
||||
const cacheDir = projectStore.getOpenCacheDir();
|
||||
const remapped = await importAppTokensFromOpenProjectCache();
|
||||
if (remapped) project = remapped;
|
||||
if (playlistsStore && cacheDir) {
|
||||
await playlistsStore.importFromExportDir(cacheDir);
|
||||
emitPlaylistsState();
|
||||
@@ -1564,7 +1851,7 @@ async function main() {
|
||||
);
|
||||
registerHandler(ipcChannels.project.importZipFromPath, async ({ filePath }) => {
|
||||
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
|
||||
const project = await projectStore.importProjectFromExternalZip(filePath, (p) => {
|
||||
let project = await projectStore.importProjectFromExternalZip(filePath, (p) => {
|
||||
emitZipProgress({
|
||||
kind: 'import',
|
||||
stage: p.stage,
|
||||
@@ -1573,6 +1860,8 @@ async function main() {
|
||||
});
|
||||
});
|
||||
const cacheDir = projectStore.getOpenCacheDir();
|
||||
const remapped = await importAppTokensFromOpenProjectCache();
|
||||
if (remapped) project = remapped;
|
||||
if (playlistsStore && cacheDir) {
|
||||
await playlistsStore.importFromExportDir(cacheDir);
|
||||
emitPlaylistsState();
|
||||
@@ -1604,7 +1893,9 @@ async function main() {
|
||||
registerHandler(ipcChannels.project.importFoundry, async ({ sourcePath }) => {
|
||||
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт Foundry…' });
|
||||
try {
|
||||
const { project, pendingAppPlaylists } = await projectStore.importProjectFromFoundry(sourcePath, (p) => {
|
||||
const { project } = await projectStore.importProjectFromFoundry(
|
||||
sourcePath,
|
||||
(p) => {
|
||||
emitZipProgress({
|
||||
kind: 'import',
|
||||
stage:
|
||||
@@ -1612,8 +1903,9 @@ async function main() {
|
||||
percent: p.percent,
|
||||
...(p.detail ? { detail: p.detail } : null),
|
||||
});
|
||||
});
|
||||
if (playlistsStore && pendingAppPlaylists.length > 0) {
|
||||
},
|
||||
async (pendingAppPlaylists) => {
|
||||
if (!playlistsStore || pendingAppPlaylists.length === 0) return;
|
||||
for (const pl of pendingAppPlaylists) {
|
||||
const created = await playlistsStore.createPlaylist(pl.name);
|
||||
const paths = pl.tracks.map((t) => t.absPath);
|
||||
@@ -1622,7 +1914,8 @@ async function main() {
|
||||
}
|
||||
}
|
||||
emitPlaylistsState();
|
||||
}
|
||||
},
|
||||
);
|
||||
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
|
||||
emitSessionState();
|
||||
return { project };
|
||||
@@ -1926,6 +2219,50 @@ async function main() {
|
||||
return { url: playersStore.getImageUrl(id) };
|
||||
});
|
||||
|
||||
registerHandler(ipcChannels.gameSystems.list, async () => {
|
||||
if (!gameSystemsStore) return { systems: [] };
|
||||
return { systems: await gameSystemsStore.list() };
|
||||
});
|
||||
registerHandler(ipcChannels.gameSystems.importFromFile, async () => {
|
||||
if (!gameSystemsStore) throw new Error('Game systems store unavailable');
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
properties: ['openFile'],
|
||||
filters: [
|
||||
{
|
||||
name: app.getLocale().toLowerCase().startsWith('ru') ? 'Системы (JSON)' : 'Systems (JSON)',
|
||||
extensions: ['json'],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (canceled || filePaths.length === 0) return { canceled: true as const };
|
||||
const filePath = filePaths[0]!;
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as SystemDefinition;
|
||||
const system = await gameSystemsStore.importSystem(parsed);
|
||||
return { canceled: false as const, system };
|
||||
});
|
||||
registerHandler(ipcChannels.gameSystems.delete, async ({ id }) => {
|
||||
if (!gameSystemsStore) return { ok: true as const };
|
||||
await gameSystemsStore.delete(id);
|
||||
if (playersStore) {
|
||||
const cleared = await playersStore.clearSystemBindings(id);
|
||||
if (cleared > 0) emitPlayersState();
|
||||
}
|
||||
return { ok: true as const };
|
||||
});
|
||||
registerHandler(ipcChannels.gameSystems.get, async ({ id }) => {
|
||||
if (!gameSystemsStore) return { system: null };
|
||||
return { system: await gameSystemsStore.get(id) };
|
||||
});
|
||||
registerHandler(ipcChannels.gameSystems.bindSnapshot, async ({ systemId }) => {
|
||||
if (!gameSystemsStore) throw new Error('Game systems store unavailable');
|
||||
return gameSystemsStore.bindSnapshot(systemId);
|
||||
});
|
||||
registerHandler(ipcChannels.gameSystems.getSnapshot, async ({ hash }) => {
|
||||
if (!gameSystemsStore) return { system: null };
|
||||
return { system: await gameSystemsStore.getSnapshot(hash) };
|
||||
});
|
||||
|
||||
registerHandler(ipcChannels.playlists.getState, async () => {
|
||||
if (!playlistsStore) return { playlists: [], audios: [] };
|
||||
await playlistsStore.ensureLoaded();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ function channelRequiresLicense(channel: string): boolean {
|
||||
if (channel.startsWith('license.')) return false;
|
||||
if (channel.startsWith('app.')) return false;
|
||||
if (channel === ipcChannels.windows.closeMultiWindow) return false;
|
||||
if (channel === ipcChannels.windows.requestCloseMultiWindowConfirm) return false;
|
||||
if (channel === ipcChannels.windows.closeSceneDescription) return false;
|
||||
if (channel === ipcChannels.windows.closeMaterials) return false;
|
||||
if (channel === ipcChannels.windows.closeNpcs) return false;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +188,8 @@ export class PlayersStore {
|
||||
input.imageScale ?? existing?.imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE,
|
||||
),
|
||||
};
|
||||
if (existing?.systemId !== undefined) player.systemId = existing.systemId;
|
||||
if (existing?.characterSheet !== undefined) player.characterSheet = existing.characterSheet;
|
||||
|
||||
if (existing) {
|
||||
this.players = this.players.map((p) => (p.id === player.id ? player : p));
|
||||
@@ -287,6 +289,20 @@ export class PlayersStore {
|
||||
return this.getById(playerId);
|
||||
}
|
||||
|
||||
/** Clears systemId / characterSheet on players bound to a removed game system. */
|
||||
async clearSystemBindings(systemId: string): Promise<number> {
|
||||
await this.ensureLoaded();
|
||||
let changed = 0;
|
||||
this.players = this.players.map((p) => {
|
||||
const hit = p.systemId === systemId || p.characterSheet?.systemId === systemId;
|
||||
if (!hit) return p;
|
||||
changed += 1;
|
||||
return { ...p, systemId: null, characterSheet: null };
|
||||
});
|
||||
if (changed > 0) await this.persist();
|
||||
return changed;
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
await fs.mkdir(this.rootDir, { recursive: true });
|
||||
const payload: PlayersManifest = { players: this.players, teams: this.teams };
|
||||
|
||||
@@ -82,6 +82,22 @@ void test('SceneNpcTokensSessionStore setInactive is session flag', () => {
|
||||
assert.equal(s3.byPlacementId.a?.inactive, undefined);
|
||||
});
|
||||
|
||||
void test('SceneNpcTokensSessionStore hide from players keeps the token for the GM', () => {
|
||||
const store = new SceneNpcTokensSessionStore();
|
||||
const s1 = store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'a', hidden: true });
|
||||
assert.equal(s1.byPlacementId.a?.hiddenFromPlayers, true);
|
||||
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.4 });
|
||||
assert.equal(s2.byPlacementId.a?.hiddenFromPlayers, true);
|
||||
assert.equal(s2.byPlacementId.a?.nx, 0.2);
|
||||
const s3 = store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'a', hidden: false });
|
||||
assert.equal(s3.byPlacementId.a?.hiddenFromPlayers, undefined);
|
||||
assert.equal(s3.byPlacementId.a?.nx, 0.2);
|
||||
const only = store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'b', hidden: true });
|
||||
store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'b', hidden: false });
|
||||
assert.equal(store.getState().byPlacementId.b, undefined);
|
||||
assert.ok(only.byPlacementId.b?.hiddenFromPlayers);
|
||||
});
|
||||
|
||||
void test('SceneNpcTokensSessionStore setSize is session-only and survives move', () => {
|
||||
const store = new SceneNpcTokensSessionStore();
|
||||
const s1 = store.dispatch({ kind: 'setSize', placementId: 'a', sizeN: 0.2 });
|
||||
|
||||
@@ -21,7 +21,8 @@ function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
|
||||
p.ny === undefined &&
|
||||
p.sizeN === undefined &&
|
||||
!p.disposition &&
|
||||
!p.inactive
|
||||
!p.inactive &&
|
||||
!p.hiddenFromPlayers
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +33,7 @@ function restOf(prev: SceneNpcTokensSessionPlacement | undefined): SceneNpcToken
|
||||
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
|
||||
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
||||
...(prev?.inactive ? { inactive: true } : {}),
|
||||
...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -238,6 +240,7 @@ export class SceneNpcTokensSessionStore {
|
||||
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
|
||||
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
||||
...(inactive ? { inactive: true } : {}),
|
||||
...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
|
||||
};
|
||||
if (isEmptyPlacement(next)) {
|
||||
this.state = withoutPlacement(this.state, placementId);
|
||||
@@ -254,6 +257,32 @@ export class SceneNpcTokensSessionStore {
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'setHiddenFromPlayers': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
const hidden = Boolean(event.hidden);
|
||||
const prev = this.state.byPlacementId[placementId];
|
||||
if (Boolean(prev?.hiddenFromPlayers) === hidden) return this.state;
|
||||
const next: SceneNpcTokensSessionPlacement = {
|
||||
...restOf(prev),
|
||||
...(hidden ? { hiddenFromPlayers: true } : {}),
|
||||
};
|
||||
if (!hidden) delete next.hiddenFromPlayers;
|
||||
if (isEmptyPlacement(next)) {
|
||||
this.state = withoutPlacement(this.state, placementId);
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {
|
||||
...this.state.byPlacementId,
|
||||
[placementId]: next,
|
||||
},
|
||||
scale: this.state.scale,
|
||||
spawned: this.state.spawned,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'setSize': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
@@ -281,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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 { ensureParentDir } from './ensureParentDir';
|
||||
|
||||
void test('ensureParentDir: creates nested parent', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-mkdir-'));
|
||||
try {
|
||||
const file = path.join(root, 'a', 'b', 'out.ttrpg.zip');
|
||||
await ensureParentDir(file);
|
||||
const st = await fs.stat(path.dirname(file));
|
||||
assert.equal(st.isDirectory(), true);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
void test('ensureParentDir: drive root is a no-op (Windows)', async () => {
|
||||
if (process.platform !== 'win32') return;
|
||||
// Must not throw EPERM the way fs.mkdir('E:\\') often does.
|
||||
await ensureParentDir('E:\\campaign.ttrpg.zip');
|
||||
await ensureParentDir('C:\\file.ttrpg.zip');
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Создаёт родительский каталог для файла.
|
||||
* На Windows `mkdir('E:\\')` даёт EPERM — корень диска пропускаем.
|
||||
*/
|
||||
export async function ensureParentDir(filePath: string): Promise<void> {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!dir || dir === '.') return;
|
||||
const root = path.parse(path.resolve(filePath)).root;
|
||||
if (root && path.resolve(dir) === path.resolve(root)) return;
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
@@ -67,6 +67,15 @@ void test('zipStore: closeOpenProject is serialized with open on projectSwitchCh
|
||||
assert.match(src, /Открытие проекта отменено/);
|
||||
});
|
||||
|
||||
void test('zipStore: Foundry archive playlists are copied before temp unzip cleanup', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
const fn = src.slice(src.indexOf('async importProjectFromFoundry'));
|
||||
const body = fn.slice(0, fn.indexOf('\n async exportProjectZipToPath'));
|
||||
const copyAt = body.indexOf('await importPlaylists(pendingAppPlaylists)');
|
||||
const cleanupAt = body.indexOf('await loaded.cleanup()');
|
||||
assert.ok(copyAt > 0 && cleanupAt > copyAt);
|
||||
});
|
||||
|
||||
void test('zipStore: exportProjectZipToPath flushes saveNow for currently open project', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
assert.match(src, /async exportProjectZipToPath/);
|
||||
@@ -122,6 +131,33 @@ void test('main index: setCurrentGraphNode uses setSessionPointer not updateProj
|
||||
assert.doesNotMatch(m[0], /updateProject/);
|
||||
});
|
||||
|
||||
void test('main index: full zip import loads app-tokens like playlists', () => {
|
||||
const src = fs.readFileSync(path.join(here, '..', 'index.ts'), 'utf8');
|
||||
assert.match(src, /async function importAppTokensFromOpenProjectCache/);
|
||||
assert.match(src, /tokensStore\.importFromExportDir\(cacheDir\)/);
|
||||
assert.match(src, /remapProjectSceneTokenIds/);
|
||||
const importZip = src.match(
|
||||
/registerHandler\(ipcChannels\.project\.importZip,[\s\S]*?return \{ canceled: false as const, project \}/,
|
||||
);
|
||||
assert.ok(importZip);
|
||||
assert.match(importZip[0], /importAppTokensFromOpenProjectCache/);
|
||||
const importFromPath = src.match(
|
||||
/registerHandler\(ipcChannels\.project\.importZipFromPath,[\s\S]*?return \{ project \}/,
|
||||
);
|
||||
assert.ok(importFromPath);
|
||||
assert.match(importFromPath[0], /importAppTokensFromOpenProjectCache/);
|
||||
});
|
||||
|
||||
void test('zipStore: zipDir uses ensureParentDir (no mkdir of drive root)', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
assert.match(src, /from '\.\/ensureParentDir'/);
|
||||
const i = src.indexOf('async function zipDir(');
|
||||
assert.ok(i >= 0);
|
||||
const block = src.slice(i, i + 800);
|
||||
assert.match(block, /await ensureParentDir\(outZipPath\)/);
|
||||
assert.doesNotMatch(block, /fs\.mkdir\(path\.dirname\(outZipPath\)/);
|
||||
});
|
||||
|
||||
void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8');
|
||||
const i = src.indexOf('export async function replaceFileAtomic');
|
||||
|
||||
@@ -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 {
|
||||
@@ -107,11 +108,13 @@ import {
|
||||
buildProjectFromFoundryDocuments,
|
||||
loadFoundryDocumentsForImport,
|
||||
type FoundryImportProgress,
|
||||
type FoundryPendingAppPlaylist,
|
||||
} from '../foundry/foundryImport';
|
||||
import { getAppSemanticVersion } from '../versionInfo';
|
||||
|
||||
import { reconcileAssetFiles } from './assetPrune';
|
||||
import { recoverOrphanProjectZipTmpInRoot, replaceFileAtomic } from './atomicReplace';
|
||||
import { ensureParentDir } from './ensureParentDir';
|
||||
import { rmWithRetries } from './fsRetry';
|
||||
import { optimizeImageBufferVisuallyLossless } from './optimizeImageImport.lib.mjs';
|
||||
import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths';
|
||||
@@ -475,7 +478,7 @@ export class ZipProjectStore {
|
||||
const relPath = `assets/${id}_${safeOrig}`;
|
||||
const abs = path.join(open.cacheDir, relPath);
|
||||
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
await ensureParentDir(abs);
|
||||
await fs.writeFile(abs, buf);
|
||||
const asset = buildMediaAsset(id, kind0, orig, relPath, sha256, buf.length);
|
||||
|
||||
@@ -771,6 +774,7 @@ export class ZipProjectStore {
|
||||
previewVideoAutostart: false,
|
||||
previewRotationDeg: 0,
|
||||
darkenScene: false,
|
||||
darknessStrokes: [],
|
||||
traps: [],
|
||||
tokens: [],
|
||||
npcTokens: [],
|
||||
@@ -797,6 +801,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)),
|
||||
@@ -1165,7 +1172,7 @@ export class ZipProjectStore {
|
||||
const safeOrig = sanitizeFileName(orig);
|
||||
const relPath = `assets/${id}_${safeOrig}`;
|
||||
const abs = path.join(open.cacheDir, relPath);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
await ensureParentDir(abs);
|
||||
await fs.copyFile(filePath, abs);
|
||||
staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length));
|
||||
}
|
||||
@@ -1226,7 +1233,7 @@ export class ZipProjectStore {
|
||||
const safeOrig = sanitizeFileName(orig);
|
||||
const relPath = `assets/${id}_${safeOrig}`;
|
||||
const abs = path.join(open.cacheDir, relPath);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
await ensureParentDir(abs);
|
||||
await fs.copyFile(filePath, abs);
|
||||
staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length));
|
||||
}
|
||||
@@ -1316,7 +1323,7 @@ export class ZipProjectStore {
|
||||
const safeOrig = sanitizeFileName(orig);
|
||||
const relPath = `assets/${id}_${safeOrig}`;
|
||||
const abs = path.join(open.cacheDir, relPath);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
await ensureParentDir(abs);
|
||||
await fs.writeFile(abs, buf);
|
||||
stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
|
||||
nextAssetId = id;
|
||||
@@ -1551,7 +1558,7 @@ export class ZipProjectStore {
|
||||
const safeOrig = sanitizeFileName(orig);
|
||||
const relPath = `assets/${id}_${safeOrig}`;
|
||||
const abs = path.join(open.cacheDir, relPath);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
await ensureParentDir(abs);
|
||||
await fs.writeFile(abs, buf);
|
||||
stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
|
||||
nextAssetId = id;
|
||||
@@ -2038,7 +2045,7 @@ export class ZipProjectStore {
|
||||
|
||||
private async packZipFromCache(cacheDir: string, zipPath: string): Promise<void> {
|
||||
const tmpPath = `${zipPath}.tmp`;
|
||||
await fs.mkdir(path.dirname(zipPath), { recursive: true });
|
||||
await ensureParentDir(zipPath);
|
||||
await zipDir(cacheDir, tmpPath);
|
||||
const st = await fs.stat(tmpPath).catch(() => null);
|
||||
if (!st?.isFile() || st.size < 22) {
|
||||
@@ -2123,7 +2130,8 @@ export class ZipProjectStore {
|
||||
async importProjectFromFoundry(
|
||||
sourcePath: string,
|
||||
onProgress?: (p: FoundryImportProgress) => void,
|
||||
): Promise<{ project: Project; pendingAppPlaylists: import('../foundry/foundryImport').FoundryPendingAppPlaylist[] }> {
|
||||
importPlaylists?: (lists: FoundryPendingAppPlaylist[]) => Promise<void>,
|
||||
): Promise<{ project: Project; pendingAppPlaylists: FoundryPendingAppPlaylist[] }> {
|
||||
await this.ensureRoots();
|
||||
const loaded = await loadFoundryDocumentsForImport(sourcePath, onProgress);
|
||||
try {
|
||||
@@ -2153,6 +2161,10 @@ export class ZipProjectStore {
|
||||
await this.writeCacheProject(cacheDir, project);
|
||||
onProgress?.({ stage: 'zip', percent: 90, detail: 'Сборка проекта…' });
|
||||
await this.enqueuePack(cacheDir, zipPath);
|
||||
// Архив распакован во временную папку. Плейлисты читают её по absPath — копировать до cleanup.
|
||||
if (importPlaylists && pendingAppPlaylists.length > 0) {
|
||||
await importPlaylists(pendingAppPlaylists);
|
||||
}
|
||||
onProgress?.({ stage: 'done', percent: 100, detail: 'Готово' });
|
||||
return { project: this.openProject.project, pendingAppPlaylists };
|
||||
} finally {
|
||||
@@ -2178,7 +2190,7 @@ export class ZipProjectStore {
|
||||
}
|
||||
const src = path.join(getProjectsRootDir(), entry.fileName);
|
||||
const dest = path.resolve(destinationPath);
|
||||
await fs.mkdir(path.dirname(dest), { recursive: true });
|
||||
await ensureParentDir(dest);
|
||||
if (onProgress) onProgress({ stage: 'copy', percent: 1, detail: 'Копирование…' });
|
||||
await copyFileWithProgress(src, dest, (pct) => {
|
||||
if (onProgress) onProgress({ stage: 'copy', percent: pct, detail: 'Копирование…' });
|
||||
@@ -2291,7 +2303,7 @@ export class ZipProjectStore {
|
||||
if (!a) continue;
|
||||
const srcAbs = path.join(snap.cacheDir, a.relPath);
|
||||
const destAbs = path.join(exportCache, a.relPath);
|
||||
await fs.mkdir(path.dirname(destAbs), { recursive: true });
|
||||
await ensureParentDir(destAbs);
|
||||
await fs.copyFile(srcAbs, destAbs);
|
||||
if (onProgress) {
|
||||
const pct = Math.round(((i + 1) / Math.max(1, assetIds.length)) * 75);
|
||||
@@ -2428,7 +2440,7 @@ export class ZipProjectStore {
|
||||
if (!srcAsset || !tgtMeta) continue;
|
||||
const srcAbs = path.join(sourceCache, srcAsset.relPath);
|
||||
const destAbs = path.join(targetCache, tgtMeta.relPath);
|
||||
await fs.mkdir(path.dirname(destAbs), { recursive: true });
|
||||
await ensureParentDir(destAbs);
|
||||
await fs.copyFile(srcAbs, destAbs);
|
||||
if (onProgress) {
|
||||
const pct = Math.round(((i + 1) / Math.max(1, assetCopies.length)) * 90);
|
||||
@@ -2618,6 +2630,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))
|
||||
@@ -2665,6 +2680,7 @@ function normalizeScene(s: Scene): Scene {
|
||||
previewVideoAutostart,
|
||||
previewRotationDeg,
|
||||
darkenScene,
|
||||
darknessStrokes,
|
||||
traps,
|
||||
tokens,
|
||||
npcTokens,
|
||||
@@ -2894,7 +2910,7 @@ async function copyFileWithProgress(
|
||||
onPercent(100);
|
||||
return;
|
||||
}
|
||||
await fs.mkdir(path.dirname(dest), { recursive: true });
|
||||
await ensureParentDir(dest);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let done = 0;
|
||||
const rs = fssync.createReadStream(src);
|
||||
@@ -3027,7 +3043,7 @@ async function zipDir(srcDir: string, outZipPath: string): Promise<void> {
|
||||
}
|
||||
zipfile.addBuffer(buf, rel, zipOptionsForRelativeEntry(rel));
|
||||
}
|
||||
await fs.mkdir(path.dirname(outZipPath), { recursive: true });
|
||||
await ensureParentDir(outZipPath);
|
||||
const out = fssync.createWriteStream(outZipPath);
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
out.on('close', resolve);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,3 +81,16 @@ void test('clear restores defaults', () => {
|
||||
assert.equal(store.getState().visibleToPlayers, false);
|
||||
assert.equal(store.getState().running, false);
|
||||
});
|
||||
|
||||
void test('dismiss on window close: pause + hide from players', () => {
|
||||
const store = new TimerSessionStore();
|
||||
const t0 = 1_000_000;
|
||||
store.dispatch({ kind: 'setDuration', durationMs: 60_000 }, t0);
|
||||
store.dispatch({ kind: 'setVisibleToPlayers', visible: true });
|
||||
store.dispatch({ kind: 'start' }, t0);
|
||||
store.dispatch({ kind: 'pause' }, t0 + 5_000);
|
||||
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
|
||||
assert.equal(store.getState().running, false);
|
||||
assert.equal(store.getState().visibleToPlayers, false);
|
||||
assert.equal(store.getState().remainingMs, 55_000);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -52,6 +52,19 @@ void test('createWindows: окно материалов закрывается
|
||||
assert.match(src, /export function closeMultiWindow[\s\S]*closeMaterialsWindow/);
|
||||
});
|
||||
|
||||
void test('createWindows: Escape запрашивает confirm на пульте', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('export function requestCloseMultiWindowConfirm'));
|
||||
assert.ok(src.includes('closeMultiWindowConfirmRequested'));
|
||||
assert.match(src, /requestCloseMultiWindowConfirm[\s\S]*ctrl\.focus\(\)/);
|
||||
});
|
||||
|
||||
void test('createWindows: окно плейлистов — minWidth 640', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('PLAYLISTS_WINDOW_MIN_WIDTH = 640'));
|
||||
assert.match(src, /kind === 'playlists'[\s\S]*?minWidth:\s*PLAYLISTS_WINDOW_MIN_WIDTH/);
|
||||
});
|
||||
|
||||
void test('createWindows: окно таймера закрывается с multi-window', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('openTimerWindow'));
|
||||
@@ -76,6 +89,16 @@ void test('createWindows: окно кубиков закрывается с mult
|
||||
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closeDiceWindow/);
|
||||
});
|
||||
|
||||
void test('createWindows: окно инструкции из редактора, не закрывается с сессией', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('openInstructionsWindow'));
|
||||
assert.ok(src.includes("createWindow('instructions'"));
|
||||
assert.ok(src.includes("return 'instructions.html'"));
|
||||
const aux = /function closePlaySessionAuxiliaryWindows\(\): void \{([\s\S]*?)\n\}/.exec(src);
|
||||
assert.ok(aux, 'closePlaySessionAuxiliaryWindows');
|
||||
assert.doesNotMatch(aux[1] ?? '', /instructions/i);
|
||||
});
|
||||
|
||||
void test('createWindows: окно НПС закрывается с multi-window', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('openNpcsWindow'));
|
||||
@@ -89,10 +112,11 @@ void test('createWindows: окно НПС закрывается с multi-window
|
||||
assert.ok(src.includes('will-attach-webview'));
|
||||
});
|
||||
|
||||
void test('createWindows: закрытие пульта закрывает сессионные окна и презентацию', () => {
|
||||
void test('createWindows: закрытие пульта и презентации спрашивает confirm', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.match(src, /kind === 'control'/);
|
||||
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closePresentationWindow/);
|
||||
assert.match(src, /kind === 'control'[\s\S]*?e\.preventDefault\(\)[\s\S]*?requestCloseMultiWindowConfirm/);
|
||||
assert.match(src, /kind === 'presentation'[\s\S]*?e\.preventDefault\(\)[\s\S]*?requestCloseMultiWindowConfirm/);
|
||||
assert.match(src, /export function closeMultiWindow[\s\S]*closePlaySessionAuxiliaryWindows/);
|
||||
});
|
||||
|
||||
void test('createWindows: production — loadFile для HTML (не только file://)', () => {
|
||||
@@ -113,3 +137,10 @@ void test('createWindows: логи окон не валят main через EPIP
|
||||
assert.ok(src.includes('errorCode === -3'));
|
||||
assert.ok(src.includes('isMainFrame'));
|
||||
});
|
||||
|
||||
void test('createWindows: presentation/control не троттлят RAF в фоне (macOS paths)', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.match(src, /kind === 'presentation' \|\| kind === 'control'/);
|
||||
assert.match(src, /backgroundThrottling:\s*false/);
|
||||
assert.match(src, /setBackgroundThrottling\(false\)/);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { app, BrowserWindow, screen } from 'electron';
|
||||
import { app, BrowserWindow, screen, shell } from 'electron';
|
||||
|
||||
import type { PresentationSettings } from '../../shared/types/presentationSettings';
|
||||
import { readPresentationSettings } from '../preferences/appPreferences';
|
||||
@@ -27,7 +27,8 @@ export type WindowKind =
|
||||
| 'npcsEditor'
|
||||
| 'sceneEditor'
|
||||
| 'tokenPathEditor'
|
||||
| 'npcs';
|
||||
| 'npcs'
|
||||
| 'instructions';
|
||||
|
||||
/** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */
|
||||
export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [
|
||||
@@ -53,6 +54,7 @@ let appQuitting = false;
|
||||
/** Защита от каскада close(control) ↔ close(presentation). */
|
||||
let closingPlaySession = false;
|
||||
let pendingSceneDescriptionHtml = '';
|
||||
let pendingInstructionsSection = 'overview';
|
||||
|
||||
/** Окно материалов — только колонка списка. */
|
||||
const MATERIALS_WINDOW_WIDTH = 300;
|
||||
@@ -60,19 +62,38 @@ const MATERIALS_WINDOW_HEIGHT = 720;
|
||||
|
||||
/** Окно плейлистов на пульте. */
|
||||
const PLAYLISTS_WINDOW_WIDTH = 720;
|
||||
const PLAYLISTS_WINDOW_MIN_WIDTH = 640;
|
||||
const PLAYLISTS_WINDOW_HEIGHT = 800;
|
||||
|
||||
/** Компактное окно таймера сессии. */
|
||||
const TIMER_WINDOW_WIDTH = 360;
|
||||
const TIMER_WINDOW_HEIGHT = 420;
|
||||
|
||||
/** Колбэк при закрытии окна таймера (Esc / крестик / programmatic). */
|
||||
let timerWindowClosedListener: (() => void) | null = null;
|
||||
|
||||
export function setTimerWindowClosedListener(listener: (() => void) | null): void {
|
||||
timerWindowClosedListener = listener;
|
||||
}
|
||||
|
||||
/** Окно трекера инициативы. */
|
||||
const INITIATIVE_WINDOW_WIDTH = 920;
|
||||
const INITIATIVE_WINDOW_HEIGHT = 420;
|
||||
|
||||
/** Компактное окно броска кубиков. */
|
||||
const DICE_WINDOW_WIDTH = 380;
|
||||
const DICE_WINDOW_HEIGHT = 420;
|
||||
const DICE_WINDOW_HEIGHT = 460;
|
||||
|
||||
/** Окно инструкции из редактора (не модалка). */
|
||||
const INSTRUCTIONS_WINDOW_WIDTH = 960;
|
||||
const INSTRUCTIONS_WINDOW_HEIGHT = 720;
|
||||
|
||||
/** Колбэк при закрытии окна кубиков (Esc / крестик / programmatic). */
|
||||
let diceWindowClosedListener: (() => void) | null = null;
|
||||
|
||||
export function setDiceWindowClosedListener(listener: (() => void) | null): void {
|
||||
diceWindowClosedListener = listener;
|
||||
}
|
||||
|
||||
/** Редактор НПС — как основной редактор, шире инспектор. */
|
||||
const NPCS_EDITOR_WINDOW_WIDTH = 1400;
|
||||
@@ -133,6 +154,15 @@ function sendSceneDescriptionContent(win: BrowserWindow, html: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function sendInstructionsSection(win: BrowserWindow, section: string): void {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) return;
|
||||
try {
|
||||
win.webContents.send(ipcChannels.windows.instructionsSection, { section });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Разрешает реальное закрытие окна редактора (выход из приложения). */
|
||||
export function markAppQuitting(): void {
|
||||
appQuitting = true;
|
||||
@@ -190,20 +220,6 @@ function closePlaySessionAuxiliaryWindows(): void {
|
||||
closeNpcsWindow();
|
||||
}
|
||||
|
||||
function closePresentationWindow(): void {
|
||||
const pres = windows.get('presentation');
|
||||
if (pres && !pres.isDestroyed()) {
|
||||
pres.close();
|
||||
}
|
||||
}
|
||||
|
||||
function closeControlWindow(): void {
|
||||
const ctrl = windows.get('control');
|
||||
if (ctrl && !ctrl.isDestroyed()) {
|
||||
ctrl.close();
|
||||
}
|
||||
}
|
||||
|
||||
function quitAppFromEditorClose(): void {
|
||||
markAppQuitting();
|
||||
app.quit();
|
||||
@@ -251,6 +267,8 @@ function pageNameForKind(kind: WindowKind): string {
|
||||
return 'tokenPathEditor.html';
|
||||
case 'npcs':
|
||||
return 'npcs.html';
|
||||
case 'instructions':
|
||||
return 'instructions.html';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,6 +344,7 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number }
|
||||
if (kind === 'sceneEditor') return { width: 1280, height: 800 };
|
||||
if (kind === 'tokenPathEditor') return { width: 1100, height: 760 };
|
||||
if (kind === 'npcs') return { width: NPCS_WINDOW_WIDTH, height: NPCS_WINDOW_HEIGHT };
|
||||
if (kind === 'instructions') return { width: INSTRUCTIONS_WINDOW_WIDTH, height: INSTRUCTIONS_WINDOW_HEIGHT };
|
||||
return { width: 1280, height: 800 };
|
||||
}
|
||||
|
||||
@@ -352,6 +371,14 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
minHeight: 480,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'playlists'
|
||||
? {
|
||||
width: PLAYLISTS_WINDOW_WIDTH,
|
||||
height: PLAYLISTS_WINDOW_HEIGHT,
|
||||
minWidth: PLAYLISTS_WINDOW_MIN_WIDTH,
|
||||
minHeight: 480,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'npcsEditor'
|
||||
? {
|
||||
width: NPCS_EDITOR_WINDOW_WIDTH,
|
||||
@@ -385,6 +412,14 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
minHeight: 560,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'instructions'
|
||||
? {
|
||||
width: INSTRUCTIONS_WINDOW_WIDTH,
|
||||
height: INSTRUCTIONS_WINDOW_HEIGHT,
|
||||
minWidth: 640,
|
||||
minHeight: 480,
|
||||
}
|
||||
: {}),
|
||||
show: false,
|
||||
backgroundColor: '#09090B',
|
||||
...(icon ? { icon } : {}),
|
||||
@@ -398,6 +433,10 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
autoplayPolicy: 'no-user-gesture-required',
|
||||
// file:// + бандл Vite: без этого на Windows часто не грузятся чанки; http:// (dev server) оставляем строгим.
|
||||
webSecurity: Boolean(process.env.VITE_DEV_SERVER_URL),
|
||||
// macOS throttles RAF in unfocused windows — token paths and video stutter on presentation.
|
||||
...(kind === 'presentation' || kind === 'control' || kind === 'tokenPathEditor'
|
||||
? { backgroundThrottling: false }
|
||||
: {}),
|
||||
...(kind === 'npcs'
|
||||
? {
|
||||
webviewTag: true,
|
||||
@@ -416,6 +455,9 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
}
|
||||
|
||||
bindWindowChromeTitle(win, kind);
|
||||
if (kind === 'presentation' || kind === 'control' || kind === 'tokenPathEditor') {
|
||||
win.webContents.setBackgroundThrottling(false);
|
||||
}
|
||||
if (kind === 'npcs') {
|
||||
win.webContents.on('will-attach-webview', (event, guestPrefs, params) => {
|
||||
delete guestPrefs.preload;
|
||||
@@ -437,7 +479,8 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
kind === 'npcsEditor' ||
|
||||
kind === 'sceneEditor' ||
|
||||
kind === 'tokenPathEditor' ||
|
||||
kind === 'npcs'
|
||||
kind === 'npcs' ||
|
||||
kind === 'instructions'
|
||||
) {
|
||||
win.setMenuBarVisibility(false);
|
||||
}
|
||||
@@ -445,6 +488,17 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
win.webContents.on('preload-error', (_event, preloadPath, error) => {
|
||||
safeConsoleError(`[preload-error] ${preloadPath}:`, error);
|
||||
});
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
void shell.openExternal(parsed.toString());
|
||||
}
|
||||
} catch {
|
||||
/* ignore invalid urls */
|
||||
}
|
||||
return { action: 'deny' };
|
||||
});
|
||||
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||||
// -3 ERR_ABORTED: частый артефакт при navigate/maximize/закрытии — не шумим.
|
||||
if (errorCode === -3) return;
|
||||
@@ -467,23 +521,31 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
});
|
||||
}
|
||||
if (kind === 'control') {
|
||||
win.on('close', () => {
|
||||
win.on('close', (e) => {
|
||||
if (appQuitting || closingPlaySession) return;
|
||||
closingPlaySession = true;
|
||||
closePlaySessionAuxiliaryWindows();
|
||||
closePresentationWindow();
|
||||
e.preventDefault();
|
||||
requestCloseMultiWindowConfirm();
|
||||
});
|
||||
}
|
||||
if (kind === 'presentation') {
|
||||
bindPresentationContentSizeTracking(win);
|
||||
win.on('close', () => {
|
||||
win.on('close', (e) => {
|
||||
if (appQuitting || closingPlaySession) return;
|
||||
closingPlaySession = true;
|
||||
closePlaySessionAuxiliaryWindows();
|
||||
closeControlWindow();
|
||||
e.preventDefault();
|
||||
requestCloseMultiWindowConfirm();
|
||||
});
|
||||
}
|
||||
win.on('closed', () => windows.delete(kind));
|
||||
if (kind === 'timer') {
|
||||
win.on('closed', () => {
|
||||
timerWindowClosedListener?.();
|
||||
});
|
||||
}
|
||||
if (kind === 'dice') {
|
||||
win.on('closed', () => {
|
||||
diceWindowClosedListener?.();
|
||||
});
|
||||
}
|
||||
if (kind === 'sceneEditor') {
|
||||
win.on('closed', () => {
|
||||
closeTokenPathEditorWindow();
|
||||
@@ -606,6 +668,19 @@ export function closeMultiWindow(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Фокус пульта + событие: выход из показа спрашивает подтверждение на пульте. */
|
||||
export function requestCloseMultiWindowConfirm(): void {
|
||||
const ctrl = windows.get('control');
|
||||
if (!ctrl || ctrl.isDestroyed()) {
|
||||
closeMultiWindow();
|
||||
return;
|
||||
}
|
||||
if (ctrl.isMinimized()) ctrl.restore();
|
||||
ctrl.show();
|
||||
ctrl.focus();
|
||||
ctrl.webContents.send(ipcChannels.windows.closeMultiWindowConfirmRequested, {});
|
||||
}
|
||||
|
||||
export function isMultiWindowOpen(): boolean {
|
||||
return windows.has('presentation') || windows.has('control');
|
||||
}
|
||||
@@ -738,6 +813,44 @@ export function getSceneDescriptionContent(): string {
|
||||
return pendingSceneDescriptionHtml;
|
||||
}
|
||||
|
||||
export function getInstructionsSection(): string {
|
||||
return pendingInstructionsSection;
|
||||
}
|
||||
|
||||
/** Окно инструкции из редактора: переиспользовать существующее или создать новое. */
|
||||
export function openInstructionsWindow(section?: string): void {
|
||||
pendingInstructionsSection = String(section ?? '').trim() || 'overview';
|
||||
const existing = windows.get('instructions');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
sendInstructionsSection(existing, pendingInstructionsSection);
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('editor');
|
||||
const win = createWindow('instructions', parent ? { parent } : undefined);
|
||||
const { width, height } = win.getBounds();
|
||||
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||
const { x, y, width: dw, height: dh } = display.workArea;
|
||||
win.setBounds({
|
||||
x: Math.round(x + Math.max(0, (dw - width) / 2)),
|
||||
y: Math.round(y + Math.max(0, (dh - height) / 2)),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
sendInstructionsSection(win, pendingInstructionsSection);
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Одно окно описания: переиспользовать существующее или создать новое. */
|
||||
export function openSceneDescriptionWindow(html: string): void {
|
||||
pendingSceneDescriptionHtml = html;
|
||||
@@ -814,6 +927,7 @@ export function openMaterialsWindow(): void {
|
||||
export function openPlaylistsWindow(): void {
|
||||
const existing = windows.get('playlists');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
existing.setMinimumSize(PLAYLISTS_WINDOW_MIN_WIDTH, 480);
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
existing.show();
|
||||
existing.focus();
|
||||
|
||||
@@ -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));
|
||||
@@ -339,19 +383,39 @@
|
||||
|
||||
.previewHeader {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.previewTitle {
|
||||
font-size: var(--text-md);
|
||||
font-weight: 900;
|
||||
flex: 0 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.previewActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
.previewTokenTools {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
min-width: 0;
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
|
||||
.npcTokenScale {
|
||||
@@ -359,6 +423,7 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.snapToGrid {
|
||||
@@ -366,26 +431,37 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.snapToGrid input {
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.snapToGridLabel {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.npcTokenScaleLabel {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.npcTokenScaleRange {
|
||||
width: min(140px, 22vw);
|
||||
flex: 1 1 auto;
|
||||
min-width: 72px;
|
||||
width: min(140px, 100%);
|
||||
height: 34px;
|
||||
margin: 0;
|
||||
accent-color: var(--color-accent, #c9a227);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -95,6 +96,8 @@ import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx';
|
||||
|
||||
/** Длительность молнии: быстрый удар + акцент в точке попадания. */
|
||||
const LIGHTNING_EFFECT_MS = 840;
|
||||
/** Темнота/маркер на презентацию — не чаще 20 Гц (полный canvas replay в обоих окнах). */
|
||||
const OVERLAY_DRAFT_IPC_MS = 50;
|
||||
|
||||
function dispositionMakeKey(d: NpcDisposition): string {
|
||||
if (d === 'hostile') return 'npcs.makeHostile';
|
||||
@@ -201,6 +204,15 @@ export function ControlApp() {
|
||||
y: number;
|
||||
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();
|
||||
@@ -208,7 +220,7 @@ export function ControlApp() {
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const historyRef = useRef<GraphNodeId[]>([]);
|
||||
const [history, setHistory] = useState<GraphNodeId[]>([]);
|
||||
const [historyOpen, setHistoryOpen] = useState(true);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [scenesOpen, setScenesOpen] = useState(false);
|
||||
const [darknessBrushShape, setDarknessBrushShape] = useState<SceneDarknessBrushShape>('circle');
|
||||
const darknessBrushShapeRef = useRef(darknessBrushShape);
|
||||
@@ -305,6 +317,7 @@ export function ControlApp() {
|
||||
const brushCursorElRef = useRef<HTMLDivElement | null>(null);
|
||||
const cursorPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const draftPaintRafRef = useRef(0);
|
||||
const overlayDraftIpcAtRef = useRef(0);
|
||||
const effectsOverlayRef = useRef<PixiEffectsOverlayHandle | null>(null);
|
||||
const [explosionDraft, setExplosionDraft] = useState<ExplosionInstance | null>(null);
|
||||
const draftMetaRef = useRef<{ createdAtMs: number; seed: number } | null>(null);
|
||||
@@ -1073,7 +1086,7 @@ export function ControlApp() {
|
||||
/** Действия с токенами/ловушками только без активной кисти эффектов. */
|
||||
const markersInteractive = tool.tool === 'none';
|
||||
|
||||
const pathLivePoses = useTokenPathLivePoses({
|
||||
useTokenPathLivePoses({
|
||||
tokens: currentScene?.tokens ?? [],
|
||||
npcTokens: visibleNpcTokens,
|
||||
pathSession: tokenPathSession,
|
||||
@@ -1152,6 +1165,75 @@ 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, () => {
|
||||
openStopConfirm();
|
||||
});
|
||||
}, [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;
|
||||
}
|
||||
openStopConfirm();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [npcMapSpawnDrag.dragging, openStopConfirm, saveOpen, stopConfirmOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!markersInteractive) {
|
||||
setNpcSessionCtxMenu(null);
|
||||
@@ -1221,7 +1303,7 @@ export function ControlApp() {
|
||||
const cw = cr ? cr.w : ps.w;
|
||||
const ch = cr ? cr.h : ps.h;
|
||||
const minDim = Math.min(cw, ch);
|
||||
const size = Math.max(2, t.radiusN * minDim * 2);
|
||||
const size = Math.max(1, t.radiusN * minDim * 2);
|
||||
el.style.left = `${String(ox + p.x * cw)}px`;
|
||||
el.style.top = `${String(oy + p.y * ch)}px`;
|
||||
el.style.width = `${String(size)}px`;
|
||||
@@ -1387,6 +1469,9 @@ export function ControlApp() {
|
||||
draftPaintRafRef.current = 0;
|
||||
pushDraftToPixi();
|
||||
const b = brushRef.current;
|
||||
const now = performance.now();
|
||||
if (now - overlayDraftIpcAtRef.current < OVERLAY_DRAFT_IPC_MS) return;
|
||||
overlayDraftIpcAtRef.current = now;
|
||||
if (
|
||||
(b?.tool === 'exploreBrush' || b?.tool === 'closeBrush') &&
|
||||
b.points
|
||||
@@ -1930,6 +2015,22 @@ export function ControlApp() {
|
||||
</svg>
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
title={t('control.helpTool')}
|
||||
ariaLabel={t('control.helpTool')}
|
||||
data-testid="control-help-tool"
|
||||
onClick={() => {
|
||||
void api.invoke(ipcChannels.windows.openInstructions, { section: 'controlPanel' }).catch((err) => {
|
||||
console.error('[control] openInstructions failed', err);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className={styles.iconGlyph} aria-hidden>
|
||||
?
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.spacer12} />
|
||||
<>
|
||||
@@ -2098,25 +2199,43 @@ 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}>
|
||||
<div className={styles.radiusLabel}>{t('control.brushRadius')}</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.015}
|
||||
max={0.18}
|
||||
step={0.001}
|
||||
value={tool.radiusN}
|
||||
min={1}
|
||||
max={36}
|
||||
step={1}
|
||||
value={Math.max(1, Math.min(36, Math.round(tool.radiusN / 0.005)))}
|
||||
onChange={(e) => {
|
||||
const v = Number((e.currentTarget as HTMLInputElement).value);
|
||||
const next = Math.max(0.01, Math.min(0.25, Number.isFinite(v) ? v : tool.radiusN));
|
||||
const ui = Number((e.currentTarget as HTMLInputElement).value);
|
||||
const uiClamped = Math.max(1, Math.min(36, Number.isFinite(ui) ? Math.round(ui) : 1));
|
||||
const next = uiClamped * 0.005;
|
||||
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, radiusN: next } });
|
||||
}}
|
||||
className={styles.range}
|
||||
aria-label={t('control.brushRadius')}
|
||||
/>
|
||||
<div className={styles.radiusValue}>{Math.round(tool.radiusN * 100)}</div>
|
||||
<div className={styles.radiusValue}>
|
||||
{Math.max(1, Math.min(36, Math.round(tool.radiusN / 0.005)))}
|
||||
</div>
|
||||
</div>
|
||||
{tool.tool === 'exploreBrush' || tool.tool === 'closeBrush' ? (
|
||||
<div className={styles.brushShapeRow}>
|
||||
@@ -2376,6 +2495,7 @@ export function ControlApp() {
|
||||
<div className={styles.previewHeader}>
|
||||
<div className={styles.previewTitle}>{t('control.screenPreview')}</div>
|
||||
<div className={styles.previewActions}>
|
||||
<div className={styles.previewTokenTools}>
|
||||
<label
|
||||
className={styles.snapToGrid}
|
||||
title={
|
||||
@@ -2399,7 +2519,6 @@ export function ControlApp() {
|
||||
<span className={styles.snapToGridLabel}>{t('control.snapTokensToGrid')}</span>
|
||||
</label>
|
||||
{USERS_BRANCH_FEATURES_ENABLED ? (
|
||||
<>
|
||||
<label className={styles.npcTokenScale}>
|
||||
<span className={styles.npcTokenScaleLabel}>{t('control.npcTokenScale')}</span>
|
||||
<input
|
||||
@@ -2418,7 +2537,9 @@ export function ControlApp() {
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{scenePlayerTokensSession.selectedPlayerIds.length > 0 ? (
|
||||
) : null}
|
||||
</div>
|
||||
{USERS_BRANCH_FEATURES_ENABLED && scenePlayerTokensSession.selectedPlayerIds.length > 0 ? (
|
||||
<Button
|
||||
data-testid="toggle-session-players"
|
||||
onClick={() => {
|
||||
@@ -2465,10 +2586,23 @@ export function ControlApp() {
|
||||
: t('control.showPlayers')}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
<Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}>
|
||||
{t('control.stopPresentation')}
|
||||
<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>
|
||||
@@ -2477,7 +2611,7 @@ export function ControlApp() {
|
||||
<div
|
||||
ref={previewFrameRef}
|
||||
className={styles.previewFrame}
|
||||
title="Колесо — зум; перетаскивание СКМ/ПКМ или Space+ЛКМ — пан"
|
||||
title={t('control.previewPanHint')}
|
||||
>
|
||||
<div
|
||||
ref={previewHostRef}
|
||||
@@ -2512,6 +2646,7 @@ export function ControlApp() {
|
||||
<PixiEffectsOverlay
|
||||
ref={effectsOverlayRef}
|
||||
state={fxState}
|
||||
perfProfile="preview"
|
||||
style={{ zIndex: 6 }}
|
||||
viewport={
|
||||
previewContentRect
|
||||
@@ -2727,7 +2862,6 @@ export function ControlApp() {
|
||||
viewport={previewContentRect}
|
||||
editable={markersInteractive}
|
||||
snapNorm={snapNormActive}
|
||||
poseOverrides={pathLivePoses.tokenPoses}
|
||||
dragEnabledById={tokenPathDragEnabled}
|
||||
onMove={(placementId, nx, ny) => {
|
||||
const snapped = snapNormActive(nx, ny);
|
||||
@@ -2767,7 +2901,6 @@ export function ControlApp() {
|
||||
grid={currentScene?.grid ?? null}
|
||||
editable={markersInteractive}
|
||||
snapNorm={snapNormActive}
|
||||
poseOverrides={pathLivePoses.npcPoses}
|
||||
dragEnabledById={npcPathDragEnabled}
|
||||
onMove={(placementId, nx, ny) => {
|
||||
const snapped = snapNormActive(nx, ny);
|
||||
@@ -3046,7 +3179,7 @@ export function ControlApp() {
|
||||
style={{ zIndex: 48 }}
|
||||
/>
|
||||
) : null}
|
||||
<SessionTimerLayer viewport={previewContentRect} editable />
|
||||
<SessionTimerLayer viewport={markerViewport} editable />
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
@@ -3094,9 +3227,9 @@ export function ControlApp() {
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!session?.project?.currentGraphNodeId}
|
||||
onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}
|
||||
onClick={openStopConfirm}
|
||||
>
|
||||
{t('control.endPresentation')}
|
||||
{t('control.stopPresentation')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -3455,6 +3588,24 @@ export function ControlApp() {
|
||||
override?.disposition,
|
||||
);
|
||||
const inactive = Boolean(override?.inactive);
|
||||
const hiddenFromPlayers = Boolean(override?.hiddenFromPlayers);
|
||||
const hideFromPlayersItem = (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
sceneNpcTokensApi.dispatch({
|
||||
kind: 'setHiddenFromPlayers',
|
||||
placementId: npcSessionCtxMenu.placementId,
|
||||
hidden: !hiddenFromPlayers,
|
||||
});
|
||||
setNpcSessionCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
{hiddenFromPlayers ? t('npcs.showToPlayers') : t('npcs.hideFromPlayers')}
|
||||
</button>
|
||||
);
|
||||
const path = placement.path;
|
||||
const pathTarget = {
|
||||
kind: 'npcToken' as const,
|
||||
@@ -3465,6 +3616,8 @@ export function ControlApp() {
|
||||
const pathVisible = Boolean(tokenPathSession?.presentationVisible[pathKey]);
|
||||
if (inactive) {
|
||||
return (
|
||||
<>
|
||||
{hideFromPlayersItem}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
@@ -3480,6 +3633,7 @@ export function ControlApp() {
|
||||
>
|
||||
{t('npcs.makeActive')}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const isSessionSpawn = Boolean(
|
||||
@@ -3487,6 +3641,7 @@ export function ControlApp() {
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{hideFromPlayersItem}
|
||||
{isSessionSpawn ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -3662,6 +3817,140 @@ export function ControlApp() {
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
{stopConfirmOpen
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={styles.modalBackdrop}
|
||||
onClick={() => setStopConfirmOpen(false)}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog} data-testid="stop-confirm-modal">
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('control.stopConfirm.title')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={styles.modalClose}
|
||||
onClick={() => setStopConfirmOpen(false)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
{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={() => {
|
||||
if (!saveBusy) setSaveOpen(false);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</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>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,4 +32,18 @@ void test('ControlApp: draft кисти без корневого draftFxTick',
|
||||
assert.ok(pixi.includes('setDraft'));
|
||||
assert.ok(pixi.includes('mergeDraftState'));
|
||||
assert.ok(pixi.includes('forwardRef'));
|
||||
assert.ok(app.includes('perfProfile="preview"'));
|
||||
assert.ok(app.includes('OVERLAY_DRAFT_IPC_MS'));
|
||||
});
|
||||
|
||||
void test('ControlApp: path playback не гоняет setState корня', () => {
|
||||
const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
|
||||
const hook = fs.readFileSync(
|
||||
path.join(here, '../shared/tokens/useTokenPathLivePoses.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.doesNotMatch(app, /\bpathLivePoses\b/);
|
||||
assert.doesNotMatch(hook, /\bsetPoses\b/);
|
||||
assert.ok(hook.includes('applyLivePathPose'));
|
||||
assert.ok(hook.includes('requestAnimationFrame'));
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
@@ -135,16 +137,20 @@ void test('ControlApp: чекбокс привязки токенов к сет
|
||||
assert.ok(src.includes('snapAllTokensToGrid'));
|
||||
assert.ok(src.includes('snapNorm={snapNormActive}'));
|
||||
assert.ok(css.includes('.snapToGrid'));
|
||||
assert.ok(css.includes('.previewTokenTools'));
|
||||
assert.match(css, /\.previewTokenTools[\s\S]*?repeat\(auto-fit/);
|
||||
assert.match(css, /\.previewHeader[\s\S]*?flex-wrap:\s*wrap/);
|
||||
assert.match(css, /\.previewHeader[\s\S]*?align-items:\s*center/);
|
||||
});
|
||||
|
||||
void test('ControlApp: история — колонка сверху вниз и фон как у карточек ветвления', () => {
|
||||
void test('ControlApp: история — свёрнута по умолчанию, колонка сверху вниз и фон как у карточек ветвления', () => {
|
||||
const src = readControlApp();
|
||||
const css = readControlAppCss();
|
||||
const story = src.indexOf("t('control.storyLine')");
|
||||
assert.ok(story !== -1);
|
||||
assert.ok(src.includes('data-testid="control-history-accordion"'));
|
||||
assert.ok(src.includes('aria-expanded={historyOpen}'));
|
||||
assert.ok(src.includes('const [historyOpen, setHistoryOpen] = useState(true)'));
|
||||
assert.ok(src.includes('const [historyOpen, setHistoryOpen] = useState(false)'));
|
||||
assert.ok(src.includes('className={styles.storyScroll}'));
|
||||
assert.match(css, /\.storyScroll[\s\S]*?justify-content:\s*flex-start/);
|
||||
assert.match(css, /\.storyScroll[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
|
||||
@@ -312,3 +318,20 @@ void test('ControlApp: загрузка камп. аудио — useEffect за
|
||||
assert.ok(!/\bproject\b/.test(depList));
|
||||
assert.ok(!/\bcampaignAudioRefs\b/.test(depList));
|
||||
});
|
||||
|
||||
void test('ControlApp: Escape → своя модалка подтверждения завершения показа', () => {
|
||||
const src = readControlApp();
|
||||
assert.ok(src.includes('closeMultiWindowConfirmRequested'));
|
||||
assert.ok(src.includes('stopConfirmOpen'));
|
||||
assert.ok(src.includes('control.stopConfirm.title'));
|
||||
assert.ok(src.includes('data-testid="stop-confirm-modal"'));
|
||||
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, 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'));
|
||||
});
|
||||
|
||||
@@ -49,6 +49,14 @@
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px;
|
||||
background: var(--color-overlay-dark-3, rgba(0, 0, 0, 0.28));
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.countValue:focus {
|
||||
border-color: rgba(147, 197, 253, 0.75);
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -58,6 +66,15 @@
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.checkRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.actions > * {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import { DICE_SIDES, type DiceSides } from '../../shared/types/diceRoll';
|
||||
import {
|
||||
DICE_COUNT_MAX,
|
||||
DICE_COUNT_MIN,
|
||||
DICE_SIDES,
|
||||
clampDiceCount,
|
||||
type DiceSides,
|
||||
} from '../../shared/types/diceRoll';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { useDiceRollState } from '../shared/dice/useDiceRollState';
|
||||
@@ -15,6 +21,7 @@ export function DiceApp() {
|
||||
const [state, diceApi] = useDiceRollState();
|
||||
const [sides, setSides] = useState<DiceSides>(20);
|
||||
const [count, setCount] = useState(1);
|
||||
const [countDraft, setCountDraft] = useState('1');
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -26,6 +33,32 @@ export function DiceApp() {
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [api]);
|
||||
|
||||
const commitCountDraft = () => {
|
||||
const trimmed = countDraft.trim();
|
||||
if (trimmed === '') {
|
||||
setCount(DICE_COUNT_MIN);
|
||||
setCountDraft(String(DICE_COUNT_MIN));
|
||||
return;
|
||||
}
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isFinite(parsed) || parsed < DICE_COUNT_MIN) {
|
||||
setCount(DICE_COUNT_MIN);
|
||||
setCountDraft(String(DICE_COUNT_MIN));
|
||||
return;
|
||||
}
|
||||
const next = clampDiceCount(parsed);
|
||||
setCount(next);
|
||||
setCountDraft(String(next));
|
||||
};
|
||||
|
||||
const setCountClamped = (n: number) => {
|
||||
const next = clampDiceCount(n);
|
||||
setCount(next);
|
||||
setCountDraft(String(next));
|
||||
};
|
||||
|
||||
const showToPlayers = state?.visibleToPlayers ?? true;
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="dice-app">
|
||||
<div className={styles.sectionLabel}>{t('dice.sides')}</div>
|
||||
@@ -46,27 +79,80 @@ export function DiceApp() {
|
||||
<div className={styles.countRow}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setCount((c) => Math.max(1, c - 1))}
|
||||
onClick={() => setCountClamped(count - 1)}
|
||||
ariaLabel={t('dice.dec')}
|
||||
disabled={count <= DICE_COUNT_MIN}
|
||||
>
|
||||
−
|
||||
</Button>
|
||||
<div className={styles.countValue} data-testid="dice-count">
|
||||
{count}
|
||||
</div>
|
||||
<input
|
||||
className={styles.countValue}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={countDraft}
|
||||
onChange={(e) => {
|
||||
const digits = e.target.value.replace(/\D/g, '');
|
||||
if (digits === '') {
|
||||
setCountDraft('');
|
||||
return;
|
||||
}
|
||||
const n = Number(digits);
|
||||
if (!Number.isFinite(n)) {
|
||||
setCountDraft(String(DICE_COUNT_MIN));
|
||||
setCount(DICE_COUNT_MIN);
|
||||
return;
|
||||
}
|
||||
if (n > DICE_COUNT_MAX) {
|
||||
setCountDraft(String(DICE_COUNT_MAX));
|
||||
setCount(DICE_COUNT_MAX);
|
||||
return;
|
||||
}
|
||||
setCountDraft(digits);
|
||||
}}
|
||||
onBlur={() => commitCountDraft()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commitCountDraft();
|
||||
(e.currentTarget as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
aria-label={t('dice.count')}
|
||||
data-testid="dice-count"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setCount((c) => Math.min(20, c + 1))}
|
||||
onClick={() => setCountClamped(count + 1)}
|
||||
ariaLabel={t('dice.inc')}
|
||||
disabled={count >= DICE_COUNT_MAX}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<label className={styles.checkRow}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showToPlayers}
|
||||
onChange={(e) =>
|
||||
void diceApi.dispatch({ kind: 'setVisibleToPlayers', visible: e.target.checked })
|
||||
}
|
||||
data-testid="dice-show-to-players"
|
||||
/>
|
||||
<span>{t('dice.showAnimationToPlayers')}</span>
|
||||
</label>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void diceApi.dispatch({ kind: 'roll', count, sides })}
|
||||
disabled={clampDiceCount(Number(countDraft.trim()) || count) < 1}
|
||||
onClick={() => {
|
||||
const next = clampDiceCount(Number(countDraft.trim()));
|
||||
setCount(next);
|
||||
setCountDraft(String(next));
|
||||
if (next < 1) return;
|
||||
void diceApi.dispatch({ kind: 'roll', count: next, sides });
|
||||
}}
|
||||
data-testid="dice-roll"
|
||||
>
|
||||
{t('dice.roll')}
|
||||
|
||||
@@ -53,6 +53,12 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.helpMark {
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -379,6 +385,11 @@
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.fileMenuItem:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.fileMenuItemDanger {
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
@@ -519,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);
|
||||
@@ -550,113 +585,6 @@
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.instructionsDialog {
|
||||
position: fixed;
|
||||
z-index: var(--z-modal);
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(920px, calc(100vw - 32px));
|
||||
max-height: min(86vh, 720px);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-surface-elevated);
|
||||
box-shadow: var(--shadow-xl);
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.instructionsLayout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 220px;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
max-height: min(62vh, 560px);
|
||||
}
|
||||
|
||||
.instructionsContent {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--bg0);
|
||||
}
|
||||
|
||||
.instructionsContentTitle {
|
||||
font-weight: 800;
|
||||
font-size: var(--text-md);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.instructionsParagraph {
|
||||
margin: 0 0 12px;
|
||||
color: var(--text1);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.instructionsParagraph:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.instructionsInlineLink {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--accent, #a78bfa);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.instructionsInlineLink:hover {
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.instructionsNav {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
align-content: start;
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--panel2);
|
||||
}
|
||||
|
||||
.instructionsNavItem {
|
||||
text-align: left;
|
||||
padding: 9px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.instructionsNavItem:hover {
|
||||
background: var(--scene-list-hover-bg);
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.instructionsNavItemActive {
|
||||
background: rgba(167, 139, 250, 0.18);
|
||||
color: var(--text0);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.aboutBody {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -688,6 +616,13 @@
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.aboutSponsorsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.aboutLink {
|
||||
color: var(--color-accent, #a78bfa);
|
||||
text-decoration: none;
|
||||
@@ -1015,6 +950,11 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actionsRowHalf > button {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actionsRowHalf > span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
|
||||
+200
-100
@@ -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,
|
||||
@@ -37,7 +38,7 @@ import { Button, Input } from '../shared/ui/controls';
|
||||
import { LayoutShell } from '../shared/ui/LayoutShell';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import { AppAboutModal, InstructionsModal } from './about/EditorAboutModals';
|
||||
import { AppAboutModal } from './about/EditorAboutModals';
|
||||
import styles from './EditorApp.module.css';
|
||||
import { EditorAudioTrackList } from './EditorAudioTrackList';
|
||||
import {
|
||||
@@ -62,6 +63,7 @@ import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
|
||||
import { PlayersManagerModal } from './PlayersModals';
|
||||
import { LaunchPlayersModal } from './LaunchPlayersModal';
|
||||
import { PresentationSettingsModal } from './PresentationSettingsModal';
|
||||
import { GameSystemsModal } from './GameSystemsModal';
|
||||
import { PlaylistsManagerModal } from './PlaylistsModal';
|
||||
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
|
||||
import { SceneDescriptionModal } from './SceneDescriptionModal';
|
||||
@@ -129,10 +131,7 @@ export function EditorApp() {
|
||||
const [projectMenuOpen, setProjectMenuOpen] = useState(false);
|
||||
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
|
||||
const [settingsLangSubOpen, setSettingsLangSubOpen] = useState(false);
|
||||
const [aboutMenuOpen, setAboutMenuOpen] = useState(false);
|
||||
const [appAboutOpen, setAppAboutOpen] = useState(false);
|
||||
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
||||
const [instructionsSection, setInstructionsSection] = useState<HelpSectionId>('overview');
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
const [importSourceOpen, setImportSourceOpen] = useState(false);
|
||||
@@ -164,12 +163,34 @@ 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);
|
||||
const [runMenuOpen, setRunMenuOpen] = useState(false);
|
||||
const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const runSplitRef = useRef<HTMLDivElement | null>(null);
|
||||
const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(null);
|
||||
|
||||
const listGameSystems = useCallback(async () => {
|
||||
const res = await getDndApi().invoke(ipcChannels.gameSystems.list, {});
|
||||
return res.systems;
|
||||
}, []);
|
||||
const importGameSystem = useCallback(async () => {
|
||||
const res = await getDndApi().invoke(ipcChannels.gameSystems.importFromFile, {});
|
||||
return res.canceled ? null : res.system;
|
||||
}, []);
|
||||
const deleteGameSystem = useCallback(async (id: string) => {
|
||||
await getDndApi().invoke(ipcChannels.gameSystems.delete, { id });
|
||||
}, []);
|
||||
const previewGameSystem = useCallback(async (id: string) => {
|
||||
console.log('[gameSystems] preview stub', id);
|
||||
window.alert(`Preview (stub): ${id}`);
|
||||
}, []);
|
||||
|
||||
const onProjectNotice = useCallback(
|
||||
(code: ProjectNoticeCode) => {
|
||||
const handlers: Record<ProjectNoticeCode, () => void> = {
|
||||
@@ -262,11 +283,9 @@ export function EditorApp() {
|
||||
const fileMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||
const projectMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||
const settingsMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||
const aboutMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [fileMenuPos, setFileMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const [projectMenuPos, setProjectMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const [settingsMenuPos, setSettingsMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const [aboutMenuPos, setAboutMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const [selectedGraphNodeId, setSelectedGraphNodeId] = useState<GraphNodeId | null>(null);
|
||||
const [sceneListDrop, setSceneListDrop] = useState<{
|
||||
targetId: SceneId;
|
||||
@@ -365,6 +384,55 @@ export function EditorApp() {
|
||||
const runHelpSection: HelpSectionId = !licenseActive ? 'license' : 'graph';
|
||||
const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene');
|
||||
|
||||
const openInstructions = useCallback((section: HelpSectionId = 'overview') => {
|
||||
void getDndApi()
|
||||
.invoke(ipcChannels.windows.openInstructions, { section })
|
||||
.catch((err) => {
|
||||
console.error('[editor] openInstructions failed', err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
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;
|
||||
@@ -464,26 +532,6 @@ export function EditorApp() {
|
||||
if (!settingsMenuOpen) setSettingsLangSubOpen(false);
|
||||
}, [settingsMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!aboutMenuOpen) return;
|
||||
const r = aboutMenuBtnRef.current?.getBoundingClientRect() ?? null;
|
||||
queueMicrotask(() => {
|
||||
if (r) {
|
||||
setAboutMenuPos({ left: r.left, top: r.bottom + 10 });
|
||||
} else {
|
||||
setAboutMenuPos(null);
|
||||
}
|
||||
});
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (!t) return;
|
||||
if (t.closest('[data-aboutmenu-root="1"]')) return;
|
||||
setAboutMenuOpen(false);
|
||||
};
|
||||
window.addEventListener('mousedown', onDown);
|
||||
return () => window.removeEventListener('mousedown', onDown);
|
||||
}, [aboutMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runMenuOpen) return;
|
||||
const r = runSplitRef.current?.getBoundingClientRect() ?? null;
|
||||
@@ -793,7 +841,6 @@ export function EditorApp() {
|
||||
onClick={() => {
|
||||
setFileMenuOpen(false);
|
||||
setProjectMenuOpen(false);
|
||||
setAboutMenuOpen(false);
|
||||
setSettingsMenuOpen((v) => {
|
||||
const next = !v;
|
||||
if (next) setSettingsLangSubOpen(false);
|
||||
@@ -804,16 +851,14 @@ export function EditorApp() {
|
||||
{t('top.settings')}
|
||||
</button>
|
||||
<button
|
||||
ref={aboutMenuBtnRef}
|
||||
type="button"
|
||||
data-aboutmenu-root="1"
|
||||
className={styles.fileMenuTrigger}
|
||||
onClick={() => {
|
||||
setFileMenuOpen(false);
|
||||
setProjectMenuOpen(false);
|
||||
setSettingsMenuOpen(false);
|
||||
setSettingsLangSubOpen(false);
|
||||
setAboutMenuOpen((v) => !v);
|
||||
setAppAboutOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('top.aboutApp')}
|
||||
@@ -829,7 +874,6 @@ export function EditorApp() {
|
||||
if (!licenseActive) return;
|
||||
setFileMenuOpen(false);
|
||||
setSettingsMenuOpen(false);
|
||||
setAboutMenuOpen(false);
|
||||
setProjectMenuOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
@@ -847,7 +891,6 @@ export function EditorApp() {
|
||||
if (!licenseActive) return;
|
||||
setProjectMenuOpen(false);
|
||||
setSettingsMenuOpen(false);
|
||||
setAboutMenuOpen(false);
|
||||
setFileMenuOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
@@ -865,7 +908,6 @@ export function EditorApp() {
|
||||
if (!licenseActive) return;
|
||||
setProjectMenuOpen(false);
|
||||
setSettingsMenuOpen(false);
|
||||
setAboutMenuOpen(false);
|
||||
setFileMenuOpen(false);
|
||||
setPlayersManagerOpen(true);
|
||||
}}
|
||||
@@ -883,7 +925,6 @@ export function EditorApp() {
|
||||
if (!licenseActive) return;
|
||||
setProjectMenuOpen(false);
|
||||
setSettingsMenuOpen(false);
|
||||
setAboutMenuOpen(false);
|
||||
setFileMenuOpen(false);
|
||||
setPlaylistsManagerOpen(true);
|
||||
}}
|
||||
@@ -897,6 +938,19 @@ export function EditorApp() {
|
||||
{appVersionText}
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
tooltipPlacement="bottom"
|
||||
title={t('control.helpTool')}
|
||||
ariaLabel={t('control.helpTool')}
|
||||
data-testid="editor-help-tool"
|
||||
onClick={() => openInstructions('overview')}
|
||||
>
|
||||
<span className={styles.helpMark} aria-hidden>
|
||||
?
|
||||
</span>
|
||||
</Button>
|
||||
<div className={styles.headerActions}>
|
||||
{state.project ? (
|
||||
<>
|
||||
@@ -935,6 +989,7 @@ export function EditorApp() {
|
||||
data-testid="run-menu-btn"
|
||||
onClick={() => {
|
||||
if (runDisabled || launching) return;
|
||||
refreshGameSaves();
|
||||
setRunMenuOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
@@ -971,8 +1026,7 @@ export function EditorApp() {
|
||||
title={runHelpTooltip}
|
||||
tooltipPlacement="bottom-left"
|
||||
onClick={() => {
|
||||
setInstructionsSection(runHelpSection);
|
||||
setInstructionsOpen(true);
|
||||
openInstructions(runHelpSection);
|
||||
}}
|
||||
>
|
||||
?
|
||||
@@ -1212,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}
|
||||
@@ -1226,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 })
|
||||
@@ -1360,6 +1412,18 @@ export function EditorApp() {
|
||||
>
|
||||
{t('menu.presentationSettings')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.fileMenuItem}
|
||||
onClick={() => {
|
||||
setSettingsMenuOpen(false);
|
||||
setSettingsLangSubOpen(false);
|
||||
setGameSystemsOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('menu.gameSystems')}
|
||||
</button>
|
||||
<div className={styles.fileMenuSubHost} role="presentation">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1441,46 +1505,6 @@ export function EditorApp() {
|
||||
snapshot={licenseSnap}
|
||||
/>
|
||||
<AppAboutModal open={appAboutOpen} onClose={() => setAppAboutOpen(false)} appVersion={appVersionText} />
|
||||
<InstructionsModal
|
||||
open={instructionsOpen}
|
||||
initialSection={instructionsSection}
|
||||
onClose={() => setInstructionsOpen(false)}
|
||||
/>
|
||||
{aboutMenuOpen && aboutMenuPos
|
||||
? createPortal(
|
||||
<div
|
||||
role="menu"
|
||||
data-aboutmenu-root="1"
|
||||
className={styles.fileMenu}
|
||||
style={{ left: aboutMenuPos.left, top: aboutMenuPos.top }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.fileMenuItem}
|
||||
onClick={() => {
|
||||
setAboutMenuOpen(false);
|
||||
setAppAboutOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('menu.aboutProgram')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.fileMenuItem}
|
||||
onClick={() => {
|
||||
setAboutMenuOpen(false);
|
||||
setInstructionsSection('overview');
|
||||
setInstructionsOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('menu.instructions')}
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
{projectMenuOpen && projectMenuPos
|
||||
? createPortal(
|
||||
<div
|
||||
@@ -1682,6 +1706,22 @@ export function EditorApp() {
|
||||
open={presentationSettingsOpen}
|
||||
onClose={() => setPresentationSettingsOpen(false)}
|
||||
/>
|
||||
<GameSystemsModal
|
||||
open={gameSystemsOpen}
|
||||
onClose={() => setGameSystemsOpen(false)}
|
||||
labels={{
|
||||
title: t('gameSystems.title'),
|
||||
import: t('gameSystems.import'),
|
||||
preview: t('gameSystems.preview'),
|
||||
remove: t('gameSystems.remove'),
|
||||
empty: t('gameSystems.empty'),
|
||||
close: t('gameSystems.close'),
|
||||
}}
|
||||
listSystems={listGameSystems}
|
||||
importSystem={importGameSystem}
|
||||
deleteSystem={deleteGameSystem}
|
||||
previewSystem={previewGameSystem}
|
||||
/>
|
||||
<PlaylistsManagerModal
|
||||
open={playlistsManagerOpen}
|
||||
onClose={() => setPlaylistsManagerOpen(false)}
|
||||
@@ -1697,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
|
||||
@@ -1719,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,
|
||||
)
|
||||
@@ -2499,7 +2618,6 @@ type SceneInspectorProps = {
|
||||
previewVideoAutostart: boolean;
|
||||
previewVideoLoop: boolean;
|
||||
previewRotationDeg: 0 | 90 | 180 | 270;
|
||||
darkenScene: boolean;
|
||||
previewBusy: boolean;
|
||||
previewBusyText: string;
|
||||
mediaAssets: MediaAsset[];
|
||||
@@ -2507,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;
|
||||
@@ -2580,7 +2697,6 @@ function SceneInspector({
|
||||
previewVideoAutostart,
|
||||
previewVideoLoop,
|
||||
previewRotationDeg,
|
||||
darkenScene,
|
||||
previewBusy,
|
||||
previewBusyText,
|
||||
mediaAssets,
|
||||
@@ -2588,7 +2704,6 @@ function SceneInspector({
|
||||
onAudioRefsChange,
|
||||
onPreviewVideoAutostartChange,
|
||||
onPreviewVideoLoopChange,
|
||||
onDarkenSceneChange,
|
||||
onTitleChange,
|
||||
onDescriptionChange,
|
||||
onImportPreview,
|
||||
@@ -2742,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;
|
||||
@@ -2752,28 +2866,14 @@ 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, {});
|
||||
}}
|
||||
>
|
||||
Редактор сцены
|
||||
{t('sceneEditor.open')}
|
||||
</Button>
|
||||
</>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.spacer6} />
|
||||
<div className={styles.labelSm}>{t('scene.audio')}</div>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { GameSystemListItem } from '../../shared/ipc/contracts';
|
||||
|
||||
export type { GameSystemListItem };
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
labels: {
|
||||
title: string;
|
||||
import: string;
|
||||
preview: string;
|
||||
remove: string;
|
||||
empty: string;
|
||||
close: string;
|
||||
};
|
||||
listSystems: () => Promise<GameSystemListItem[]>;
|
||||
importSystem: () => Promise<GameSystemListItem | null>;
|
||||
deleteSystem: (id: string) => Promise<void>;
|
||||
previewSystem: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function GameSystemsModal({
|
||||
open,
|
||||
onClose,
|
||||
labels,
|
||||
listSystems,
|
||||
importSystem,
|
||||
deleteSystem,
|
||||
previewSystem,
|
||||
}: Props) {
|
||||
const [items, setItems] = useState<GameSystemListItem[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void (async () => {
|
||||
setItems(await listSystems());
|
||||
})();
|
||||
}, [open, listSystems]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="game-systems-modal"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
zIndex: 10000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 560,
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
background: 'rgba(10,10,14,0.98)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
color: 'rgba(255,255,255,0.92)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<strong>{labels.title}</strong>
|
||||
<button type="button" data-testid="game-systems-close" onClick={onClose}>
|
||||
{labels.close}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="game-systems-import"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await importSystem();
|
||||
setItems(await listSystems());
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{labels.import}
|
||||
</button>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<div style={{ opacity: 0.6 }}>{labels.empty}</div>
|
||||
) : (
|
||||
items.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
data-testid={`game-system-${s.id}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
padding: '10px 0',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div>{s.name}</div>
|
||||
<div style={{ opacity: 0.55, fontSize: 12 }}>
|
||||
{s.id} · r{s.revision}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" onClick={() => void previewSystem(s.id)}>
|
||||
{labels.preview}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await deleteSystem(s.id);
|
||||
setItems(await listSystems());
|
||||
}}
|
||||
>
|
||||
{labels.remove}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,24 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { APP_DISPLAY_NAME_EN, APP_DISPLAY_NAME_RU } from '../../../shared/appBranding';
|
||||
import { Button } from '../../shared/ui/controls';
|
||||
import styles from '../EditorApp.module.css';
|
||||
import { buildHelpLinkCatalog, splitHelpTextWithLinks } from '../help/helpLinkify';
|
||||
import {
|
||||
HELP_SECTION_IDS,
|
||||
helpSectionBodyKey,
|
||||
helpSectionTitleKey,
|
||||
type HelpSectionId,
|
||||
} from '../help/helpSections';
|
||||
import { useEditorI18n } from '../i18n/EditorI18nContext';
|
||||
|
||||
const ABOUT_SPONSORS = [
|
||||
{ kind: 'text', name: 'Тифлинг бард 😈❤️' },
|
||||
{
|
||||
kind: 'link',
|
||||
name: 'Клуб настольных и бизнес-игр "КвестИя" - Подчуфарова Евгения Александровна',
|
||||
url: 'https://vk.ru/club210361604',
|
||||
},
|
||||
{ kind: 'link', name: 'Старые кости', url: 'https://boosty.to/tabletoppodcasts' },
|
||||
{ kind: 'text', name: 'Майер С.В. (Клуб «Серый Май»)' },
|
||||
{ kind: 'link', name: 'true_rus(КАВ)', url: 'https://truecorp.su/' },
|
||||
{ kind: 'text', name: 'Макс LonelyFlame Чурсин' },
|
||||
] as const;
|
||||
|
||||
type AppAboutModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -80,123 +86,25 @@ export function AppAboutModal({ open, onClose, appVersion }: AppAboutModalProps)
|
||||
{t('app.about.websiteUrl')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type InstructionsModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
initialSection?: HelpSectionId;
|
||||
};
|
||||
|
||||
function InstructionsModalBody({
|
||||
initialSection,
|
||||
onClose,
|
||||
}: {
|
||||
initialSection: HelpSectionId;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
const [activeId, setActiveId] = useState<HelpSectionId>(initialSection);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const navRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
contentRef.current?.scrollTo({ top: 0 });
|
||||
const activeNav = navRef.current?.querySelector<HTMLElement>('[aria-current="true"]');
|
||||
activeNav?.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeId]);
|
||||
|
||||
const linkCatalog = useMemo(
|
||||
() => buildHelpLinkCatalog((id) => t(helpSectionTitleKey(id))),
|
||||
[t],
|
||||
);
|
||||
|
||||
const body = t(helpSectionBodyKey(activeId));
|
||||
const paragraphs = body.split('\n\n').filter((p) => p.trim() !== '');
|
||||
|
||||
const goToSection = (id: HelpSectionId) => {
|
||||
setActiveId(id);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.instructionsDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('help.title')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
<div className={styles.fieldLabel}>{t('app.about.sponsorsLabel')}</div>
|
||||
<div className={styles.aboutSponsorsList}>
|
||||
{ABOUT_SPONSORS.map((sponsor) =>
|
||||
sponsor.kind === 'link' ? (
|
||||
<a
|
||||
key={sponsor.name}
|
||||
className={styles.aboutLink}
|
||||
href={sponsor.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.instructionsLayout}>
|
||||
<div ref={contentRef} className={styles.instructionsContent}>
|
||||
<div className={styles.instructionsContentTitle}>{t(helpSectionTitleKey(activeId))}</div>
|
||||
{paragraphs.map((p, i) => (
|
||||
<p key={i} className={styles.instructionsParagraph}>
|
||||
{splitHelpTextWithLinks(p, linkCatalog).map((part, j) =>
|
||||
part.type === 'text' ? (
|
||||
<React.Fragment key={j}>{part.value}</React.Fragment>
|
||||
{sponsor.name}
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
key={j}
|
||||
type="button"
|
||||
className={styles.instructionsInlineLink}
|
||||
onClick={() => goToSection(part.id)}
|
||||
>
|
||||
{part.value}
|
||||
</button>
|
||||
<div key={sponsor.name}>{sponsor.name}</div>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<nav ref={navRef} className={styles.instructionsNav} aria-label={t('help.navAria')}>
|
||||
{HELP_SECTION_IDS.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={[
|
||||
styles.instructionsNavItem,
|
||||
id === activeId ? styles.instructionsNavItemActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-current={id === activeId ? 'true' : undefined}
|
||||
onClick={() => goToSection(id)}
|
||||
>
|
||||
{t(helpSectionTitleKey(id))}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
@@ -208,9 +116,3 @@ function InstructionsModalBody({
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function InstructionsModal({ open, onClose, initialSection }: InstructionsModalProps) {
|
||||
if (!open) return null;
|
||||
const section = initialSection ?? 'overview';
|
||||
return <InstructionsModalBody key={section} initialSection={section} onClose={onClose} />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { HelpSectionId } from './helpSections';
|
||||
import { parseHelpSectionId, type HelpSectionId } from './helpSections';
|
||||
import { buildHelpLinkCatalog, findHelpLinkRanges, splitHelpTextWithLinks } from './helpLinkify';
|
||||
|
||||
const RU_TITLES: Record<HelpSectionId, string> = {
|
||||
@@ -80,3 +80,9 @@ void test('findHelpLinkRanges: полное «Пульт управления»
|
||||
assert.equal(ranges[0]?.id, 'controlPanel');
|
||||
assert.equal(ranges[0]?.text, '«Пульт управления»');
|
||||
});
|
||||
|
||||
void test('parseHelpSectionId: unknown falls back to overview', () => {
|
||||
assert.equal(parseHelpSectionId('grid'), 'grid');
|
||||
assert.equal(parseHelpSectionId(''), 'overview');
|
||||
assert.equal(parseHelpSectionId('not-a-section'), 'overview');
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -40,3 +40,9 @@ export function helpSectionTitleKey(id: HelpSectionId): string {
|
||||
export function helpSectionBodyKey(id: HelpSectionId): string {
|
||||
return `help.section.${id}.body`;
|
||||
}
|
||||
|
||||
export function parseHelpSectionId(raw: unknown): HelpSectionId {
|
||||
const s = String(raw ?? '').trim();
|
||||
if ((HELP_SECTION_IDS as readonly string[]).includes(s)) return s as HelpSectionId;
|
||||
return 'overview';
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||
<title>TTRPG</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/instructions/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,98 @@
|
||||
.page {
|
||||
height: 100vh;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(160px, 220px);
|
||||
gap: 14px;
|
||||
background: var(--color-surface-elevated);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--bg0);
|
||||
}
|
||||
|
||||
.contentTitle {
|
||||
font-weight: 800;
|
||||
font-size: var(--text-md);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
margin: 0 0 12px;
|
||||
color: var(--text1);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.paragraph:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.inlineLink {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--accent, #a78bfa);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inlineLink:hover {
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.nav {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
align-content: start;
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--panel2);
|
||||
}
|
||||
|
||||
.navItem {
|
||||
text-align: left;
|
||||
padding: 9px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.navItem:hover {
|
||||
background: var(--scene-list-hover-bg);
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.navItemActive {
|
||||
background: rgba(167, 139, 250, 0.18);
|
||||
color: var(--text0);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(0, 1fr) minmax(120px, 32vh);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import { buildHelpLinkCatalog, splitHelpTextWithLinks } from '../editor/help/helpLinkify';
|
||||
import {
|
||||
HELP_SECTION_IDS,
|
||||
helpSectionBodyKey,
|
||||
helpSectionTitleKey,
|
||||
parseHelpSectionId,
|
||||
type HelpSectionId,
|
||||
} from '../editor/help/helpSections';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
|
||||
import styles from './InstructionsApp.module.css';
|
||||
|
||||
function InstructionsPanel({ initialSection }: { initialSection: HelpSectionId }) {
|
||||
const { t } = useEditorI18n();
|
||||
const [activeId, setActiveId] = useState<HelpSectionId>(initialSection);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const navRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveId(initialSection);
|
||||
}, [initialSection]);
|
||||
|
||||
useEffect(() => {
|
||||
contentRef.current?.scrollTo({ top: 0 });
|
||||
const activeNav = navRef.current?.querySelector<HTMLElement>('[aria-current="true"]');
|
||||
activeNav?.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeId]);
|
||||
|
||||
const linkCatalog = useMemo(
|
||||
() => buildHelpLinkCatalog((id) => t(helpSectionTitleKey(id))),
|
||||
[t],
|
||||
);
|
||||
|
||||
const body = t(helpSectionBodyKey(activeId));
|
||||
const paragraphs = body.split('\n\n').filter((p) => p.trim() !== '');
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="instructions-app">
|
||||
<div ref={contentRef} className={styles.content}>
|
||||
<div className={styles.contentTitle}>{t(helpSectionTitleKey(activeId))}</div>
|
||||
{paragraphs.map((p, i) => (
|
||||
<p key={i} className={styles.paragraph}>
|
||||
{splitHelpTextWithLinks(p, linkCatalog).map((part, j) =>
|
||||
part.type === 'text' ? (
|
||||
<React.Fragment key={j}>{part.value}</React.Fragment>
|
||||
) : (
|
||||
<button
|
||||
key={j}
|
||||
type="button"
|
||||
className={styles.inlineLink}
|
||||
onClick={() => setActiveId(part.id)}
|
||||
>
|
||||
{part.value}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<nav ref={navRef} className={styles.nav} aria-label={t('help.navAria')}>
|
||||
{HELP_SECTION_IDS.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={[styles.navItem, id === activeId ? styles.navItemActive : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-current={id === activeId ? 'true' : undefined}
|
||||
onClick={() => setActiveId(id)}
|
||||
>
|
||||
{t(helpSectionTitleKey(id))}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InstructionsApp() {
|
||||
const [section, setSection] = useState<HelpSectionId>('overview');
|
||||
|
||||
useEffect(() => {
|
||||
const api = getDndApi();
|
||||
void api.invoke(ipcChannels.windows.getInstructionsSection, {}).then(({ section: next }) => {
|
||||
setSection(parseHelpSectionId(next));
|
||||
});
|
||||
return api.on(ipcChannels.windows.instructionsSection, ({ section: next }) => {
|
||||
setSection(parseHelpSectionId(next));
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') window.close();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
return <InstructionsPanel initialSection={section} />;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||
|
||||
import { InstructionsApp } from './InstructionsApp';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) {
|
||||
throw new Error('Missing #root element');
|
||||
}
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<WindowErrorBoundary title="Instructions">
|
||||
<EditorI18nProvider>
|
||||
<InstructionsApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
void test('PresentationApp: Escape запрашивает confirm, не закрывает сразу', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'PresentationApp.tsx'), 'utf8');
|
||||
assert.ok(src.includes('requestCloseMultiWindowConfirm'));
|
||||
assert.match(
|
||||
src,
|
||||
/e\.key === 'Escape'[\s\S]*requestCloseMultiWindowConfirm/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
src,
|
||||
/e\.key === 'Escape'[\s\S]*closeMultiWindow/,
|
||||
);
|
||||
});
|
||||
@@ -23,7 +23,8 @@ export function PresentationApp() {
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
void api.invoke(ipcChannels.windows.closeMultiWindow, {});
|
||||
e.preventDefault();
|
||||
void api.invoke(ipcChannels.windows.requestCloseMultiWindowConfirm, {});
|
||||
return;
|
||||
}
|
||||
if (e.altKey && (e.key === 'Enter' || e.code === 'Enter' || e.code === 'NumpadEnter')) {
|
||||
|
||||
@@ -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,
|
||||
@@ -38,13 +45,11 @@ import {
|
||||
SCENE_GRID_SIZE_MAX,
|
||||
SCENE_GRID_SIZE_MIN,
|
||||
sceneGridTokenFitFactor,
|
||||
sceneGridTypeLabelRu,
|
||||
} from '../../shared/types/sceneGrid';
|
||||
import {
|
||||
asSceneTrapId,
|
||||
DEFAULT_SCENE_TRAP_SIZE_N,
|
||||
SCENE_TRAP_TYPES,
|
||||
trapTypeLabelRu,
|
||||
} from '../../shared/types/sceneTraps';
|
||||
import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
|
||||
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
|
||||
@@ -239,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);
|
||||
@@ -272,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<
|
||||
@@ -293,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;
|
||||
@@ -318,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;
|
||||
@@ -326,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]);
|
||||
@@ -358,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 });
|
||||
@@ -406,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;
|
||||
@@ -422,8 +592,8 @@ export function SceneEditorApp() {
|
||||
|
||||
const defaultTemplateName = useMemo(() => {
|
||||
const sizeLabel = Math.round(localGrid.sizeN * 100);
|
||||
return `${sceneGridTypeLabelRu(localGrid.type)} · ${String(sizeLabel)}`;
|
||||
}, [localGrid.sizeN, localGrid.type]);
|
||||
return `${t(localGrid.type === 'hex' ? 'sceneEditor.gridHex' : 'sceneEditor.gridSquare')} · ${String(sizeLabel)}`;
|
||||
}, [localGrid.sizeN, localGrid.type, t]);
|
||||
|
||||
const saveGridTemplate = async () => {
|
||||
if (!sceneId) return;
|
||||
@@ -506,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(() => {
|
||||
@@ -634,21 +832,32 @@ 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}>
|
||||
<EllipsisText
|
||||
text={scene?.title ?? 'Сцена'}
|
||||
text={scene?.title ?? t('sceneEditor.untitledScene')}
|
||||
className={[styles.sideTitle, ellipsisStyles.root].join(' ')}
|
||||
/>
|
||||
<div className={styles.hint}>
|
||||
Колесо — зум. СКМ / Space+ЛКМ — пан. Delete — удалить выбранное.
|
||||
{t('sceneEditor.mapHint')}
|
||||
</div>
|
||||
|
||||
<div className={styles.accordionScroll}>
|
||||
<div className={styles.accordion}>
|
||||
<button type="button" className={styles.accordionHead} onClick={() => setGridOpen((v) => !v)}>
|
||||
Сетка {gridOpen ? '▾' : '▸'}
|
||||
{t('sceneEditor.grid')} {gridOpen ? '▾' : '▸'}
|
||||
</button>
|
||||
{gridOpen ? (
|
||||
<div className={styles.gridPanel}>
|
||||
@@ -658,17 +867,17 @@ export function SceneEditorApp() {
|
||||
checked={localGrid.enabled}
|
||||
onChange={(e) => persistGrid({ ...localGrid, enabled: e.target.checked })}
|
||||
/>
|
||||
<span>Наложить сетку</span>
|
||||
<span>{t('sceneEditor.enableGrid')}</span>
|
||||
</label>
|
||||
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
|
||||
<span className={styles.fieldLabel}>Тип</span>
|
||||
<span className={styles.fieldLabel}>{t('sceneEditor.gridType')}</span>
|
||||
<Select
|
||||
disabled={!localGrid.enabled}
|
||||
value={localGrid.type}
|
||||
ariaLabel="Тип сетки"
|
||||
ariaLabel={t('sceneEditor.gridTypeAria')}
|
||||
options={[
|
||||
{ value: 'square', label: sceneGridTypeLabelRu('square') },
|
||||
{ value: 'hex', label: sceneGridTypeLabelRu('hex') },
|
||||
{ value: 'square', label: t('sceneEditor.gridSquare') },
|
||||
{ value: 'hex', label: t('sceneEditor.gridHex') },
|
||||
]}
|
||||
onChange={(next) =>
|
||||
persistGrid({
|
||||
@@ -679,19 +888,19 @@ export function SceneEditorApp() {
|
||||
/>
|
||||
</label>
|
||||
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
|
||||
<span className={styles.fieldLabel}>Цвет</span>
|
||||
<span className={styles.fieldLabel}>{t('sceneEditor.gridColor')}</span>
|
||||
<input
|
||||
type="color"
|
||||
className={styles.colorInput}
|
||||
disabled={!localGrid.enabled}
|
||||
value={localGrid.color}
|
||||
onChange={(e) => persistGrid({ ...localGrid, color: e.target.value })}
|
||||
aria-label="Цвет сетки"
|
||||
aria-label={t('sceneEditor.gridColorAria')}
|
||||
/>
|
||||
</label>
|
||||
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
|
||||
<span className={styles.fieldLabel}>
|
||||
Размер <span className={styles.fieldValue}>{Math.round(localGrid.sizeN * 100)}</span>
|
||||
{t('sceneEditor.gridSize')} <span className={styles.fieldValue}>{Math.round(localGrid.sizeN * 100)}</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
@@ -784,7 +993,7 @@ export function SceneEditorApp() {
|
||||
<div className={styles.templateName}>{tpl.name}</div>
|
||||
)}
|
||||
<div className={styles.templateHint}>
|
||||
{sceneGridTypeLabelRu(tpl.type)} · {Math.round(tpl.sizeN * 100)}
|
||||
{t(tpl.type === 'hex' ? 'sceneEditor.gridHex' : 'sceneEditor.gridSquare')} · {Math.round(tpl.sizeN * 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.templateActions}>
|
||||
@@ -830,24 +1039,116 @@ 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)}>
|
||||
НПС {npcsOpen ? '▾' : '▸'}
|
||||
{t('sceneEditor.npcs')} {npcsOpen ? '▾' : '▸'}
|
||||
</button>
|
||||
{npcsOpen ? (
|
||||
<div className={styles.tokensPanel}>
|
||||
<Input
|
||||
value={npcSearch}
|
||||
onChange={setNpcSearch}
|
||||
placeholder="Поиск НПС…"
|
||||
placeholder={t('sceneEditor.npcSearch')}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className={styles.tokenGrid}>
|
||||
{filteredNpcs.map((npc) => (
|
||||
<NpcPaletteTile key={npc.id} npc={npc} />
|
||||
))}
|
||||
{filteredNpcs.length === 0 ? <div className={styles.hint}>Нет НПС</div> : null}
|
||||
{filteredNpcs.length === 0 ? <div className={styles.hint}>{t('sceneEditor.noNpcs')}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -856,22 +1157,22 @@ export function SceneEditorApp() {
|
||||
|
||||
<div className={styles.accordion}>
|
||||
<button type="button" className={styles.accordionHead} onClick={() => setTokensOpen((v) => !v)}>
|
||||
Неигровые токены {tokensOpen ? '▾' : '▸'}
|
||||
{t('sceneEditor.tokens')} {tokensOpen ? '▾' : '▸'}
|
||||
</button>
|
||||
{tokensOpen ? (
|
||||
<div className={styles.tokensPanel}>
|
||||
<div className={styles.tokensToolbar}>
|
||||
<Button onClick={() => setTokenModal({ mode: 'create' })}>Добавить</Button>
|
||||
<Button onClick={() => setTokenModal({ mode: 'create' })}>{t('sceneEditor.add')}</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={tokenSearch}
|
||||
onChange={setTokenSearch}
|
||||
placeholder="Поиск…"
|
||||
placeholder={t('sceneEditor.tokenSearch')}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className={styles.tokenGrid}>
|
||||
{filteredTokens.length === 0 ? (
|
||||
<div className={styles.hint}>Нет токенов</div>
|
||||
<div className={styles.hint}>{t('sceneEditor.noTokens')}</div>
|
||||
) : (
|
||||
filteredTokens.map((token) => (
|
||||
<TokenTile
|
||||
@@ -889,7 +1190,7 @@ export function SceneEditorApp() {
|
||||
|
||||
<div className={styles.accordion}>
|
||||
<button type="button" className={styles.accordionHead} onClick={() => setTrapsOpen((v) => !v)}>
|
||||
Ловушки {trapsOpen ? '▾' : '▸'}
|
||||
{t('sceneEditor.traps')} {trapsOpen ? '▾' : '▸'}
|
||||
</button>
|
||||
{trapsOpen ? (
|
||||
<div className={styles.palette}>
|
||||
@@ -902,10 +1203,10 @@ export function SceneEditorApp() {
|
||||
e.dataTransfer.setData('application/x-dnd-trap-type', type);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
title="Перетащите на карту"
|
||||
title={t('sceneEditor.dragToMap')}
|
||||
>
|
||||
<TrapGlyph type={type} size={22} />
|
||||
<span className={styles.paletteLabel}>{trapTypeLabelRu(type)}</span>
|
||||
<span className={styles.paletteLabel}>{t(`sceneEditor.trap.${type}`)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -940,7 +1241,7 @@ export function SceneEditorApp() {
|
||||
});
|
||||
}}
|
||||
>
|
||||
Очистить сцену
|
||||
{t('sceneEditor.clearScene')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -948,7 +1249,7 @@ export function SceneEditorApp() {
|
||||
|
||||
<div className={styles.stage}>
|
||||
{!hasMapMedia ? (
|
||||
<div className={styles.empty}>Нужно изображение или видео сцены</div>
|
||||
<div className={styles.empty}>{t('sceneEditor.needMedia')}</div>
|
||||
) : (
|
||||
<div
|
||||
ref={hostRef}
|
||||
@@ -1073,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 : []}
|
||||
@@ -1278,13 +1678,13 @@ export function SceneEditorApp() {
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Закрыть"
|
||||
aria-label={t('common.close')}
|
||||
className={editorStyles.modalBackdrop}
|
||||
onClick={() => setPendingDeleteToken(null)}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
|
||||
<div className={editorStyles.modalHeader}>
|
||||
<div className={editorStyles.modalTitle}>Удалить токен</div>
|
||||
<div className={editorStyles.modalTitle}>{t('sceneEditor.deleteTokenTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
className={editorStyles.modalClose}
|
||||
@@ -1294,10 +1694,10 @@ export function SceneEditorApp() {
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
Удалить токен «{pendingDeleteToken.name}» из пула? Он также будет убран с текущей сцены.
|
||||
{t('sceneEditor.deleteTokenBody', { name: pendingDeleteToken.name })}
|
||||
</div>
|
||||
<div className={editorStyles.modalFooter}>
|
||||
<Button onClick={() => setPendingDeleteToken(null)}>Отмена</Button>
|
||||
<Button onClick={() => setPendingDeleteToken(null)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
@@ -1307,7 +1707,7 @@ export function SceneEditorApp() {
|
||||
persistTokens(tokensRef.current.filter((t) => t.tokenId !== id));
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1345,7 +1745,7 @@ export function SceneEditorApp() {
|
||||
.catch((err) => console.error('[sceneEditor] openTokenPathEditor', err));
|
||||
}}
|
||||
>
|
||||
Указать движение
|
||||
{t('sceneEditor.setPath')}
|
||||
</button>
|
||||
{(() => {
|
||||
const tok = localTokens.find((item) => item.id === tokenCtxMenu.placementId);
|
||||
@@ -1369,7 +1769,7 @@ export function SceneEditorApp() {
|
||||
setTokenCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
Сбросить на старт пути
|
||||
{t('sceneEditor.resetPath')}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
@@ -1423,7 +1823,7 @@ export function SceneEditorApp() {
|
||||
.catch((err) => console.error('[sceneEditor] openTokenPathEditor', err));
|
||||
}}
|
||||
>
|
||||
Указать движение
|
||||
{t('sceneEditor.setPath')}
|
||||
</button>
|
||||
{(() => {
|
||||
const tok = localNpcTokens.find((item) => item.id === npcCtxMenu.placementId);
|
||||
@@ -1441,7 +1841,7 @@ export function SceneEditorApp() {
|
||||
setNpcCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
Сбросить на старт пути
|
||||
{t('sceneEditor.resetPath')}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { SceneToken } from '../../shared/types';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { useTokenImageUrl } from '../shared/tokens/useTokenImageUrl';
|
||||
|
||||
import styles from './SceneEditorApp.module.css';
|
||||
@@ -30,6 +31,7 @@ export function SceneTokenMarker({
|
||||
onResizePointerDown,
|
||||
onRotatePointerDown,
|
||||
}: Props) {
|
||||
const { t } = useEditorI18n();
|
||||
const url = useTokenImageUrl(token.tokenId);
|
||||
return (
|
||||
<div
|
||||
@@ -41,7 +43,7 @@ export function SceneTokenMarker({
|
||||
height: sizePx,
|
||||
transform: `translate(-50%, -50%) rotate(${String(token.rotationDeg)}deg)`,
|
||||
}}
|
||||
title="Перетащите · ПКМ — удалить"
|
||||
title={t('sceneEditor.dragDelete')}
|
||||
onContextMenu={onContextMenu}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
@@ -56,8 +58,8 @@ export function SceneTokenMarker({
|
||||
<button
|
||||
type="button"
|
||||
className={styles.rotateHandle}
|
||||
aria-label="Повернуть"
|
||||
title="Повернуть"
|
||||
aria-label={t('sceneEditor.rotate')}
|
||||
title={t('sceneEditor.rotate')}
|
||||
onPointerDown={onRotatePointerDown}
|
||||
>
|
||||
↻
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '../editor/fileDrop';
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import matStyles from '../editor/MaterialsModals.module.css';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
import { useTokenImageUrl } from '../shared/tokens/useTokenImageUrl';
|
||||
@@ -29,6 +30,7 @@ function normalizeName(input: string): string {
|
||||
}
|
||||
|
||||
export function TokenEditModal({ open, initial, existingNames, onClose, onSaved }: Props) {
|
||||
const { t } = useEditorI18n();
|
||||
const api = getDndApi();
|
||||
const [name, setName] = useState('');
|
||||
const [filePath, setFilePath] = useState<string | null>(null);
|
||||
@@ -94,26 +96,26 @@ export function TokenEditModal({ open, initial, existingNames, onClose, onSaved
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button type="button" aria-label="Закрыть" onClick={onClose} className={editorStyles.modalBackdrop} />
|
||||
<button type="button" aria-label={t('common.close')} onClick={onClose} className={editorStyles.modalBackdrop} />
|
||||
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
|
||||
<div className={editorStyles.modalHeader}>
|
||||
<div className={editorStyles.modalTitle}>
|
||||
{initial ? 'Изменить токен' : 'Добавить токен'}
|
||||
{initial ? t('sceneEditor.editToken') : t('sceneEditor.addToken')}
|
||||
</div>
|
||||
<button type="button" aria-label="Закрыть" onClick={onClose} className={editorStyles.modalClose}>
|
||||
<button type="button" aria-label={t('common.close')} onClick={onClose} className={editorStyles.modalClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={editorStyles.fieldGrid}>
|
||||
<div className={editorStyles.fieldLabel}>Название</div>
|
||||
<Input value={name} onChange={setName} placeholder="Название токена" />
|
||||
{!nameOk ? <div className={editorStyles.fieldError}>Укажите название</div> : null}
|
||||
{nameDup ? <div className={editorStyles.fieldError}>Такое название уже есть</div> : null}
|
||||
<div className={editorStyles.fieldLabel}>{t('sceneEditor.tokenName')}</div>
|
||||
<Input value={name} onChange={setName} placeholder={t('sceneEditor.tokenNamePlaceholder')} />
|
||||
{!nameOk ? <div className={editorStyles.fieldError}>{t('sceneEditor.tokenNameRequired')}</div> : null}
|
||||
{nameDup ? <div className={editorStyles.fieldError}>{t('sceneEditor.tokenNameDuplicate')}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className={editorStyles.fieldGrid}>
|
||||
<div className={editorStyles.fieldLabel}>Изображение</div>
|
||||
<div className={editorStyles.fieldLabel}>{t('sceneEditor.image')}</div>
|
||||
<div
|
||||
className={[matStyles.imageDrop, drop.dragOver ? matStyles.imageDropOver : ''].join(' ')}
|
||||
onDragEnter={drop.onDragEnter}
|
||||
@@ -137,12 +139,12 @@ export function TokenEditModal({ open, initial, existingNames, onClose, onSaved
|
||||
}}
|
||||
>
|
||||
{drop.dragOver ? (
|
||||
<div className={editorStyles.dropHintOverlay}>Отпустите, чтобы загрузить изображение</div>
|
||||
<div className={editorStyles.dropHintOverlay}>{t('sceneEditor.dropImage')}</div>
|
||||
) : null}
|
||||
{previewSrc ? (
|
||||
<img src={previewSrc} alt="" className={matStyles.previewThumb} draggable={false} />
|
||||
) : (
|
||||
<div className={editorStyles.muted}>Перетащите изображение или выберите файл</div>
|
||||
<div className={editorStyles.muted}>{t('sceneEditor.dropOrPick')}</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -153,17 +155,17 @@ export function TokenEditModal({ open, initial, existingNames, onClose, onSaved
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Выбрать изображение
|
||||
{t('sceneEditor.chooseImage')}
|
||||
</Button>
|
||||
</div>
|
||||
{!hasImage ? <div className={editorStyles.fieldError}>Нужно изображение</div> : null}
|
||||
{!hasImage ? <div className={editorStyles.fieldError}>{t('sceneEditor.imageRequired')}</div> : null}
|
||||
</div>
|
||||
|
||||
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
|
||||
|
||||
<div className={editorStyles.modalFooter}>
|
||||
<Button onClick={onClose} disabled={saving}>
|
||||
Отмена
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -182,14 +184,14 @@ export function TokenEditModal({ open, initial, existingNames, onClose, onSaved
|
||||
onSaved(token);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Не удалось сохранить');
|
||||
setError(e instanceof Error ? e.message : t('sceneEditor.saveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Сохранить
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { AppToken } from '../../shared/types';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { useTokenImageUrl } from '../shared/tokens/useTokenImageUrl';
|
||||
|
||||
import styles from './SceneEditorApp.module.css';
|
||||
@@ -15,6 +16,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export function TokenTile({ token, onEdit, onDelete }: Props) {
|
||||
const { t } = useEditorI18n();
|
||||
const url = useTokenImageUrl(token.id);
|
||||
const menuBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -63,7 +65,7 @@ export function TokenTile({ token, onEdit, onDelete }: Props) {
|
||||
e.dataTransfer.setData(TOKEN_DND_MIME, token.id);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
title="Перетащите на карту"
|
||||
title={t('sceneEditor.dragToMap')}
|
||||
>
|
||||
<div className={styles.tokenThumb}>
|
||||
{url ? <img src={url} alt="" draggable={false} /> : null}
|
||||
@@ -74,10 +76,10 @@ export function TokenTile({ token, onEdit, onDelete }: Props) {
|
||||
ref={menuBtnRef}
|
||||
type="button"
|
||||
className={styles.tokenTileMore}
|
||||
aria-label="Действия"
|
||||
aria-label={t('sceneEditor.actions')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menu !== null}
|
||||
title="Действия"
|
||||
title={t('sceneEditor.actions')}
|
||||
onClick={openMenu}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -102,7 +104,7 @@ export function TokenTile({ token, onEdit, onDelete }: Props) {
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
Изменить
|
||||
{t('sceneEditor.edit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -113,7 +115,7 @@ export function TokenTile({ token, onEdit, onDelete }: Props) {
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
@@ -107,7 +107,10 @@ export function PresentationView({
|
||||
sceneNpcTokensSession.spawned,
|
||||
session?.currentSceneId,
|
||||
);
|
||||
const pathLivePoses = useTokenPathLivePoses({
|
||||
const presentationNpcTokens = visibleNpcTokens.filter(
|
||||
(token) => !sceneNpcTokensSession.byPlacementId[String(token.id)]?.hiddenFromPlayers,
|
||||
);
|
||||
useTokenPathLivePoses({
|
||||
tokens: scene?.tokens ?? [],
|
||||
npcTokens: visibleNpcTokens,
|
||||
pathSession: tokenPathSession,
|
||||
@@ -245,7 +248,7 @@ export function PresentationView({
|
||||
{(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? (
|
||||
<TokenPathsOverlay
|
||||
tokens={scene.tokens ?? []}
|
||||
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? visibleNpcTokens : []}
|
||||
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? presentationNpcTokens : []}
|
||||
viewport={contentRect}
|
||||
mode="presentation"
|
||||
pathSession={tokenPathSession}
|
||||
@@ -257,7 +260,6 @@ export function PresentationView({
|
||||
library={appTokens}
|
||||
session={sceneTokensSession}
|
||||
viewport={contentRect}
|
||||
poseOverrides={pathLivePoses.tokenPoses}
|
||||
/>
|
||||
) : null}
|
||||
{USERS_BRANCH_FEATURES_ENABLED &&
|
||||
@@ -269,7 +271,7 @@ export function PresentationView({
|
||||
session={sceneNpcTokensSession}
|
||||
viewport={contentRect}
|
||||
grid={scene.grid}
|
||||
poseOverrides={pathLivePoses.npcPoses}
|
||||
concealFromPlayers
|
||||
/>
|
||||
) : null}
|
||||
{USERS_BRANCH_FEATURES_ENABLED &&
|
||||
@@ -340,7 +342,7 @@ export function PresentationView({
|
||||
))}
|
||||
{activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null}
|
||||
</SceneOverlayHost>
|
||||
<SessionTimerLayer viewport={contentRect} editable={false} />
|
||||
<SessionTimerLayer viewport={markerViewport} editable={false} />
|
||||
<DiceRollLayer />
|
||||
{showTitle ? (
|
||||
<div className={styles.titleWrap}>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
.host {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.hostFill {
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.hostBound {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hostInteractive {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -49,3 +49,16 @@ void test('PxiEffectsOverlay: эффекты перекладываются пр
|
||||
assert.ok(src.includes('viewportSig'));
|
||||
assert.match(src, /\$\{contentSig\}@\$\{viewportSig\(viewport\)\}/);
|
||||
});
|
||||
|
||||
void test('PxiEffectsOverlay: canvas по contentRect + preview profile', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8');
|
||||
const css = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.module.css'), 'utf8');
|
||||
assert.ok(css.includes('.hostBound'));
|
||||
assert.ok(css.includes('.hostFill'));
|
||||
assert.match(src, /perfProfile\s*=\s*'live'/);
|
||||
assert.match(src, /resolution:\s*preview\s*\?\s*0\.5\s*:\s*dpr/);
|
||||
assert.match(src, /maxFPS\s*=\s*preview\s*\?\s*12\s*:\s*32/);
|
||||
assert.ok(src.includes('decimateFieldStampPoints'));
|
||||
assert.ok(src.includes('fieldStampPoints'));
|
||||
assert.match(src, /viewportRef\.current = \{ x: 0, y: 0, w: viewport\.w/);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { decimateFieldStampPoints, fieldStampMinSpacingN } from '../../../shared/fieldEffectStampPoints';
|
||||
import type {
|
||||
EffectInstance,
|
||||
EffectInstanceType,
|
||||
@@ -9,6 +10,10 @@ import type {
|
||||
|
||||
import styles from './PxiEffectsOverlay.module.css';
|
||||
|
||||
function fieldStampPoints(inst: Extract<EffectInstance, { type: 'fog' | 'fire' | 'rain' }>) {
|
||||
return decimateFieldStampPoints(inst.points, fieldStampMinSpacingN(inst.type, inst.radiusN));
|
||||
}
|
||||
|
||||
export type PixiEffectsOverlayHandle = {
|
||||
/** Draft-штрих без React re-render родителя; null — сброс. */
|
||||
setDraft: (instance: EffectInstance | null) => void;
|
||||
@@ -193,6 +198,11 @@ type Props = {
|
||||
style?: React.CSSProperties;
|
||||
/** Область “контента” внутри контейнера (например при object-fit: contain). */
|
||||
viewport?: { x: number; y: number; w: number; h: number } | undefined;
|
||||
/**
|
||||
* `preview` — пульт: ниже FPS, меньший backing store, без покачивания спрайтов.
|
||||
* Презентация остаётся `live`.
|
||||
*/
|
||||
perfProfile?: 'live' | 'preview';
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -203,7 +213,7 @@ type Props = {
|
||||
* - Draft кисти идёт через `setDraft` (imperative), без re-render ControlApp.
|
||||
*/
|
||||
export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(function PixiEffectsOverlay(
|
||||
{ state, interactive = false, style, viewport },
|
||||
{ state, interactive = false, style, viewport, perfProfile = 'live' },
|
||||
ref,
|
||||
) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -320,17 +330,18 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
||||
const pixi = await import('pixi.js');
|
||||
pixiRef.current = pixi;
|
||||
if (destroyed) return;
|
||||
const preview = perfProfile === 'preview';
|
||||
app = new pixi.Application();
|
||||
await app.init({
|
||||
backgroundAlpha: 0,
|
||||
antialias: false,
|
||||
powerPreference: 'high-performance',
|
||||
resolution: dpr,
|
||||
resolution: preview ? 0.5 : dpr,
|
||||
autoDensity: true,
|
||||
preference: 'webgl',
|
||||
});
|
||||
// Меньше кадров — меньше CPU/GPU; анимации эффектов остаются плавными.
|
||||
app.ticker.maxFPS = 32;
|
||||
// live: 32 FPS; preview (пульт): 12 — второй WebGL поверх видео на Mac дешевле.
|
||||
app.ticker.maxFPS = preview ? 12 : 32;
|
||||
if (destroyed) return;
|
||||
host.appendChild(app.canvas);
|
||||
// Canvas по умолчанию перехватывает hit-test; оставляем клики «сквозь» оверлей для слоя кисти сверху.
|
||||
@@ -370,12 +381,15 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
||||
const s = stateRef.current;
|
||||
if (!s || s.instances.length === 0) return;
|
||||
const nowMs = Date.now() + timeOffsetRef.current;
|
||||
animateNodes(pixi, nodesRef.current, s, nowMs, sizeRef.current, viewportRef.current);
|
||||
const motion = !preview;
|
||||
animateNodes(pixi, nodesRef.current, s, nowMs, sizeRef.current, viewportRef.current, {
|
||||
motion,
|
||||
});
|
||||
|
||||
// Лёгкое “потряхивание” сцены в момент удара молнии.
|
||||
// Делаем через смещение корневого контейнера, чтобы не вмешиваться в рендерер/камера-логику.
|
||||
const rootNode = rootRef.current;
|
||||
if (rootNode) {
|
||||
if (rootNode && motion) {
|
||||
const { x, y } = computeSceneShake(s, nowMs, sizeRef.current);
|
||||
rootNode.x = x;
|
||||
rootNode.y = y;
|
||||
@@ -404,7 +418,7 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
||||
host.replaceChildren();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gpuBoot]);
|
||||
}, [gpuBoot, perfProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
@@ -420,9 +434,9 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
// Если viewport не задан — используем “весь холст”.
|
||||
// Host уже стоит на contentRect — в Pixi локальные координаты с (0,0).
|
||||
viewportProvidedRef.current = Boolean(viewport);
|
||||
if (viewport) viewportRef.current = viewport;
|
||||
if (viewport) viewportRef.current = { x: 0, y: 0, w: viewport.w, h: viewport.h };
|
||||
else viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h };
|
||||
const pixi = pixiRef.current;
|
||||
const root = rootRef.current;
|
||||
@@ -437,9 +451,22 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
||||
}
|
||||
}, [viewport]);
|
||||
|
||||
const hostClass = [styles.host, interactive ? styles.hostInteractive : styles.hostPassthrough].join(' ');
|
||||
const hostClass = [
|
||||
styles.host,
|
||||
viewport ? styles.hostBound : styles.hostFill,
|
||||
interactive ? styles.hostInteractive : styles.hostPassthrough,
|
||||
].join(' ');
|
||||
const hostStyle: React.CSSProperties = viewport
|
||||
? {
|
||||
...style,
|
||||
left: viewport.x,
|
||||
top: viewport.y,
|
||||
width: viewport.w,
|
||||
height: viewport.h,
|
||||
}
|
||||
: { ...style };
|
||||
|
||||
return <div ref={hostRef} className={hostClass} style={style} />;
|
||||
return <div ref={hostRef} className={hostClass} style={hostStyle} />;
|
||||
});
|
||||
|
||||
function syncNodes(
|
||||
@@ -530,8 +557,9 @@ function createInstanceNode(
|
||||
const r = inst.radiusN * minDim;
|
||||
const textures = getFogVfxFrameTextures(pixi);
|
||||
const c = new pixi.Container();
|
||||
for (let i = 0; i < inst.points.length; i += 1) {
|
||||
const p = inst.points[i];
|
||||
const stamps = fieldStampPoints(inst);
|
||||
for (let i = 0; i < stamps.length; i += 1) {
|
||||
const p = stamps[i];
|
||||
if (!p) continue;
|
||||
const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
|
||||
s.anchor?.set?.(0.5, 0.5);
|
||||
@@ -570,8 +598,9 @@ function createInstanceNode(
|
||||
const r = inst.radiusN * minDim;
|
||||
const textures = getGroundFireVfxFrameTextures(pixi);
|
||||
const c = new pixi.Container();
|
||||
for (let i = 0; i < inst.points.length; i += 1) {
|
||||
const p = inst.points[i];
|
||||
const stamps = fieldStampPoints(inst);
|
||||
for (let i = 0; i < stamps.length; i += 1) {
|
||||
const p = stamps[i];
|
||||
if (!p) continue;
|
||||
const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
|
||||
s.anchor?.set?.(0.5, 0.62);
|
||||
@@ -613,8 +642,9 @@ function createInstanceNode(
|
||||
const r = inst.radiusN * minDim;
|
||||
const textures = getRainVfxFrameTextures(pixi);
|
||||
const c = new pixi.Container();
|
||||
for (let i = 0; i < inst.points.length; i += 1) {
|
||||
const p = inst.points[i];
|
||||
const stamps = fieldStampPoints(inst);
|
||||
for (let i = 0; i < stamps.length; i += 1) {
|
||||
const p = stamps[i];
|
||||
if (!p) continue;
|
||||
const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
|
||||
s.anchor?.set?.(0.5, 0.5);
|
||||
@@ -885,6 +915,7 @@ function animateNodes(
|
||||
nowMs: number,
|
||||
size: { w: number; h: number },
|
||||
viewport: { x: number; y: number; w: number; h: number },
|
||||
opts?: { motion?: boolean },
|
||||
) {
|
||||
const instById = new Map(state.instances.map((i) => [i.id, i]));
|
||||
void pixi;
|
||||
@@ -940,12 +971,14 @@ function animateNodes(
|
||||
child.texture = frame;
|
||||
}
|
||||
}
|
||||
if (opts?.motion !== false) {
|
||||
const wobble = typeof fx.wobble === 'number' ? fx.wobble : 2;
|
||||
child.x = fx.bx + Math.sin(0.0016 * t + (fx.phase ?? 0) * 6.28) * wobble;
|
||||
child.y = fx.by + Math.cos(0.0019 * t + (fx.phase ?? 0) * 6.28) * wobble * 0.45;
|
||||
const local = 0.93 + 0.07 * Math.sin(0.0042 * t + (fx.phase ?? 0) * 12.56);
|
||||
child.width = (typeof fx.w0 === 'number' ? fx.w0 : child.width) * local;
|
||||
child.height = (typeof fx.h0 === 'number' ? fx.h0 : child.height) * (0.96 + (local - 0.93) * 0.7);
|
||||
}
|
||||
child.alpha = 1;
|
||||
}
|
||||
}
|
||||
@@ -991,9 +1024,11 @@ function animateNodes(
|
||||
}
|
||||
}
|
||||
const alphaMul = typeof fx.alphaMul === 'number' ? fx.alphaMul : 1;
|
||||
if (opts?.motion !== false) {
|
||||
const wob = typeof fx.drift === 'number' ? fx.drift : 0;
|
||||
child.x = fx.bx + Math.sin(0.0011 * t + (fx.phase ?? 0) * 6.28) * wob;
|
||||
child.y = fx.by + Math.cos(0.0014 * t + (fx.phase ?? 0) * 6.28) * wob * 0.35;
|
||||
}
|
||||
child.width = typeof fx.w0 === 'number' ? fx.w0 : child.width;
|
||||
child.height = typeof fx.h0 === 'number' ? fx.h0 : child.height;
|
||||
child.alpha = Math.max(0.25, Math.min(1, inst.opacity * base * alphaMul));
|
||||
@@ -1867,10 +1902,11 @@ function relayoutInstanceNode(
|
||||
if (inst.type === 'fog') {
|
||||
const r = inst.radiusN * minDim;
|
||||
const fogSize = Math.max(4, r * 2);
|
||||
const stamps = fieldStampPoints(inst);
|
||||
const children = node.children ?? [];
|
||||
let pi = 0;
|
||||
for (const child of children) {
|
||||
const p = inst.points[pi];
|
||||
const p = stamps[pi];
|
||||
pi += 1;
|
||||
if (!p) continue;
|
||||
const fx = (child as any).__fx ?? {};
|
||||
@@ -1891,10 +1927,11 @@ function relayoutInstanceNode(
|
||||
const r = inst.radiusN * minDim;
|
||||
const flameWidth = Math.max(r * 3.2, minDim * 0.05);
|
||||
const flameHeight = flameWidth / GROUND_FIRE_VFX_FRAME_ASPECT;
|
||||
const stamps = fieldStampPoints(inst);
|
||||
const children = node.children ?? [];
|
||||
let pi = 0;
|
||||
for (const child of children) {
|
||||
const p = inst.points[pi];
|
||||
const p = stamps[pi];
|
||||
pi += 1;
|
||||
if (!p) continue;
|
||||
const fx = (child as any).__fx ?? {};
|
||||
@@ -1915,10 +1952,11 @@ function relayoutInstanceNode(
|
||||
const r = inst.radiusN * minDim;
|
||||
const rainWidth = Math.max(r * 3.8, minDim * 0.08);
|
||||
const rainHeight = rainWidth / RAIN_VFX_FRAME_ASPECT;
|
||||
const stamps = fieldStampPoints(inst);
|
||||
const children = node.children ?? [];
|
||||
let pi = 0;
|
||||
for (const child of children) {
|
||||
const p = inst.points[pi];
|
||||
const p = stamps[pi];
|
||||
pi += 1;
|
||||
if (!p) continue;
|
||||
const fx = (child as any).__fx ?? {};
|
||||
|
||||
@@ -114,7 +114,7 @@ export function SceneDarknessOverlay({
|
||||
const h = Math.max(1, Math.round(viewport.h));
|
||||
if (canvas.width !== w) canvas.width = w;
|
||||
if (canvas.height !== h) canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
|
||||
@@ -98,7 +98,7 @@ export function SceneMarkerOverlay({ state, viewport, style }: SceneMarkerOverla
|
||||
const h = Math.max(1, Math.round(viewport.h));
|
||||
if (canvas.width !== w) canvas.width = w;
|
||||
if (canvas.height !== h) canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
@@ -70,7 +70,7 @@ export function SceneGridOverlay({ grid, viewport }: Props) {
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
|
||||
.token {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
overflow: visible;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
touch-action: none;
|
||||
}
|
||||
@@ -34,6 +35,23 @@
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.hiddenFromPlayers {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.hiddenFromPlayers::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
border-radius: 50%;
|
||||
border: 2px dashed var(--text2);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.concealedFromPlayers {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Кольцо строго по центру круга токена (обёртка = sizePx × sizePx). */
|
||||
.initiativeActive::before {
|
||||
content: '';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
NpcDisposition,
|
||||
@@ -16,6 +16,8 @@ import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid';
|
||||
import { useAssetUrl } from '../useAssetImageUrl';
|
||||
|
||||
import { PlayerTokenView } from './PlayerTokenView';
|
||||
import { tokenPathKey } from '../../../shared/types/tokenPathSession';
|
||||
import { registerPathTokenNode, writeTokenPathTransform } from '../tokens/tokenPathLiveDom';
|
||||
import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast';
|
||||
import {
|
||||
TokenInitiativeDecor,
|
||||
@@ -49,6 +51,8 @@ function NpcSprite({
|
||||
gridFit,
|
||||
disposition,
|
||||
inactive,
|
||||
hiddenFromPlayers,
|
||||
concealed,
|
||||
onMove,
|
||||
onResize,
|
||||
resizeTitle,
|
||||
@@ -69,6 +73,8 @@ function NpcSprite({
|
||||
gridFit: number;
|
||||
disposition: NpcDisposition;
|
||||
inactive: boolean;
|
||||
hiddenFromPlayers: boolean;
|
||||
concealed: boolean;
|
||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||
onResize?: (placementId: string, sizeN: number) => void;
|
||||
resizeTitle?: string;
|
||||
@@ -100,6 +106,8 @@ function NpcSprite({
|
||||
onResize,
|
||||
String(placement.id),
|
||||
);
|
||||
const elRef = useRef<HTMLDivElement>(null);
|
||||
const pathKey = tokenPathKey('npcToken', String(placement.id));
|
||||
const pos = localPos ?? { nx, ny };
|
||||
const shownSizeN = localSize ?? sizeN;
|
||||
const minDim = Math.min(viewport.w, viewport.h);
|
||||
@@ -107,6 +115,17 @@ function NpcSprite({
|
||||
const canDrag = editable && dragEnabled && onMove !== undefined;
|
||||
const canResize = editable && dragEnabled && onResize !== undefined;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
registerPathTokenNode(pathKey, elRef.current, viewport, false);
|
||||
return () => registerPathTokenNode(pathKey, null, viewport, false);
|
||||
}, [pathKey, viewport]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = elRef.current;
|
||||
if (!el || el.dataset.pathLive === '1') return;
|
||||
writeTokenPathTransform(el, pos.nx, pos.ny, 0, viewport, false);
|
||||
}, [pos.nx, pos.ny, viewport]);
|
||||
|
||||
const point = (e: React.PointerEvent, host?: HTMLElement | null) => {
|
||||
const rect = (host ?? e.currentTarget.parentElement)?.getBoundingClientRect();
|
||||
return {
|
||||
@@ -133,18 +152,19 @@ function NpcSprite({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={elRef}
|
||||
className={[
|
||||
styles.token,
|
||||
canDrag ? styles.editable : '',
|
||||
initiativeActive ? styles.initiativeActive : '',
|
||||
hiddenFromPlayers && !concealed ? styles.hiddenFromPlayers : '',
|
||||
concealed ? styles.concealedFromPlayers : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
data-testid={`session-npc-token-${placement.id}`}
|
||||
data-initiative-active={initiativeActive ? '1' : '0'}
|
||||
style={{
|
||||
left: viewport.x + pos.nx * viewport.w,
|
||||
top: viewport.y + pos.ny * viewport.h,
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
pointerEvents: canDrag || canResize || onContextMenu ? 'auto' : undefined,
|
||||
@@ -260,6 +280,7 @@ export function SceneNpcTokensOverlay({
|
||||
snapNorm,
|
||||
poseOverrides = null,
|
||||
dragEnabledById = null,
|
||||
concealFromPlayers = false,
|
||||
}: {
|
||||
placements: readonly SceneNpcToken[];
|
||||
library: readonly ProjectNpc[];
|
||||
@@ -275,6 +296,8 @@ export function SceneNpcTokensOverlay({
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
poseOverrides?: Record<string, { nx: number; ny: number }> | null;
|
||||
dragEnabledById?: Record<string, boolean> | null;
|
||||
/** На презентации скрытый от игроков токен остаётся в DOM, чтобы путь продолжал идти. */
|
||||
concealFromPlayers?: boolean;
|
||||
}) {
|
||||
const [initiative] = useInitiativeState();
|
||||
if (!viewport) return null;
|
||||
@@ -291,6 +314,7 @@ export function SceneNpcTokensOverlay({
|
||||
const override = session?.byPlacementId[key];
|
||||
const disposition = resolveNpcTokenDisposition(placement, npc, override?.disposition);
|
||||
const inactive = Boolean(override?.inactive);
|
||||
const hiddenFromPlayers = Boolean(override?.hiddenFromPlayers);
|
||||
const dragEnabled = dragEnabledById ? Boolean(dragEnabledById[key]) : true;
|
||||
return (
|
||||
<NpcSprite
|
||||
@@ -307,6 +331,8 @@ export function SceneNpcTokensOverlay({
|
||||
gridFit={gridFit}
|
||||
disposition={disposition}
|
||||
inactive={inactive}
|
||||
hiddenFromPlayers={hiddenFromPlayers}
|
||||
concealed={concealFromPlayers && hiddenFromPlayers}
|
||||
initiativeActive={initiativeHighlightForNpc(initiative, key)}
|
||||
initiativeConditions={initiativeConditionsForNpc(initiative, key)}
|
||||
{...(onMove ? { onMove } : {})}
|
||||
|
||||
@@ -34,7 +34,8 @@ function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
|
||||
p.ny === undefined &&
|
||||
p.sizeN === undefined &&
|
||||
!p.disposition &&
|
||||
!p.inactive
|
||||
!p.inactive &&
|
||||
!p.hiddenFromPlayers
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,6 +46,7 @@ function restOf(prev: SceneNpcTokensSessionPlacement | undefined): SceneNpcToken
|
||||
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
|
||||
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
||||
...(prev?.inactive ? { inactive: true } : {}),
|
||||
...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,6 +119,7 @@ function applyEvent(
|
||||
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
|
||||
disposition,
|
||||
...(cur?.inactive ? { inactive: true } : {}),
|
||||
...(cur?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
|
||||
},
|
||||
},
|
||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
@@ -135,6 +138,7 @@ function applyEvent(
|
||||
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
|
||||
...(cur?.disposition ? { disposition: cur.disposition } : {}),
|
||||
...(inactive ? { inactive: true } : {}),
|
||||
...(cur?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
|
||||
};
|
||||
if (isEmptyPlacement(next)) {
|
||||
const { [placementId]: _removed, ...rest } = prev.byPlacementId;
|
||||
@@ -155,6 +159,36 @@ function applyEvent(
|
||||
spawned,
|
||||
};
|
||||
}
|
||||
if (event.kind === 'setHiddenFromPlayers') {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return prev;
|
||||
const hidden = Boolean(event.hidden);
|
||||
const cur = prev.byPlacementId[placementId];
|
||||
if (Boolean(cur?.hiddenFromPlayers) === hidden) return prev;
|
||||
const next: SceneNpcTokensSessionPlacement = {
|
||||
...restOf(cur),
|
||||
...(hidden ? { hiddenFromPlayers: true } : {}),
|
||||
};
|
||||
if (!hidden) delete next.hiddenFromPlayers;
|
||||
if (isEmptyPlacement(next)) {
|
||||
const { [placementId]: _removed, ...rest } = prev.byPlacementId;
|
||||
return {
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: rest,
|
||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
spawned,
|
||||
};
|
||||
}
|
||||
return {
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: {
|
||||
...prev.byPlacementId,
|
||||
[placementId]: next,
|
||||
},
|
||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
spawned,
|
||||
};
|
||||
}
|
||||
if (event.kind === 'setSize') {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return prev;
|
||||
@@ -187,6 +221,7 @@ function applyEvent(
|
||||
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
|
||||
...(cur?.disposition ? { disposition: cur.disposition } : {}),
|
||||
...(cur?.inactive ? { inactive: true } : {}),
|
||||
...(cur?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
|
||||
},
|
||||
},
|
||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
|
||||
@@ -81,29 +81,27 @@ export function useScenePlayerTokensSession(): [
|
||||
visible: false,
|
||||
byPlayerId: {},
|
||||
});
|
||||
const localRevRef = useRef(0);
|
||||
/** Только revision с main. Оптимистичные move/setSize не должны глушить смену сцены. */
|
||||
const serverRevRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const applyServer = (next: ScenePlayerTokensSessionState) => {
|
||||
if (next.revision < serverRevRef.current) return;
|
||||
serverRevRef.current = next.revision;
|
||||
setState(next);
|
||||
};
|
||||
void api.invoke(ipcChannels.scenePlayerTokensSession.getState, {}).then((res) => {
|
||||
if (res.state.revision < localRevRef.current) return;
|
||||
localRevRef.current = res.state.revision;
|
||||
setState(res.state);
|
||||
applyServer(res.state);
|
||||
});
|
||||
return api.on(ipcChannels.scenePlayerTokensSession.stateChanged, ({ state: next }) => {
|
||||
if (next.revision < localRevRef.current) return;
|
||||
localRevRef.current = next.revision;
|
||||
setState(next);
|
||||
applyServer(next);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const dispatch = useCallback(
|
||||
(event: ScenePlayerTokensSessionEvent) => {
|
||||
if (event.kind !== 'seedBottom' && event.kind !== 'show') {
|
||||
setState((prev) => {
|
||||
const next = applyEvent(prev, event);
|
||||
localRevRef.current = Math.max(localRevRef.current, next.revision);
|
||||
return next;
|
||||
});
|
||||
setState((prev) => applyEvent(prev, event));
|
||||
}
|
||||
void api.invoke(ipcChannels.scenePlayerTokensSession.dispatch, { event });
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
.sidebar,
|
||||
.main {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
@@ -74,6 +75,8 @@
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.playlistRow:hover,
|
||||
@@ -103,10 +106,10 @@
|
||||
|
||||
.playlistName,
|
||||
.trackTitle,
|
||||
.playlistNameReadonly,
|
||||
.trackTitleReadonly {
|
||||
flex: 1;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -114,6 +117,7 @@
|
||||
}
|
||||
|
||||
.trackTitle {
|
||||
display: block;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
@@ -122,18 +126,24 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.playlistNameReadonly {
|
||||
font-weight: 700;
|
||||
font-size: var(--text-md, 1rem);
|
||||
.trackTitleReadonly {
|
||||
display: block;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.trackTitleReadonly {
|
||||
color: inherit;
|
||||
.playlistNameReadonly {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 700;
|
||||
font-size: var(--text-md, 1rem);
|
||||
}
|
||||
|
||||
.playlistCount {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mainHeader {
|
||||
@@ -153,6 +163,7 @@
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
|
||||
@@ -309,7 +309,7 @@ export function PlaylistsPanel({
|
||||
}
|
||||
onClick={() => setSelectedId(pl.id)}
|
||||
>
|
||||
<span className={styles.playlistName}>{pl.name || labels.untitled}</span>
|
||||
<span className={styles.playlistName} title={pl.name || labels.untitled}>{pl.name || labels.untitled}</span>
|
||||
{playerUi[pl.id]?.playing ? <span className={styles.playlistPlayingMark}>▶</span> : null}
|
||||
<span className={styles.playlistCount}>{pl.tracks.length}</span>
|
||||
</button>
|
||||
@@ -337,7 +337,9 @@ export function PlaylistsPanel({
|
||||
<>
|
||||
<div className={styles.mainHeader}>
|
||||
{readOnly ? (
|
||||
<div className={styles.playlistNameReadonly}>{selected.name || labels.untitled}</div>
|
||||
<div className={styles.playlistNameReadonly} title={selected.name || labels.untitled}>
|
||||
{selected.name || labels.untitled}
|
||||
</div>
|
||||
) : (
|
||||
<Input value={editingName} onChange={setEditingName} onBlur={() => void saveRename()} />
|
||||
)}
|
||||
@@ -436,7 +438,9 @@ export function PlaylistsPanel({
|
||||
▶
|
||||
</button>
|
||||
{readOnly ? (
|
||||
<span className={styles.trackTitleReadonly}>{title}</span>
|
||||
<span className={styles.trackTitleReadonly} title={title}>
|
||||
{title}
|
||||
</span>
|
||||
) : editingAudioId === tr.audioId ? (
|
||||
<Input
|
||||
value={editingAudioTitle}
|
||||
@@ -453,11 +457,11 @@ export function PlaylistsPanel({
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trackTitle}
|
||||
title={title}
|
||||
onClick={() => {
|
||||
setEditingAudioId(tr.audioId);
|
||||
setEditingAudioTitle(title);
|
||||
}}
|
||||
title={labels.renameAudio}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
.token {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { AppToken, SceneToken, SceneTokensSessionState } from '../../../shared/types';
|
||||
import { clampSceneTokenSizeN } from '../../../shared/types/appTokens';
|
||||
import { tokenPathKey } from '../../../shared/types/tokenPathSession';
|
||||
import { useLiveResizeBroadcast } from '../playerToken/useLiveDragBroadcast';
|
||||
|
||||
import { registerPathTokenNode, writeTokenPathTransform } from './tokenPathLiveDom';
|
||||
import { useTokenImageUrl } from './useTokenImageUrl';
|
||||
import styles from './SceneTokensOverlay.module.css';
|
||||
|
||||
@@ -106,18 +108,29 @@ function TokenSprite({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const elRef = useRef<HTMLDivElement>(null);
|
||||
const posNx = localPos?.nx ?? nx;
|
||||
const posNy = localPos?.ny ?? ny;
|
||||
const shownSizeN = localSize ?? sizeN;
|
||||
const pathKey = tokenPathKey('token', String(placement.id));
|
||||
|
||||
const minDim = Math.min(viewport.w, viewport.h);
|
||||
const sizePx = Math.max(16, shownSizeN * minDim);
|
||||
const left = viewport.x + posNx * viewport.w;
|
||||
const top = viewport.y + posNy * viewport.h;
|
||||
const canDrag = editable && dragEnabled && Boolean(onMove);
|
||||
const canResize = editable && dragEnabled && Boolean(onResize);
|
||||
const interactive = canDrag || canResize || Boolean(onContextMenu);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
registerPathTokenNode(pathKey, elRef.current, viewport, true);
|
||||
return () => registerPathTokenNode(pathKey, null, viewport, true);
|
||||
}, [pathKey, viewport]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = elRef.current;
|
||||
if (!el || el.dataset.pathLive === '1') return;
|
||||
writeTokenPathTransform(el, posNx, posNy, rotationDeg, viewport, true);
|
||||
}, [posNx, posNy, rotationDeg, viewport]);
|
||||
|
||||
const hostToNorm = (clientX: number, clientY: number, host: HTMLElement) => {
|
||||
const r = host.getBoundingClientRect();
|
||||
const w = Math.max(1e-6, viewport.w);
|
||||
@@ -160,6 +173,7 @@ function TokenSprite({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={elRef}
|
||||
className={[
|
||||
styles.token,
|
||||
canDrag ? styles.tokenEditable : '',
|
||||
@@ -168,11 +182,8 @@ function TokenSprite({
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
|
||||
}}
|
||||
onContextMenu={
|
||||
onContextMenu
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { writeTokenPathTransform } from './tokenPathLiveDom';
|
||||
|
||||
void test('writeTokenPathTransform: GPU-friendly translate3d, not left/top', () => {
|
||||
const el = { style: { transform: '' } } as unknown as HTMLElement;
|
||||
writeTokenPathTransform(el, 0.25, 0.5, 45, { x: 10, y: 20, w: 200, h: 100 }, true);
|
||||
assert.equal(el.style.transform, 'translate3d(60px, 70px, 0) translate(-50%, -50%) rotate(45deg)');
|
||||
});
|
||||
|
||||
void test('writeTokenPathTransform: NPC without rotate keeps centering only', () => {
|
||||
const el = { style: { transform: '' } } as unknown as HTMLElement;
|
||||
writeTokenPathTransform(el, 0, 0, 90, { x: 0, y: 0, w: 100, h: 100 }, false);
|
||||
assert.equal(el.style.transform, 'translate3d(0px, 0px, 0) translate(-50%, -50%)');
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/** Direct DOM transforms for path playback — avoids React re-renders on every RAF tick. */
|
||||
|
||||
export type TokenPathViewport = { x: number; y: number; w: number; h: number };
|
||||
|
||||
type NodeReg = {
|
||||
el: HTMLElement;
|
||||
viewport: TokenPathViewport;
|
||||
rotate: boolean;
|
||||
};
|
||||
|
||||
const registry = new Map<string, NodeReg>();
|
||||
|
||||
export function writeTokenPathTransform(
|
||||
el: HTMLElement,
|
||||
nx: number,
|
||||
ny: number,
|
||||
rotationDeg: number,
|
||||
viewport: TokenPathViewport,
|
||||
rotate: boolean,
|
||||
): void {
|
||||
const x = viewport.x + nx * viewport.w;
|
||||
const y = viewport.y + ny * viewport.h;
|
||||
const rot = rotate ? ` rotate(${String(rotationDeg)}deg)` : '';
|
||||
el.style.transform = `translate3d(${String(x)}px, ${String(y)}px, 0) translate(-50%, -50%)${rot}`;
|
||||
}
|
||||
|
||||
export function registerPathTokenNode(
|
||||
key: string,
|
||||
el: HTMLElement | null,
|
||||
viewport: TokenPathViewport,
|
||||
rotate: boolean,
|
||||
): void {
|
||||
if (!el) {
|
||||
registry.delete(key);
|
||||
return;
|
||||
}
|
||||
registry.set(key, { el, viewport, rotate });
|
||||
}
|
||||
|
||||
export function applyLivePathPose(
|
||||
key: string,
|
||||
nx: number,
|
||||
ny: number,
|
||||
rotationDeg: number,
|
||||
animating: boolean,
|
||||
): void {
|
||||
const n = registry.get(key);
|
||||
if (!n) return;
|
||||
n.el.dataset.pathLive = '1';
|
||||
n.el.style.willChange = animating ? 'transform' : '';
|
||||
writeTokenPathTransform(n.el, nx, ny, rotationDeg, n.viewport, n.rotate);
|
||||
}
|
||||
|
||||
export function endLivePathPose(key: string): void {
|
||||
const n = registry.get(key);
|
||||
if (!n) return;
|
||||
delete n.el.dataset.pathLive;
|
||||
n.el.style.willChange = '';
|
||||
}
|
||||
|
||||
export function endAllLivePathPoses(): void {
|
||||
for (const key of [...registry.keys()]) {
|
||||
endLivePathPose(key);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +1,17 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { computePathPlaybackSample } from '../../../shared/types/tokenPathPlayback';
|
||||
import {
|
||||
tokenPathKey,
|
||||
type TokenPathPlaybackPhase,
|
||||
type TokenPathSessionState,
|
||||
} from '../../../shared/types/tokenPathSession';
|
||||
import type { SceneNpcToken, SceneToken } from '../../../shared/types';
|
||||
|
||||
export type TokenPathLivePose = {
|
||||
nx: number;
|
||||
ny: number;
|
||||
rotationDeg: number;
|
||||
phase: TokenPathPlaybackPhase;
|
||||
dist: number;
|
||||
};
|
||||
|
||||
type PoseMaps = {
|
||||
tokenPoses: Record<string, TokenPathLivePose>;
|
||||
npcPoses: Record<string, TokenPathLivePose>;
|
||||
};
|
||||
|
||||
const EMPTY: PoseMaps = { tokenPoses: {}, npcPoses: {} };
|
||||
|
||||
function posesEqual(a: PoseMaps, b: PoseMaps): boolean {
|
||||
const aT = a.tokenPoses;
|
||||
const bT = b.tokenPoses;
|
||||
const aN = a.npcPoses;
|
||||
const bN = b.npcPoses;
|
||||
const aTk = Object.keys(aT);
|
||||
const bTk = Object.keys(bT);
|
||||
const aNk = Object.keys(aN);
|
||||
const bNk = Object.keys(bN);
|
||||
if (aTk.length !== bTk.length || aNk.length !== bNk.length) return false;
|
||||
for (const k of aTk) {
|
||||
const x = aT[k];
|
||||
const y = bT[k];
|
||||
if (!y || x!.nx !== y.nx || x!.ny !== y.ny || x!.rotationDeg !== y.rotationDeg || x!.phase !== y.phase) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (const k of aNk) {
|
||||
const x = aN[k];
|
||||
const y = bN[k];
|
||||
if (!y || x!.nx !== y.nx || x!.ny !== y.ny || x!.rotationDeg !== y.rotationDeg || x!.phase !== y.phase) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
import { applyLivePathPose, endAllLivePathPoses, endLivePathPose } from './tokenPathLiveDom';
|
||||
|
||||
/**
|
||||
* RAF poses for control/presentation while path playback is active.
|
||||
* When phase === 'stopped', pose is omitted so session/placement (drag) wins.
|
||||
* Writes transforms on registered token DOM nodes — no React setState per frame.
|
||||
*
|
||||
* Uses local Date.now() — segmentStartedAtMs is also wall-clock from main (same machine).
|
||||
* Do NOT clamp to stale serverNowMs: that froze motion after ~250ms until the next IPC bump.
|
||||
@@ -63,10 +22,10 @@ export function useTokenPathLivePoses(args: {
|
||||
pathSession: TokenPathSessionState | null;
|
||||
enabled?: boolean;
|
||||
onMarkDone?: (kind: 'token' | 'npcToken', placementId: string, atDist: number) => void;
|
||||
}): PoseMaps {
|
||||
}): void {
|
||||
const { tokens, npcTokens, pathSession, enabled = true, onMarkDone } = args;
|
||||
const [poses, setPoses] = useState<PoseMaps>(EMPTY);
|
||||
const markedDoneRef = useRef<Set<string>>(new Set());
|
||||
const liveKeysRef = useRef<Set<string>>(new Set());
|
||||
const onMarkDoneRef = useRef(onMarkDone);
|
||||
onMarkDoneRef.current = onMarkDone;
|
||||
|
||||
@@ -83,7 +42,8 @@ export function useTokenPathLivePoses(args: {
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setPoses(EMPTY);
|
||||
endAllLivePathPoses();
|
||||
liveKeysRef.current.clear();
|
||||
return;
|
||||
}
|
||||
let raf = 0;
|
||||
@@ -92,22 +52,14 @@ export function useTokenPathLivePoses(args: {
|
||||
const tick = () => {
|
||||
if (!alive) return;
|
||||
const session = pathSessionRef.current;
|
||||
if (!session) {
|
||||
setPoses((prev) => (prev === EMPTY || Object.keys(prev.tokenPoses).length + Object.keys(prev.npcPoses).length === 0 ? prev : EMPTY));
|
||||
raf = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
|
||||
// Wall clock: matches main's Date.now() for segmentStartedAtMs (Electron, one host).
|
||||
const nextKeys = new Set<string>();
|
||||
if (session) {
|
||||
const sampleNow = Date.now();
|
||||
const tokenPoses: Record<string, TokenPathLivePose> = {};
|
||||
const npcPoses: Record<string, TokenPathLivePose> = {};
|
||||
|
||||
const sampleOne = (
|
||||
kind: 'token' | 'npcToken',
|
||||
placementId: string,
|
||||
path: NonNullable<SceneToken['path']>,
|
||||
out: Record<string, TokenPathLivePose>,
|
||||
) => {
|
||||
const key = tokenPathKey(kind, placementId);
|
||||
const entry = session.playback[key];
|
||||
@@ -115,13 +67,14 @@ export function useTokenPathLivePoses(args: {
|
||||
if (entry.phase === 'stopped') return;
|
||||
const result = computePathPlaybackSample({ path, entry, nowMs: sampleNow });
|
||||
if (!result) return;
|
||||
out[placementId] = {
|
||||
nx: result.sample.nx,
|
||||
ny: result.sample.ny,
|
||||
rotationDeg: result.sample.rotationDeg,
|
||||
phase: result.phase,
|
||||
dist: result.sample.dist,
|
||||
};
|
||||
nextKeys.add(key);
|
||||
applyLivePathPose(
|
||||
key,
|
||||
result.sample.nx,
|
||||
result.sample.ny,
|
||||
result.sample.rotationDeg,
|
||||
entry.phase === 'moving' || entry.phase === 'delay',
|
||||
);
|
||||
if (result.markDone && !markedDoneRef.current.has(key)) {
|
||||
markedDoneRef.current.add(key);
|
||||
onMarkDoneRef.current?.(kind, placementId, result.sample.dist);
|
||||
@@ -130,17 +83,20 @@ export function useTokenPathLivePoses(args: {
|
||||
|
||||
for (const t of tokensRef.current) {
|
||||
if (t.path && t.path.points.length >= 2) {
|
||||
sampleOne('token', String(t.id), t.path, tokenPoses);
|
||||
sampleOne('token', String(t.id), t.path);
|
||||
}
|
||||
}
|
||||
for (const t of npcTokensRef.current) {
|
||||
if (t.path && t.path.points.length >= 2) {
|
||||
sampleOne('npcToken', String(t.id), t.path, npcPoses);
|
||||
sampleOne('npcToken', String(t.id), t.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const next: PoseMaps = { tokenPoses, npcPoses };
|
||||
setPoses((prev) => (posesEqual(prev, next) ? prev : next));
|
||||
for (const key of liveKeysRef.current) {
|
||||
if (!nextKeys.has(key)) endLivePathPose(key);
|
||||
}
|
||||
liveKeysRef.current = nextKeys;
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
@@ -148,8 +104,10 @@ export function useTokenPathLivePoses(args: {
|
||||
return () => {
|
||||
alive = false;
|
||||
cancelAnimationFrame(raf);
|
||||
for (const key of liveKeysRef.current) {
|
||||
endLivePathPose(key);
|
||||
}
|
||||
liveKeysRef.current.clear();
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return poses;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const SECONDARY_WINDOW_MAINS = [
|
||||
'dice/main.tsx',
|
||||
'sceneEditor/main.tsx',
|
||||
'sceneDescription/main.tsx',
|
||||
'instructions/main.tsx',
|
||||
'control/main.tsx',
|
||||
'presentation/main.tsx',
|
||||
] as const;
|
||||
@@ -119,6 +120,24 @@ void test('Control and Presentation use SceneNpcTokensOverlay', () => {
|
||||
assert.ok(control.includes('ScenePlayerTokensOverlay'));
|
||||
assert.ok(presentation.includes('ScenePlayerTokensOverlay'));
|
||||
assert.ok(control.includes('toggle-session-players'));
|
||||
assert.match(presentation, /<SessionTimerLayer viewport=\{markerViewport\} editable=\{false\} \/>/);
|
||||
});
|
||||
|
||||
void test('useScenePlayerTokensSession: scene-switch broadcast is not gated by optimistic local revision', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(rendererRoot, 'shared/playerToken/useScenePlayerTokensSession.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(src.includes('serverRevRef'));
|
||||
assert.doesNotMatch(src, /localRevRef/);
|
||||
assert.doesNotMatch(src, /localRevRef\.current = Math\.max/);
|
||||
});
|
||||
|
||||
void test('EditorApp: Instructions opens electron window, not modal', () => {
|
||||
const src = fs.readFileSync(path.join(rendererRoot, 'editor/EditorApp.tsx'), 'utf8');
|
||||
assert.ok(src.includes('windows.openInstructions'));
|
||||
assert.doesNotMatch(src, /InstructionsModal/);
|
||||
assert.ok(fs.existsSync(path.join(rendererRoot, 'instructions/InstructionsApp.tsx')));
|
||||
});
|
||||
|
||||
void test('EditorApp: split Run button and launch-with-players modal', () => {
|
||||
@@ -143,11 +162,24 @@ void test('ControlApp: Playlists tool opens window', () => {
|
||||
assert.ok(src.includes('windows.openPlaylists'));
|
||||
});
|
||||
|
||||
void test('PlaylistsPanel: long audio titles ellipsis via CSS', () => {
|
||||
const css = fs.readFileSync(
|
||||
path.join(rendererRoot, 'shared/playlists/PlaylistsPanel.module.css'),
|
||||
'utf8',
|
||||
);
|
||||
const panel = fs.readFileSync(path.join(rendererRoot, 'shared/playlists/PlaylistsPanel.tsx'), 'utf8');
|
||||
assert.match(css, /\.trackTitleReadonly[\s\S]*?text-overflow:\s*ellipsis/);
|
||||
assert.match(css, /\.trackTitle[\s\S]*?text-overflow:\s*ellipsis/);
|
||||
assert.ok(panel.includes('trackTitleReadonly'));
|
||||
assert.ok(panel.includes('title={title}'));
|
||||
});
|
||||
|
||||
void test('ControlApp: Timer tool opens window', () => {
|
||||
const src = fs.readFileSync(path.join(rendererRoot, 'control/ControlApp.tsx'), 'utf8');
|
||||
assert.ok(src.includes('data-testid="control-timer-tool"'));
|
||||
assert.ok(src.includes('windows.openTimer'));
|
||||
assert.ok(src.includes('SessionTimerLayer'));
|
||||
assert.match(src, /<SessionTimerLayer viewport=\{markerViewport\} editable \/>/);
|
||||
});
|
||||
|
||||
void test('ControlApp: Initiative tool opens window', () => {
|
||||
|
||||
@@ -29,10 +29,11 @@ export type AppWindowKind =
|
||||
| 'npcsEditor'
|
||||
| 'sceneEditor'
|
||||
| 'tokenPathEditor'
|
||||
| 'npcs';
|
||||
| 'npcs'
|
||||
| 'instructions';
|
||||
|
||||
const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
|
||||
editor: { ru: 'Редактор компании', en: 'Campaign editor' },
|
||||
editor: { ru: 'Редактор кампании', en: 'Campaign editor' },
|
||||
presentation: { ru: 'Презентация', en: 'Presentation' },
|
||||
control: { ru: 'Пульт управления', en: 'Control deck' },
|
||||
boot: { ru: 'Загрузка', en: 'Loading' },
|
||||
@@ -46,6 +47,7 @@ const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
|
||||
sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' },
|
||||
tokenPathEditor: { ru: 'Движение токена', en: 'Token path' },
|
||||
npcs: { ru: 'НПС', en: 'NPCs' },
|
||||
instructions: { ru: 'Инструкция', en: 'Instructions' },
|
||||
};
|
||||
|
||||
export function windowChromeTitle(kind: AppWindowKind, localeTag: string): string {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import { evaluateFormula, buildFormulaContext } from './formula';
|
||||
import { computeDerived, sortDerived, CycleError } from './derived';
|
||||
import { validatePointBuy, validateArray, rollDice, sum, validateCreationRules } from './creation';
|
||||
import { snapshotHash, canonicalJson } from './snapshot';
|
||||
import { validateSystemDefinition } from './systemValidate';
|
||||
import type { SystemDefinition } from './types';
|
||||
import { FORMAT_VERSION, SYSTEM_FORMAT } from './types';
|
||||
|
||||
test('evaluateFormula: D&D ability modifier', () => {
|
||||
const ctx = { 'attr.str': 16 };
|
||||
assert.equal(evaluateFormula('floor((attr.str - 10) / 2)', ctx), 3);
|
||||
assert.equal(evaluateFormula('(attr.str - 10) / 2', ctx, 'floor'), 3);
|
||||
});
|
||||
|
||||
test('evaluateFormula: clamp min max', () => {
|
||||
assert.equal(evaluateFormula('clamp(attr.str, 1, 20)', { 'attr.str': 25 }), 20);
|
||||
});
|
||||
|
||||
test('computeDerived topo order', () => {
|
||||
const derived = computeDerived(
|
||||
[
|
||||
{
|
||||
key: 'strMod',
|
||||
label: 'STR mod',
|
||||
formula: 'floor((attr.str - 10) / 2)',
|
||||
dependsOn: ['attr.str'],
|
||||
round: 'none',
|
||||
},
|
||||
{
|
||||
key: 'carry',
|
||||
label: 'Carry',
|
||||
formula: 'attr.str * 15',
|
||||
dependsOn: ['attr.str'],
|
||||
},
|
||||
],
|
||||
{ attributes: { str: 12 } },
|
||||
);
|
||||
assert.equal(derived.strMod, 1);
|
||||
assert.equal(derived.carry, 180);
|
||||
});
|
||||
|
||||
test('sortDerived detects cycles', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
sortDerived([
|
||||
{ key: 'a', label: 'a', formula: 'derived.b', dependsOn: ['derived.b'] },
|
||||
{ key: 'b', label: 'b', formula: 'derived.a', dependsOn: ['derived.a'] },
|
||||
]),
|
||||
CycleError,
|
||||
);
|
||||
});
|
||||
|
||||
test('point buy budget', () => {
|
||||
const rule = {
|
||||
id: 'pb',
|
||||
type: 'point_buy' as const,
|
||||
appliesTo: 'attribute' as const,
|
||||
params: {
|
||||
budget: 27,
|
||||
keys: ['str', 'dex', 'con', 'int', 'wis', 'cha'],
|
||||
},
|
||||
};
|
||||
const ok = validatePointBuy(rule, { str: 15, dex: 14, con: 13, int: 12, wis: 10, cha: 8 });
|
||||
assert.equal(ok.length, 0);
|
||||
const bad = validatePointBuy(rule, { str: 15, dex: 15, con: 15, int: 15, wis: 15, cha: 15 });
|
||||
assert.ok(bad.some((e) => e.code === 'point_buy_budget'));
|
||||
});
|
||||
|
||||
test('array permutation', () => {
|
||||
const rule = {
|
||||
id: 'arr',
|
||||
type: 'array' as const,
|
||||
appliesTo: 'attribute' as const,
|
||||
params: { array: [15, 14, 13, 12, 10, 8], keys: ['str', 'dex', 'con', 'int', 'wis', 'cha'] },
|
||||
};
|
||||
assert.equal(
|
||||
validateArray(rule, { str: 15, dex: 14, con: 13, int: 12, wis: 10, cha: 8 }).length,
|
||||
0,
|
||||
);
|
||||
assert.ok(validateArray(rule, { str: 15, dex: 15, con: 13, int: 12, wis: 10, cha: 8 }).length > 0);
|
||||
});
|
||||
|
||||
test('rollDice 4d6dl', () => {
|
||||
let i = 0;
|
||||
const seq = [0.0, 0.9, 0.5, 0.2]; // -> 1,6,4,2 drop 1 => 6+4+2=12
|
||||
const rolls = rollDice('4d6dl', () => seq[i++] ?? 0);
|
||||
assert.equal(sum(rolls), 12);
|
||||
});
|
||||
|
||||
test('snapshotHash stable', () => {
|
||||
const system = minimalSystem();
|
||||
const a = snapshotHash(system);
|
||||
const b = snapshotHash({ ...system, name: 'Other display name' });
|
||||
// name is not part of hash payload — hash equal
|
||||
assert.equal(a, b);
|
||||
const c = snapshotHash({ ...system, revision: 2 });
|
||||
assert.notEqual(a, c);
|
||||
});
|
||||
|
||||
test('canonicalJson sorts keys', () => {
|
||||
assert.equal(canonicalJson({ b: 1, a: 2 }), '{"a":2,"b":1}');
|
||||
});
|
||||
|
||||
test('validateSystemDefinition', () => {
|
||||
const r = validateSystemDefinition(minimalSystem());
|
||||
assert.equal(r.ok, true);
|
||||
});
|
||||
|
||||
test('validateCreationRules integrates point_buy', () => {
|
||||
const system = minimalSystem();
|
||||
const result = validateCreationRules(
|
||||
system,
|
||||
{ attributes: { str: 15, dex: 14, con: 13, int: 12, wis: 10, cha: 8 } },
|
||||
'point_buy',
|
||||
);
|
||||
assert.equal(result.ok, true);
|
||||
});
|
||||
|
||||
test('buildFormulaContext', () => {
|
||||
const ctx = buildFormulaContext({ attributes: { str: 10 }, resources: { hp: 8 } });
|
||||
assert.equal(ctx['attr.str'], 10);
|
||||
assert.equal(ctx['resource.hp'], 8);
|
||||
});
|
||||
|
||||
function minimalSystem(): SystemDefinition {
|
||||
return {
|
||||
format: SYSTEM_FORMAT,
|
||||
formatVersion: FORMAT_VERSION,
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
schemaVersion: 1,
|
||||
revision: 1,
|
||||
bricks: {
|
||||
attributes: [
|
||||
{ key: 'str', label: 'STR', scale: 'numeric', min: 8, max: 15, default: 10 },
|
||||
{ key: 'dex', label: 'DEX', scale: 'numeric', min: 8, max: 15, default: 10 },
|
||||
{ key: 'con', label: 'CON', scale: 'numeric', min: 8, max: 15, default: 10 },
|
||||
{ key: 'int', label: 'INT', scale: 'numeric', min: 8, max: 15, default: 10 },
|
||||
{ key: 'wis', label: 'WIS', scale: 'numeric', min: 8, max: 15, default: 10 },
|
||||
{ key: 'cha', label: 'CHA', scale: 'numeric', min: 8, max: 15, default: 10 },
|
||||
],
|
||||
derived: [
|
||||
{
|
||||
key: 'strMod',
|
||||
label: 'STR Mod',
|
||||
formula: 'floor((attr.str - 10) / 2)',
|
||||
dependsOn: ['attr.str'],
|
||||
},
|
||||
],
|
||||
},
|
||||
creationRules: [
|
||||
{
|
||||
id: 'pb',
|
||||
type: 'point_buy',
|
||||
appliesTo: 'attribute',
|
||||
params: {
|
||||
budget: 27,
|
||||
keys: ['str', 'dex', 'con', 'int', 'wis', 'cha'],
|
||||
},
|
||||
},
|
||||
],
|
||||
sheetLayout: {
|
||||
pageSize: 'fluid',
|
||||
grid: { size: 8 },
|
||||
pages: [{ id: 'main', label: 'Main', widgets: [] }],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type {
|
||||
CharacterValues,
|
||||
CreationRule,
|
||||
SystemDefinition,
|
||||
ValidationIssue,
|
||||
ValidationResult,
|
||||
} from './types';
|
||||
|
||||
function err(path: string, message: string, code: string): ValidationIssue {
|
||||
return { path, message, code };
|
||||
}
|
||||
|
||||
export function validatePointBuy(
|
||||
rule: CreationRule,
|
||||
attributes: Record<string, number | string | boolean>,
|
||||
): ValidationIssue[] {
|
||||
const budget = Number(rule.params.budget ?? 27);
|
||||
const costs = (rule.params.costs as Record<string, number> | undefined) ?? {
|
||||
'8': 0,
|
||||
'9': 1,
|
||||
'10': 2,
|
||||
'11': 3,
|
||||
'12': 4,
|
||||
'13': 5,
|
||||
'14': 7,
|
||||
'15': 9,
|
||||
};
|
||||
const keys = (rule.params.keys as string[] | undefined) ?? Object.keys(attributes);
|
||||
let spent = 0;
|
||||
const issues: ValidationIssue[] = [];
|
||||
for (const key of keys) {
|
||||
const raw = attributes[key];
|
||||
if (typeof raw !== 'number') {
|
||||
issues.push(err(`attributes.${key}`, 'Point buy requires numeric attribute', 'point_buy_type'));
|
||||
continue;
|
||||
}
|
||||
const c = costs[String(raw)];
|
||||
if (c === undefined) {
|
||||
issues.push(err(`attributes.${key}`, `Value ${raw} is not in point-buy cost table`, 'point_buy_value'));
|
||||
continue;
|
||||
}
|
||||
spent += c;
|
||||
}
|
||||
if (spent > budget) {
|
||||
issues.push(err('creation.point_buy', `Spent ${spent} exceeds budget ${budget}`, 'point_buy_budget'));
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function validateRoll(
|
||||
rule: CreationRule,
|
||||
values: CharacterValues,
|
||||
): ValidationIssue[] {
|
||||
const issues: ValidationIssue[] = [];
|
||||
const keys = (rule.params.keys as string[] | undefined) ?? [];
|
||||
const min = Number(rule.params.min ?? 3);
|
||||
const max = Number(rule.params.max ?? 18);
|
||||
for (const key of keys) {
|
||||
const v = values.attributes?.[key];
|
||||
if (typeof v !== 'number') {
|
||||
issues.push(err(`attributes.${key}`, 'Roll strategy expects a number', 'roll_type'));
|
||||
continue;
|
||||
}
|
||||
if (v < min || v > max) {
|
||||
issues.push(err(`attributes.${key}`, `Value ${v} out of roll range ${min}-${max}`, 'roll_range'));
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function validateArray(
|
||||
rule: CreationRule,
|
||||
attributes: Record<string, number | string | boolean>,
|
||||
): ValidationIssue[] {
|
||||
const array = (rule.params.array as number[] | undefined) ?? [15, 14, 13, 12, 10, 8];
|
||||
const keys = (rule.params.keys as string[] | undefined) ?? Object.keys(attributes);
|
||||
const used = keys.map((k) => attributes[k]).filter((v): v is number => typeof v === 'number');
|
||||
const sortedUsed = [...used].sort((a, b) => b - a);
|
||||
const sortedArray = [...array].sort((a, b) => b - a);
|
||||
if (sortedUsed.length !== sortedArray.length || sortedUsed.some((v, i) => v !== sortedArray[i])) {
|
||||
return [err('creation.array', 'Attributes must be a permutation of the fixed array', 'array_mismatch')];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function validateCreationRules(
|
||||
system: SystemDefinition,
|
||||
values: CharacterValues,
|
||||
activeStrategy?: string,
|
||||
): ValidationResult {
|
||||
const errors: ValidationIssue[] = [];
|
||||
const rules = activeStrategy
|
||||
? system.creationRules.filter((r) => r.type === activeStrategy)
|
||||
: system.creationRules;
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.type === 'point_buy') errors.push(...validatePointBuy(rule, values.attributes ?? {}));
|
||||
if (rule.type === 'roll') errors.push(...validateRoll(rule, values));
|
||||
if (rule.type === 'array') errors.push(...validateArray(rule, values.attributes ?? {}));
|
||||
}
|
||||
|
||||
for (const attr of system.bricks.attributes ?? []) {
|
||||
const v = values.attributes?.[attr.key];
|
||||
if (v === undefined) continue;
|
||||
if (typeof v === 'number') {
|
||||
if (attr.min !== undefined && v < attr.min) {
|
||||
errors.push(err(`attributes.${attr.key}`, `Below min ${attr.min}`, 'attr_min'));
|
||||
}
|
||||
if (attr.max !== undefined && v > attr.max) {
|
||||
errors.push(err(`attributes.${attr.key}`, `Above max ${attr.max}`, 'attr_max'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/** Deterministic dice roller for tests / roll strategy. */
|
||||
export function rollDice(expr: string, rng: () => number = Math.random): number[] {
|
||||
// formats: NdM, NdMdl (drop lowest), NdMdh (drop highest)
|
||||
const m = /^(\d+)d(\d+)(dl|dh)?$/i.exec(expr.trim());
|
||||
if (!m) throw new Error(`Unsupported dice expression '${expr}'`);
|
||||
const n = Number(m[1]);
|
||||
const sides = Number(m[2]);
|
||||
const drop = m[3]?.toLowerCase();
|
||||
const rolls: number[] = [];
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
rolls.push(1 + Math.floor(rng() * sides));
|
||||
}
|
||||
if (drop === 'dl') {
|
||||
const idx = rolls.indexOf(Math.min(...rolls));
|
||||
return rolls.filter((_, i) => i !== idx);
|
||||
}
|
||||
if (drop === 'dh') {
|
||||
const idx = rolls.indexOf(Math.max(...rolls));
|
||||
return rolls.filter((_, i) => i !== idx);
|
||||
}
|
||||
return rolls;
|
||||
}
|
||||
|
||||
export function sum(nums: number[]): number {
|
||||
return nums.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { DerivedDefinition } from './types';
|
||||
import { buildFormulaContext, evaluateFormula } from './formula';
|
||||
|
||||
export class CycleError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'CycleError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Kahn topological sort of derived definitions by dependsOn keys (derived.* or attr.* etc.). */
|
||||
export function sortDerived(defs: DerivedDefinition[]): DerivedDefinition[] {
|
||||
const byKey = new Map(defs.map((d) => [d.key, d]));
|
||||
const indeg = new Map<string, number>();
|
||||
const adj = new Map<string, string[]>();
|
||||
|
||||
for (const d of defs) {
|
||||
indeg.set(d.key, 0);
|
||||
adj.set(d.key, []);
|
||||
}
|
||||
|
||||
for (const d of defs) {
|
||||
for (const dep of d.dependsOn) {
|
||||
const depKey = dep.startsWith('derived.') ? dep.slice('derived.'.length) : null;
|
||||
if (depKey && byKey.has(depKey)) {
|
||||
adj.get(depKey)!.push(d.key);
|
||||
indeg.set(d.key, (indeg.get(d.key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const queue = [...indeg.entries()].filter(([, n]) => n === 0).map(([k]) => k);
|
||||
const ordered: DerivedDefinition[] = [];
|
||||
while (queue.length) {
|
||||
const k = queue.shift()!;
|
||||
const def = byKey.get(k);
|
||||
if (def) ordered.push(def);
|
||||
for (const next of adj.get(k) ?? []) {
|
||||
const n = (indeg.get(next) ?? 0) - 1;
|
||||
indeg.set(next, n);
|
||||
if (n === 0) queue.push(next);
|
||||
}
|
||||
}
|
||||
|
||||
if (ordered.length !== defs.length) {
|
||||
throw new CycleError('Derived definitions contain a cycle');
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export function computeDerived(
|
||||
defs: DerivedDefinition[],
|
||||
values: {
|
||||
attributes?: Record<string, number | string | boolean>;
|
||||
skills?: Record<string, number | string | boolean>;
|
||||
resources?: Record<string, number>;
|
||||
},
|
||||
): Record<string, number> {
|
||||
const ordered = sortDerived(defs);
|
||||
const derived: Record<string, number> = {};
|
||||
for (const d of ordered) {
|
||||
const ctx = buildFormulaContext({ ...values, derived });
|
||||
derived[d.key] = evaluateFormula(d.formula, ctx, d.round ?? 'none');
|
||||
}
|
||||
return derived;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/** Minimal safe formula DSL for derived stats. */
|
||||
|
||||
export type FormulaRound = 'floor' | 'ceil' | 'round' | 'none';
|
||||
|
||||
export type FormulaContext = Record<string, number>;
|
||||
|
||||
const IDENT = /^(attr|skill|resource|derived|val)\.[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
function tokenize(input: string): string[] {
|
||||
const src = input.replace(/\s+/g, '');
|
||||
const tokens: string[] = [];
|
||||
let i = 0;
|
||||
while (i < src.length) {
|
||||
const ch = src[i]!;
|
||||
if ('+-*/%(),'.includes(ch)) {
|
||||
tokens.push(ch);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (/[0-9.]/.test(ch)) {
|
||||
let j = i + 1;
|
||||
while (j < src.length && /[0-9.]/.test(src[j]!)) j += 1;
|
||||
tokens.push(src.slice(i, j));
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
if (/[A-Za-z_]/.test(ch)) {
|
||||
let j = i + 1;
|
||||
while (j < src.length && /[A-Za-z0-9_.]/.test(src[j]!)) j += 1;
|
||||
tokens.push(src.slice(i, j));
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unexpected character '${ch}' in formula`);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
type Parser = {
|
||||
tokens: string[];
|
||||
i: number;
|
||||
ctx: FormulaContext;
|
||||
};
|
||||
|
||||
function peek(p: Parser): string | undefined {
|
||||
return p.tokens[p.i];
|
||||
}
|
||||
|
||||
function consume(p: Parser, expected?: string): string {
|
||||
const t = p.tokens[p.i];
|
||||
if (t === undefined) throw new Error('Unexpected end of formula');
|
||||
if (expected !== undefined && t !== expected) throw new Error(`Expected '${expected}', got '${t}'`);
|
||||
p.i += 1;
|
||||
return t;
|
||||
}
|
||||
|
||||
function parseExpression(p: Parser): number {
|
||||
let left = parseTerm(p);
|
||||
while (peek(p) === '+' || peek(p) === '-') {
|
||||
const op = consume(p);
|
||||
const right = parseTerm(p);
|
||||
left = op === '+' ? left + right : left - right;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
function parseTerm(p: Parser): number {
|
||||
let left = parseUnary(p);
|
||||
while (peek(p) === '*' || peek(p) === '/' || peek(p) === '%') {
|
||||
const op = consume(p);
|
||||
const right = parseUnary(p);
|
||||
if (op === '*') left *= right;
|
||||
else if (op === '/') left /= right;
|
||||
else left %= right;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
function parseUnary(p: Parser): number {
|
||||
if (peek(p) === '-') {
|
||||
consume(p);
|
||||
return -parseUnary(p);
|
||||
}
|
||||
if (peek(p) === '+') {
|
||||
consume(p);
|
||||
return parseUnary(p);
|
||||
}
|
||||
return parsePrimary(p);
|
||||
}
|
||||
|
||||
function parsePrimary(p: Parser): number {
|
||||
const t = peek(p);
|
||||
if (t === undefined) throw new Error('Unexpected end of formula');
|
||||
if (t === '(') {
|
||||
consume(p, '(');
|
||||
const v = parseExpression(p);
|
||||
consume(p, ')');
|
||||
return v;
|
||||
}
|
||||
if (/^[0-9]+(\.[0-9]+)?$/.test(t)) {
|
||||
consume(p);
|
||||
return Number(t);
|
||||
}
|
||||
if (IDENT.test(t)) {
|
||||
consume(p);
|
||||
const v = p.ctx[t];
|
||||
if (typeof v !== 'number' || Number.isNaN(v)) {
|
||||
throw new Error(`Unknown or non-numeric identifier '${t}'`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
// function call
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(t) && p.tokens[p.i + 1] === '(') {
|
||||
const name = consume(p);
|
||||
consume(p, '(');
|
||||
const args: number[] = [];
|
||||
if (peek(p) !== ')') {
|
||||
args.push(parseExpression(p));
|
||||
while (peek(p) === ',') {
|
||||
consume(p, ',');
|
||||
args.push(parseExpression(p));
|
||||
}
|
||||
}
|
||||
consume(p, ')');
|
||||
return callFn(name, args);
|
||||
}
|
||||
throw new Error(`Unexpected token '${t}'`);
|
||||
}
|
||||
|
||||
function callFn(name: string, args: number[]): number {
|
||||
switch (name) {
|
||||
case 'floor':
|
||||
return Math.floor(args[0] ?? 0);
|
||||
case 'ceil':
|
||||
return Math.ceil(args[0] ?? 0);
|
||||
case 'round':
|
||||
return Math.round(args[0] ?? 0);
|
||||
case 'abs':
|
||||
return Math.abs(args[0] ?? 0);
|
||||
case 'min':
|
||||
return Math.min(...args);
|
||||
case 'max':
|
||||
return Math.max(...args);
|
||||
case 'clamp': {
|
||||
const [v = 0, lo = 0, hi = 0] = args;
|
||||
return Math.min(hi, Math.max(lo, v));
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown function '${name}'`);
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateFormula(
|
||||
formula: string,
|
||||
ctx: FormulaContext,
|
||||
round: FormulaRound = 'none',
|
||||
): number {
|
||||
const p: Parser = { tokens: tokenize(formula), i: 0, ctx };
|
||||
const value = parseExpression(p);
|
||||
if (p.i !== p.tokens.length) throw new Error('Trailing tokens in formula');
|
||||
if (round === 'floor') return Math.floor(value);
|
||||
if (round === 'ceil') return Math.ceil(value);
|
||||
if (round === 'round') return Math.round(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildFormulaContext(values: {
|
||||
attributes?: Record<string, number | string | boolean>;
|
||||
skills?: Record<string, number | string | boolean>;
|
||||
resources?: Record<string, number>;
|
||||
derived?: Record<string, number>;
|
||||
}): FormulaContext {
|
||||
const ctx: FormulaContext = {};
|
||||
for (const [k, v] of Object.entries(values.attributes ?? {})) {
|
||||
if (typeof v === 'number') ctx[`attr.${k}`] = v;
|
||||
}
|
||||
for (const [k, v] of Object.entries(values.skills ?? {})) {
|
||||
if (typeof v === 'number') ctx[`skill.${k}`] = v;
|
||||
}
|
||||
for (const [k, v] of Object.entries(values.resources ?? {})) {
|
||||
ctx[`resource.${k}`] = v;
|
||||
}
|
||||
for (const [k, v] of Object.entries(values.derived ?? {})) {
|
||||
ctx[`derived.${k}`] = v;
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './types';
|
||||
export * from './formula';
|
||||
export * from './derived';
|
||||
export * from './creation';
|
||||
export * from './snapshot';
|
||||
export * from './systemValidate';
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import type { SystemDefinition } from './types';
|
||||
|
||||
/** Canonical JSON for stable hashing (sorted keys). */
|
||||
export function canonicalJson(value: unknown): string {
|
||||
return JSON.stringify(sortKeys(value));
|
||||
}
|
||||
|
||||
function sortKeys(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(sortKeys);
|
||||
if (value && typeof value === 'object') {
|
||||
const obj = value as Record<string, unknown>;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(obj).sort()) {
|
||||
out[key] = sortKeys(obj[key]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function snapshotHash(system: SystemDefinition): string {
|
||||
const payload = {
|
||||
id: system.id,
|
||||
schemaVersion: system.schemaVersion,
|
||||
revision: system.revision,
|
||||
bricks: system.bricks,
|
||||
creationRules: system.creationRules,
|
||||
sheetLayout: system.sheetLayout,
|
||||
compendium: system.compendium,
|
||||
};
|
||||
return createHash('sha256').update(canonicalJson(payload)).digest('hex');
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { SystemDefinition, ValidationIssue, ValidationResult } from './types';
|
||||
import { SYSTEM_FORMAT, FORMAT_VERSION } from './types';
|
||||
|
||||
function err(path: string, message: string, code: string): ValidationIssue {
|
||||
return { path, message, code };
|
||||
}
|
||||
|
||||
export function validateSystemDefinition(system: unknown): ValidationResult {
|
||||
const errors: ValidationIssue[] = [];
|
||||
if (!system || typeof system !== 'object') {
|
||||
return { ok: false, errors: [err('$', 'System must be an object', 'type')] };
|
||||
}
|
||||
const s = system as Partial<SystemDefinition>;
|
||||
if (s.format !== SYSTEM_FORMAT) errors.push(err('format', `Expected ${SYSTEM_FORMAT}`, 'format'));
|
||||
if (s.formatVersion !== FORMAT_VERSION) {
|
||||
errors.push(err('formatVersion', `Expected ${FORMAT_VERSION}`, 'formatVersion'));
|
||||
}
|
||||
if (!s.id || typeof s.id !== 'string') errors.push(err('id', 'id required', 'required'));
|
||||
if (!s.name || typeof s.name !== 'string') errors.push(err('name', 'name required', 'required'));
|
||||
if (typeof s.schemaVersion !== 'number') errors.push(err('schemaVersion', 'number required', 'type'));
|
||||
if (typeof s.revision !== 'number') errors.push(err('revision', 'number required', 'type'));
|
||||
if (!s.bricks || typeof s.bricks !== 'object') errors.push(err('bricks', 'bricks required', 'required'));
|
||||
if (!Array.isArray(s.creationRules)) errors.push(err('creationRules', 'array required', 'type'));
|
||||
if (!s.sheetLayout || !Array.isArray(s.sheetLayout.pages)) {
|
||||
errors.push(err('sheetLayout.pages', 'pages required', 'required'));
|
||||
}
|
||||
|
||||
const derived = s.bricks?.derived ?? [];
|
||||
const keys = new Set(derived.map((d) => d.key));
|
||||
for (const d of derived) {
|
||||
if (!d.formula) errors.push(err(`derived.${d.key}`, 'formula required', 'required'));
|
||||
for (const dep of d.dependsOn ?? []) {
|
||||
if (dep.startsWith('derived.') && !keys.has(dep.slice('derived.'.length))) {
|
||||
// allow forward refs; cycle checked elsewhere
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/** Portable TTRPG system / character contract (builder + player + future mobile). */
|
||||
|
||||
export const SYSTEM_FORMAT = 'ttrpg-system' as const;
|
||||
export const CHARACTER_FORMAT = 'ttrpg-character' as const;
|
||||
export const FORMAT_VERSION = 1 as const;
|
||||
|
||||
export type ScaleKind = 'numeric' | 'dice' | 'percent' | 'rank' | 'pool' | 'boolean';
|
||||
|
||||
export type AttributeDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
scale: ScaleKind;
|
||||
min?: number;
|
||||
max?: number;
|
||||
default?: number | string | boolean;
|
||||
category?: 'physical' | 'mental' | 'social' | 'other';
|
||||
costPerPoint?: number;
|
||||
};
|
||||
|
||||
export type SkillDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
linkedAttribute?: string;
|
||||
scale: ScaleKind;
|
||||
min?: number;
|
||||
max?: number;
|
||||
default?: number | string | boolean;
|
||||
};
|
||||
|
||||
export type ResourceDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
min?: number;
|
||||
max?: number | string;
|
||||
default?: number;
|
||||
track?: boolean;
|
||||
};
|
||||
|
||||
export type TraitDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
prerequisites?: string[];
|
||||
};
|
||||
|
||||
export type DerivedDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
formula: string;
|
||||
dependsOn: string[];
|
||||
round?: 'floor' | 'ceil' | 'round' | 'none';
|
||||
};
|
||||
|
||||
export type ProgressionNode = {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: 'career' | 'feat' | 'talent' | 'other';
|
||||
traitKey?: string;
|
||||
position?: { x: number; y: number };
|
||||
};
|
||||
|
||||
export type ProgressionEdge = {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
kind?: 'prerequisite' | 'exit' | 'unlock';
|
||||
};
|
||||
|
||||
export type ProgressionDefinition = {
|
||||
label?: string;
|
||||
graph: {
|
||||
nodes: ProgressionNode[];
|
||||
edges: ProgressionEdge[];
|
||||
};
|
||||
};
|
||||
|
||||
export type CreationStrategy =
|
||||
| 'point_buy'
|
||||
| 'roll'
|
||||
| 'array'
|
||||
| 'priority'
|
||||
| 'life_path'
|
||||
| 'funnel';
|
||||
|
||||
export type CreationRule = {
|
||||
id: string;
|
||||
type: CreationStrategy;
|
||||
appliesTo: 'attribute' | 'skill' | 'any';
|
||||
params: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type WidgetType =
|
||||
| 'text'
|
||||
| 'number'
|
||||
| 'modBadge'
|
||||
| 'skillRow'
|
||||
| 'skillList'
|
||||
| 'resourceBar'
|
||||
| 'checkbox'
|
||||
| 'tagList'
|
||||
| 'section'
|
||||
| 'portrait'
|
||||
| 'notes'
|
||||
| 'table'
|
||||
| 'repeatingTable'
|
||||
| 'statBlock'
|
||||
| 'dots'
|
||||
| 'percent'
|
||||
| 'rankEnum'
|
||||
| 'track'
|
||||
| 'clock'
|
||||
| 'slotMatrix'
|
||||
| 'diceStep'
|
||||
| 'locationBar'
|
||||
| 'spellCards'
|
||||
| 'unknownWidget';
|
||||
|
||||
export type WidgetFrame = { x: number; y: number; w: number; h: number };
|
||||
|
||||
export type SheetWidget = {
|
||||
id: string;
|
||||
type: WidgetType;
|
||||
bind?: string;
|
||||
frame: WidgetFrame;
|
||||
responsive?: { sm?: Partial<WidgetFrame> & { hide?: boolean; stack?: boolean } };
|
||||
props?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type SheetPage = {
|
||||
id: string;
|
||||
label: string;
|
||||
widgets: SheetWidget[];
|
||||
};
|
||||
|
||||
export type SheetLayout = {
|
||||
pageSize: 'letter' | 'a4' | 'fluid' | { w: number; h: number };
|
||||
grid: { size: number; columns?: number };
|
||||
pages: SheetPage[];
|
||||
};
|
||||
|
||||
export type CompendiumSpell = {
|
||||
id: string;
|
||||
name: string;
|
||||
level: number;
|
||||
school?: string;
|
||||
source?: string;
|
||||
classes?: string[];
|
||||
castingTime?: string;
|
||||
range?: string;
|
||||
components?: string;
|
||||
duration?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type SystemBricks = {
|
||||
attributes?: AttributeDefinition[];
|
||||
skills?: SkillDefinition[];
|
||||
resources?: ResourceDefinition[];
|
||||
traits?: TraitDefinition[];
|
||||
derived?: DerivedDefinition[];
|
||||
progression?: ProgressionDefinition;
|
||||
constraints?: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
export type SystemDefinition = {
|
||||
format: typeof SYSTEM_FORMAT;
|
||||
formatVersion: typeof FORMAT_VERSION;
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
edition?: string;
|
||||
schemaVersion: number;
|
||||
revision: number;
|
||||
bricks: SystemBricks;
|
||||
creationRules: CreationRule[];
|
||||
sheetLayout: SheetLayout;
|
||||
compendium?: {
|
||||
spells?: CompendiumSpell[];
|
||||
};
|
||||
theme?: { tokenOverrides?: Record<string, string> };
|
||||
};
|
||||
|
||||
export type CharacterValues = {
|
||||
meta?: Record<string, unknown>;
|
||||
attributes?: Record<string, number | string | boolean>;
|
||||
skills?: Record<string, number | string | boolean>;
|
||||
resources?: Record<string, number>;
|
||||
traits?: string[];
|
||||
ownedTraitIds?: string[];
|
||||
spells?: Array<{ id: string; prepared?: boolean; notes?: string }>;
|
||||
inventory?: Array<Record<string, unknown>>;
|
||||
derived?: Record<string, number>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type CharacterSheet = {
|
||||
format: typeof CHARACTER_FORMAT;
|
||||
formatVersion: typeof FORMAT_VERSION;
|
||||
id: string;
|
||||
name: string;
|
||||
systemId: string;
|
||||
schemaVersion: number;
|
||||
revisionAtCreate: number;
|
||||
values: CharacterValues;
|
||||
creation?: {
|
||||
strategy?: CreationStrategy;
|
||||
seed?: number;
|
||||
rolls?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type ValidationIssue = {
|
||||
path: string;
|
||||
message: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export type ValidationResult = {
|
||||
ok: boolean;
|
||||
errors: ValidationIssue[];
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
FIELD_STAMP_MAX_SPRITES,
|
||||
decimateFieldStampPoints,
|
||||
fieldStampMinSpacingN,
|
||||
} from './fieldEffectStampPoints';
|
||||
import type { NPoint } from './types/effects';
|
||||
|
||||
function pt(x: number, y: number, tMs = 0): NPoint {
|
||||
return { x, y, tMs };
|
||||
}
|
||||
|
||||
void test('decimateFieldStampPoints: пустой и одна точка', () => {
|
||||
assert.deepEqual(decimateFieldStampPoints([], 0.05), []);
|
||||
const one = [pt(0.2, 0.3)];
|
||||
assert.deepEqual(decimateFieldStampPoints(one, 0.05), one);
|
||||
});
|
||||
|
||||
void test('decimateFieldStampPoints: оставляет концы и выкидывает близкие середины', () => {
|
||||
const points = [pt(0, 0.5), pt(0.01, 0.5), pt(0.02, 0.5), pt(0.5, 0.5), pt(0.51, 0.5), pt(1, 0.5)];
|
||||
const out = decimateFieldStampPoints(points, 0.1);
|
||||
assert.equal(out[0]?.x, 0);
|
||||
assert.equal(out[out.length - 1]?.x, 1);
|
||||
assert.ok(out.length < points.length);
|
||||
assert.ok(out.length >= 3);
|
||||
});
|
||||
|
||||
void test('decimateFieldStampPoints: maxPoints режет длинный штрих', () => {
|
||||
const points: NPoint[] = [];
|
||||
for (let i = 0; i <= 200; i += 1) {
|
||||
points.push(pt(i / 200, 0.4, i));
|
||||
}
|
||||
const out = decimateFieldStampPoints(points, 0.001, 16);
|
||||
assert.ok(out.length <= 16);
|
||||
assert.equal(out[0]?.x, 0);
|
||||
assert.equal(out[out.length - 1]?.x, 1);
|
||||
});
|
||||
|
||||
void test('fieldStampMinSpacingN: огонь реже тумана', () => {
|
||||
const r = 0.04;
|
||||
assert.ok(fieldStampMinSpacingN('fire', r) > fieldStampMinSpacingN('fog', r));
|
||||
assert.ok(fieldStampMinSpacingN('fog', r) >= 0.006);
|
||||
assert.equal(FIELD_STAMP_MAX_SPRITES, 40);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { NPoint } from './types/effects';
|
||||
|
||||
/** Верхняя граница спрайтов на один штрих тумана/огня/дождя. */
|
||||
export const FIELD_STAMP_MAX_SPRITES = 40;
|
||||
|
||||
/**
|
||||
* Минимальное расстояние между центрами спрайтов (норм. 0..1).
|
||||
* Огонь крупнее пятна радиуса — реже ставим штампы.
|
||||
*/
|
||||
export function fieldStampMinSpacingN(type: 'fog' | 'fire' | 'rain', radiusN: number): number {
|
||||
const factor = type === 'fire' ? 0.75 : type === 'rain' ? 0.55 : 0.5;
|
||||
return Math.max(0.006, radiusN * factor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Оставляет концы штриха и точки не ближе `minSpacingN`.
|
||||
* Если точек всё ещё больше `maxPoints` — равномерный stride.
|
||||
*/
|
||||
export function decimateFieldStampPoints(
|
||||
points: readonly NPoint[],
|
||||
minSpacingN: number,
|
||||
maxPoints: number = FIELD_STAMP_MAX_SPRITES,
|
||||
): NPoint[] {
|
||||
if (points.length <= 1) return points.slice();
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
if (!first || !last) {
|
||||
return points.filter((p): p is NPoint => Boolean(p));
|
||||
}
|
||||
|
||||
const cap = Math.max(2, Math.floor(maxPoints));
|
||||
const minDistSq = Math.max(0, minSpacingN) * Math.max(0, minSpacingN);
|
||||
const kept: NPoint[] = [first];
|
||||
for (let i = 1; i < points.length - 1; i += 1) {
|
||||
const p = points[i];
|
||||
const prev = kept[kept.length - 1];
|
||||
if (!p || !prev) continue;
|
||||
const dx = p.x - prev.x;
|
||||
const dy = p.y - prev.y;
|
||||
if (dx * dx + dy * dy >= minDistSq) kept.push(p);
|
||||
}
|
||||
const tail = kept[kept.length - 1];
|
||||
if (!tail) {
|
||||
kept.push(last);
|
||||
} else {
|
||||
const dx = last.x - tail.x;
|
||||
const dy = last.y - tail.y;
|
||||
if (dx * dx + dy * dy > 1e-12) kept.push(last);
|
||||
}
|
||||
|
||||
if (kept.length <= cap) return kept;
|
||||
|
||||
const reduced: NPoint[] = [];
|
||||
const lastIdx = kept.length - 1;
|
||||
const stride = Math.max(1, Math.ceil(lastIdx / (cap - 1)));
|
||||
for (let i = 0; i < kept.length; i += stride) {
|
||||
const p = kept[i];
|
||||
if (p) reduced.push(p);
|
||||
}
|
||||
const reducedTail = reduced[reduced.length - 1];
|
||||
if (reducedTail !== last) reduced.push(last);
|
||||
if (reduced.length > cap) {
|
||||
const head = reduced[0];
|
||||
if (!head) return kept.slice(0, cap);
|
||||
return [head, ...reduced.slice(reduced.length - (cap - 1))];
|
||||
}
|
||||
return reduced;
|
||||
}
|
||||
@@ -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: [],
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
asProjectId,
|
||||
asSceneId,
|
||||
asSceneNpcTokenId,
|
||||
asSceneTokenId,
|
||||
asTokenId,
|
||||
} from '../types/ids';
|
||||
import { PROJECT_SCHEMA_VERSION } from '../types';
|
||||
|
||||
@@ -21,8 +23,10 @@ import {
|
||||
findSceneTitleConflicts,
|
||||
listExportedNpcsFromBundle,
|
||||
listImportableStorylines,
|
||||
collectTokenIdsFromProject,
|
||||
mergeStorylinesIntoProject,
|
||||
newExportBundleProjectId,
|
||||
remapProjectSceneTokenIds,
|
||||
selectNpcsByIds,
|
||||
} from './storylineExportImport';
|
||||
|
||||
@@ -39,6 +43,7 @@ function scene(id: string, title: string): Scene {
|
||||
previewVideoAutostart: false,
|
||||
previewRotationDeg: 0,
|
||||
darkenScene: false,
|
||||
darknessStrokes: [],
|
||||
traps: [],
|
||||
tokens: [],
|
||||
npcTokens: [],
|
||||
@@ -550,3 +555,48 @@ void test('mergeStorylinesIntoProject: reuse existing scene by resolution', () =
|
||||
assert.equal(merged.sceneGraphNodes.length, 1);
|
||||
assert.equal(merged.sceneGraphNodes[0]!.sceneId, targetSceneId);
|
||||
});
|
||||
|
||||
void test('collectTokenIdsFromProject: собирает id неигровых токенов со сцен', () => {
|
||||
const s1 = scene('s1', 'Start');
|
||||
s1.tokens = [
|
||||
{
|
||||
id: asSceneTokenId('st1'),
|
||||
tokenId: asTokenId('tok_a'),
|
||||
nx: 0.2,
|
||||
ny: 0.3,
|
||||
sizeN: 0.08,
|
||||
rotationDeg: 0,
|
||||
},
|
||||
{
|
||||
id: asSceneTokenId('st2'),
|
||||
tokenId: asTokenId('tok_b'),
|
||||
nx: 0.5,
|
||||
ny: 0.5,
|
||||
sizeN: 0.08,
|
||||
rotationDeg: 0,
|
||||
},
|
||||
];
|
||||
const project = minimalProject({
|
||||
scenes: { [asSceneId('s1')]: s1 },
|
||||
sceneGraphNodes: [node('main', 's1', { isStartScene: true })],
|
||||
});
|
||||
const ids = collectTokenIdsFromProject(project).map(String).sort();
|
||||
assert.deepEqual(ids, ['tok_a', 'tok_b']);
|
||||
});
|
||||
|
||||
void test('remapProjectSceneTokenIds: переписывает tokenId на сценах', () => {
|
||||
const s1 = scene('s1', 'Start');
|
||||
s1.tokens = [
|
||||
{
|
||||
id: asSceneTokenId('st1'),
|
||||
tokenId: asTokenId('old'),
|
||||
nx: 0.2,
|
||||
ny: 0.3,
|
||||
sizeN: 0.08,
|
||||
rotationDeg: 0,
|
||||
},
|
||||
];
|
||||
const project = minimalProject({ scenes: { [asSceneId('s1')]: s1 } });
|
||||
const remapped = remapProjectSceneTokenIds(project, new Map([['old', 'new']]));
|
||||
assert.equal(String(remapped.scenes[asSceneId('s1')]!.tokens[0]!.tokenId), 'new');
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ void test('contracts: players and sceneNpcTokensSession channels exist', () => {
|
||||
assert.match(src, /sceneNpcTokensSession:\s*\{/);
|
||||
assert.match(src, /npcMapSpawn:\s*\{/);
|
||||
assert.match(src, /scenePlayerTokensSession:\s*\{/);
|
||||
assert.match(src, /openInstructions:\s*'windows\.openInstructions'/);
|
||||
assert.match(src, /instructionsSection:\s*'windows\.instructionsSection'/);
|
||||
assert.match(src, /tokenGridSnap:\s*\{/);
|
||||
assert.match(src, /tokenGridSnap\.setEnabled/);
|
||||
assert.match(src, /npcTokens\?:\s*SceneNpcToken\[\]/);
|
||||
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
DisplayInfo,
|
||||
Scene,
|
||||
SceneDarknessEvent,
|
||||
SceneDarknessRevealStroke,
|
||||
SceneDarknessState,
|
||||
SceneMarkerEvent,
|
||||
SceneMarkerState,
|
||||
@@ -76,11 +77,22 @@ import type {
|
||||
StorylineListItem,
|
||||
StorylineSelection,
|
||||
} from '../graph/storylineExportImport';
|
||||
import type { SystemDefinition } from '../characterSheet/types';
|
||||
|
||||
/** Catalog row for imported TTRPG systems (userData/game-systems). */
|
||||
export type GameSystemListItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
edition?: string;
|
||||
schemaVersion: number;
|
||||
revision: number;
|
||||
};
|
||||
|
||||
export const ipcChannels = {
|
||||
app: {
|
||||
quit: 'app.quit',
|
||||
getVersion: 'app.getVersion',
|
||||
openExternal: 'app.openExternal',
|
||||
},
|
||||
updater: {
|
||||
check: 'updater.check',
|
||||
@@ -155,9 +167,16 @@ 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',
|
||||
requestCloseMultiWindowConfirm: 'windows.requestCloseMultiWindowConfirm',
|
||||
closeMultiWindowConfirmRequested: 'windows.closeMultiWindowConfirmRequested',
|
||||
togglePresentationFullscreen: 'windows.togglePresentationFullscreen',
|
||||
getMultiWindowState: 'windows.getMultiWindowState',
|
||||
multiWindowStateChanged: 'windows.multiWindowStateChanged',
|
||||
@@ -165,6 +184,9 @@ export const ipcChannels = {
|
||||
closeSceneDescription: 'windows.closeSceneDescription',
|
||||
getSceneDescriptionContent: 'windows.getSceneDescriptionContent',
|
||||
sceneDescriptionContent: 'windows.sceneDescriptionContent',
|
||||
openInstructions: 'windows.openInstructions',
|
||||
getInstructionsSection: 'windows.getInstructionsSection',
|
||||
instructionsSection: 'windows.instructionsSection',
|
||||
openMaterials: 'windows.openMaterials',
|
||||
closeMaterials: 'windows.closeMaterials',
|
||||
openPlaylists: 'windows.openPlaylists',
|
||||
@@ -284,6 +306,14 @@ export const ipcChannels = {
|
||||
upsertProgress: 'players.upsertProgress',
|
||||
stateChanged: 'players.stateChanged',
|
||||
},
|
||||
gameSystems: {
|
||||
list: 'gameSystems.list',
|
||||
importFromFile: 'gameSystems.importFromFile',
|
||||
delete: 'gameSystems.delete',
|
||||
get: 'gameSystems.get',
|
||||
bindSnapshot: 'gameSystems.bindSnapshot',
|
||||
getSnapshot: 'gameSystems.getSnapshot',
|
||||
},
|
||||
playlists: {
|
||||
getState: 'playlists.getState',
|
||||
create: 'playlists.create',
|
||||
@@ -408,8 +438,10 @@ export type IpcEventMap = {
|
||||
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
||||
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
|
||||
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
|
||||
[ipcChannels.windows.closeMultiWindowConfirmRequested]: Record<string, never>;
|
||||
[ipcChannels.windows.presentationContentSizeChanged]: { width: number; height: number };
|
||||
[ipcChannels.windows.sceneDescriptionContent]: { html: string };
|
||||
[ipcChannels.windows.instructionsSection]: { section: string };
|
||||
[ipcChannels.project.importZipProgress]: ZipProgressEvent;
|
||||
[ipcChannels.project.exportZipProgress]: ZipProgressEvent;
|
||||
[ipcChannels.project.scenePreviewImportProgress]: ScenePreviewImportEvent;
|
||||
@@ -427,6 +459,10 @@ export type IpcInvokeMap = {
|
||||
req: Record<string, never>;
|
||||
res: { version: string; buildNumber: string | null; packaged: boolean };
|
||||
};
|
||||
[ipcChannels.app.openExternal]: {
|
||||
req: { url: string };
|
||||
res: { ok: true } | { ok: false; error: string };
|
||||
};
|
||||
[ipcChannels.updater.check]: {
|
||||
req: Record<string, never>;
|
||||
res: UpdaterCheckResponse;
|
||||
@@ -757,13 +793,29 @@ 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>;
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.windows.requestCloseMultiWindowConfirm]: {
|
||||
req: Record<string, never>;
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.windows.togglePresentationFullscreen]: {
|
||||
req: Record<string, never>;
|
||||
res: { ok: true; isFullScreen: boolean };
|
||||
@@ -792,6 +844,14 @@ export type IpcInvokeMap = {
|
||||
req: { html: string };
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.windows.openInstructions]: {
|
||||
req: { section?: string };
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.windows.getInstructionsSection]: {
|
||||
req: Record<string, never>;
|
||||
res: { section: string };
|
||||
};
|
||||
[ipcChannels.windows.closeSceneDescription]: {
|
||||
req: Record<string, never>;
|
||||
res: { ok: true };
|
||||
@@ -1052,6 +1112,30 @@ export type IpcInvokeMap = {
|
||||
req: { id: PlayerId };
|
||||
res: { url: string | null };
|
||||
};
|
||||
[ipcChannels.gameSystems.list]: {
|
||||
req: Record<string, never>;
|
||||
res: { systems: GameSystemListItem[] };
|
||||
};
|
||||
[ipcChannels.gameSystems.importFromFile]: {
|
||||
req: Record<string, never>;
|
||||
res: { canceled: true } | { canceled: false; system: GameSystemListItem };
|
||||
};
|
||||
[ipcChannels.gameSystems.delete]: {
|
||||
req: { id: string };
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.gameSystems.get]: {
|
||||
req: { id: string };
|
||||
res: { system: SystemDefinition | null };
|
||||
};
|
||||
[ipcChannels.gameSystems.bindSnapshot]: {
|
||||
req: { systemId: string };
|
||||
res: { systemId: string; snapshotHash: string; system: SystemDefinition };
|
||||
};
|
||||
[ipcChannels.gameSystems.getSnapshot]: {
|
||||
req: { hash: string };
|
||||
res: { system: SystemDefinition | null };
|
||||
};
|
||||
[ipcChannels.playlists.getState]: {
|
||||
req: Record<string, never>;
|
||||
res: PlaylistsState;
|
||||
@@ -1202,6 +1286,7 @@ export type ScenePatch = {
|
||||
previewVideoAutostart?: boolean;
|
||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
||||
darkenScene?: boolean;
|
||||
darknessStrokes?: SceneDarknessRevealStroke[];
|
||||
traps?: SceneTrap[];
|
||||
tokens?: SceneToken[];
|
||||
npcTokens?: SceneNpcToken[];
|
||||
|
||||
@@ -31,6 +31,16 @@ export type AppPlayer = {
|
||||
imageOffset: PlayerImageOffset;
|
||||
/** Масштаб аватара внутри круга (1 = по умолчанию). */
|
||||
imageScale: number;
|
||||
/** Optional TTRPG system id from Game Systems catalog. */
|
||||
systemId?: string | null;
|
||||
/** Frozen sheet binding: snapshotHash + values (schema freeze on bind). */
|
||||
characterSheet?: {
|
||||
systemId: string;
|
||||
snapshotHash: string;
|
||||
schemaVersion: number;
|
||||
revisionAtBind: number;
|
||||
values: Record<string, unknown>;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/** Расстановка кампанийного НПС на карте как игрового токена (в проекте). */
|
||||
@@ -74,6 +84,8 @@ export type SceneNpcTokensSessionPlacement = {
|
||||
disposition?: NpcDisposition;
|
||||
/** Session-only: серая рамка и grayscale. */
|
||||
inactive?: boolean;
|
||||
/** Session-only: токен виден мастеру, но не на презентации. */
|
||||
hiddenFromPlayers?: boolean;
|
||||
};
|
||||
|
||||
/** Подкрепление: токен только на сессию, не пишется в scene.npcTokens. */
|
||||
@@ -100,6 +112,7 @@ export type SceneNpcTokensSessionEvent =
|
||||
| { kind: 'setSize'; placementId: string; sizeN: number }
|
||||
| { kind: 'setDisposition'; placementId: string; disposition: NpcDisposition }
|
||||
| { kind: 'setInactive'; placementId: string; inactive: boolean }
|
||||
| { kind: 'setHiddenFromPlayers'; placementId: string; hidden: boolean }
|
||||
| { kind: 'setScale'; scale: number }
|
||||
| {
|
||||
kind: 'spawn';
|
||||
@@ -276,7 +289,7 @@ export function normalizeAppPlayer(raw: unknown, teamIds?: Set<string>): AppPlay
|
||||
if (typeof obj.teamId === 'string' && obj.teamId) {
|
||||
if (!teamIds || teamIds.has(obj.teamId)) teamId = asPlayerTeamId(obj.teamId);
|
||||
}
|
||||
return {
|
||||
const out: AppPlayer = {
|
||||
id: asPlayerId(obj.id),
|
||||
name,
|
||||
imageRelPath: obj.imageRelPath,
|
||||
@@ -286,6 +299,35 @@ export function normalizeAppPlayer(raw: unknown, teamIds?: Set<string>): AppPlay
|
||||
imageOffset: clampPlayerImageOffset(obj.imageOffset),
|
||||
imageScale: clampPlayerImageScale((obj as { imageScale?: unknown }).imageScale),
|
||||
};
|
||||
if (obj.systemId === null) out.systemId = null;
|
||||
else if (typeof obj.systemId === 'string') out.systemId = obj.systemId;
|
||||
if (obj.characterSheet === null) out.characterSheet = null;
|
||||
else if (obj.characterSheet && typeof obj.characterSheet === 'object') {
|
||||
const cs = obj.characterSheet as {
|
||||
systemId?: unknown;
|
||||
snapshotHash?: unknown;
|
||||
schemaVersion?: unknown;
|
||||
revisionAtBind?: unknown;
|
||||
values?: unknown;
|
||||
};
|
||||
if (
|
||||
typeof cs.systemId === 'string' &&
|
||||
typeof cs.snapshotHash === 'string' &&
|
||||
typeof cs.schemaVersion === 'number' &&
|
||||
typeof cs.revisionAtBind === 'number' &&
|
||||
cs.values &&
|
||||
typeof cs.values === 'object'
|
||||
) {
|
||||
out.characterSheet = {
|
||||
systemId: cs.systemId,
|
||||
snapshotHash: cs.snapshotHash,
|
||||
schemaVersion: cs.schemaVersion,
|
||||
revisionAtBind: cs.revisionAtBind,
|
||||
values: cs.values as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeSceneNpcToken(raw: unknown): SceneNpcToken | null {
|
||||
|
||||
@@ -14,8 +14,10 @@ export type DiceRollRequest = {
|
||||
|
||||
export type DiceRollState = {
|
||||
revision: number;
|
||||
/** Идёт показ броска (оверлей виден). */
|
||||
/** Идёт показ броска (оверлей виден на презентации). */
|
||||
active: boolean;
|
||||
/** Показывать 3D-анимацию игрокам на экране презентации. */
|
||||
visibleToPlayers: boolean;
|
||||
/** Например "2d20". */
|
||||
notation: string;
|
||||
/** Заранее выбранные исходы — для синхрона пульт/презентация. */
|
||||
@@ -27,6 +29,7 @@ export type DiceRollState = {
|
||||
|
||||
export type DiceRollEvent =
|
||||
| { kind: 'roll'; count: number; sides: DiceSides }
|
||||
| { kind: 'setVisibleToPlayers'; visible: boolean }
|
||||
| { kind: 'hideOverlay' }
|
||||
| { kind: 'clear' };
|
||||
|
||||
@@ -34,6 +37,7 @@ export function createInitialDiceRollState(): DiceRollState {
|
||||
return {
|
||||
revision: 1,
|
||||
active: false,
|
||||
visibleToPlayers: true,
|
||||
notation: '',
|
||||
values: [],
|
||||
total: 0,
|
||||
@@ -41,9 +45,13 @@ export function createInitialDiceRollState(): DiceRollState {
|
||||
};
|
||||
}
|
||||
|
||||
/** Минимум / максимум количества кубиков в UI и store. */
|
||||
export const DICE_COUNT_MIN = 0;
|
||||
export const DICE_COUNT_MAX = 20;
|
||||
|
||||
export function clampDiceCount(n: number): number {
|
||||
if (!Number.isFinite(n)) return 1;
|
||||
return Math.max(1, Math.min(20, Math.round(n)));
|
||||
if (!Number.isFinite(n)) return DICE_COUNT_MIN;
|
||||
return Math.max(DICE_COUNT_MIN, Math.min(DICE_COUNT_MAX, Math.round(n)));
|
||||
}
|
||||
|
||||
/** Грани d100 в dice-box-threejs: 10,20,…,90,100 (лицо «00»). */
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user