Compare commits
8 Commits
3d0474fe97
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c0c72892d | |||
| 877fbaa8e7 | |||
| f6c301a12b | |||
| 060bbd471a | |||
| d228b9aab7 | |||
| 143d141aa6 | |||
| a29c72b307 | |||
| 712086ddca |
@@ -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');
|
||||
}
|
||||
+337
-19
@@ -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,7 +20,9 @@ import {
|
||||
stripProjectZipExtension,
|
||||
} from '../shared/project/projectZipExtension';
|
||||
import type { Project } from '../shared/types';
|
||||
import { asNpcId } from '../shared/types/ids';
|
||||
import { canonicalGameSave, freezeTokenPathPlayback, pathForPlacement } from '../shared/gameSave/buildSnapshot';
|
||||
import { GAME_SAVE_SCHEMA_VERSION, type GameSaveBody, type GameSaveFile } from '../shared/types/gameSave';
|
||||
import { asNpcId, asSceneId, type MaterialId } from '../shared/types/ids';
|
||||
import { listVisibleSceneNpcTokens } from '../shared/types/appPlayers';
|
||||
import { remapProjectSceneTokenIds } from '../shared/graph/storylineExportImport';
|
||||
import {
|
||||
@@ -27,7 +30,9 @@ import {
|
||||
previewRotationStepsCw,
|
||||
} from '../shared/types/scenePreviewRotation';
|
||||
|
||||
import { getSessionTimerRemainingMs } from '../shared/types/sessionTimer';
|
||||
import { EffectsStore, effectsDefaultTool } from './effects/effectsStore';
|
||||
import { GameSavesStore } from './gameSave/gameSavesStore';
|
||||
import { SceneDarknessStore } from './effects/sceneDarknessStore';
|
||||
import { SceneMarkerStore } from './effects/sceneMarkerStore';
|
||||
import { SceneTrapsStore } from './sceneTraps/sceneTrapsStore';
|
||||
@@ -43,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';
|
||||
@@ -81,6 +87,8 @@ import {
|
||||
openTokenPathEditorWindow,
|
||||
openNpcsWindow,
|
||||
openSceneDescriptionWindow,
|
||||
openInstructionsWindow,
|
||||
getInstructionsSection,
|
||||
closeMaterialsWindow,
|
||||
closePlaylistsWindow,
|
||||
closeTimerWindow,
|
||||
@@ -90,7 +98,10 @@ import {
|
||||
closeSceneEditorWindow,
|
||||
closeTokenPathEditorWindow,
|
||||
closeNpcsWindow,
|
||||
requestCloseMultiWindowConfirm,
|
||||
sendToAppWindows,
|
||||
setDiceWindowClosedListener,
|
||||
setTimerWindowClosedListener,
|
||||
syncAllWindowChromeTitles,
|
||||
togglePresentationFullscreen,
|
||||
waitForEditorWindowReady,
|
||||
@@ -197,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 {
|
||||
@@ -293,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()) {
|
||||
@@ -317,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;
|
||||
@@ -481,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 {
|
||||
@@ -633,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(() => {
|
||||
@@ -650,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();
|
||||
@@ -667,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();
|
||||
@@ -692,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();
|
||||
@@ -710,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 };
|
||||
@@ -739,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 };
|
||||
@@ -903,6 +1165,8 @@ async function main() {
|
||||
sceneNpcTokensSessionStore.reset();
|
||||
clearNpcMapSpawnDrag();
|
||||
scenePlayerTokensSessionStore.reset();
|
||||
effectsStore.resetSession();
|
||||
lastSaveCanonical = null;
|
||||
syncSceneMarkerForProject(project);
|
||||
emitSceneViewState();
|
||||
emitSceneTokensSessionState();
|
||||
@@ -916,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();
|
||||
@@ -957,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();
|
||||
@@ -988,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();
|
||||
@@ -1022,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();
|
||||
}
|
||||
@@ -1623,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:
|
||||
@@ -1631,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);
|
||||
@@ -1641,7 +1914,8 @@ async function main() {
|
||||
}
|
||||
}
|
||||
emitPlaylistsState();
|
||||
}
|
||||
},
|
||||
);
|
||||
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
|
||||
emitSessionState();
|
||||
return { project };
|
||||
@@ -1945,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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,6 +108,7 @@ import {
|
||||
buildProjectFromFoundryDocuments,
|
||||
loadFoundryDocumentsForImport,
|
||||
type FoundryImportProgress,
|
||||
type FoundryPendingAppPlaylist,
|
||||
} from '../foundry/foundryImport';
|
||||
import { getAppSemanticVersion } from '../versionInfo';
|
||||
|
||||
@@ -772,6 +774,7 @@ export class ZipProjectStore {
|
||||
previewVideoAutostart: false,
|
||||
previewRotationDeg: 0,
|
||||
darkenScene: false,
|
||||
darknessStrokes: [],
|
||||
traps: [],
|
||||
tokens: [],
|
||||
npcTokens: [],
|
||||
@@ -798,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)),
|
||||
@@ -2124,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 {
|
||||
@@ -2154,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 {
|
||||
@@ -2619,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))
|
||||
@@ -2666,6 +2680,7 @@ function normalizeScene(s: Scene): Scene {
|
||||
previewVideoAutostart,
|
||||
previewRotationDeg,
|
||||
darkenScene,
|
||||
darknessStrokes,
|
||||
traps,
|
||||
tokens,
|
||||
npcTokens,
|
||||
|
||||
@@ -131,4 +131,29 @@ export class SceneTrapsStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exportByCard(): Record<string, Record<string, SceneTrapRuntime>> {
|
||||
if (this.currentKey !== null) {
|
||||
this.cache.set(this.currentKey, cloneById(this.state.byId));
|
||||
}
|
||||
const out: Record<string, Record<string, SceneTrapRuntime>> = {};
|
||||
for (const [key, byId] of this.cache) {
|
||||
const kept: Record<string, SceneTrapRuntime> = {};
|
||||
for (const [id, runtime] of Object.entries(byId)) {
|
||||
if (runtime.status === 'inactive' && !runtime.revealed) continue;
|
||||
kept[id] = { ...runtime };
|
||||
}
|
||||
if (Object.keys(kept).length > 0) out[key] = kept;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
importByCard(byCard: Record<string, Record<string, SceneTrapRuntime>>): void {
|
||||
this.cache.clear();
|
||||
this.currentKey = null;
|
||||
this.state = emptyState();
|
||||
for (const [key, byId] of Object.entries(byCard)) {
|
||||
this.cache.set(key, cloneById(byId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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://)', () => {
|
||||
|
||||
@@ -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 } : {}),
|
||||
@@ -444,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);
|
||||
}
|
||||
@@ -452,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;
|
||||
@@ -474,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();
|
||||
@@ -613,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');
|
||||
}
|
||||
@@ -745,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;
|
||||
@@ -821,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,
|
||||
@@ -203,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();
|
||||
@@ -210,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);
|
||||
@@ -1155,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);
|
||||
@@ -1224,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`;
|
||||
@@ -1936,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} />
|
||||
<>
|
||||
@@ -2104,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}>
|
||||
@@ -2382,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={
|
||||
@@ -2405,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
|
||||
@@ -2424,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={() => {
|
||||
@@ -2471,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>
|
||||
@@ -2483,7 +2611,7 @@ export function ControlApp() {
|
||||
<div
|
||||
ref={previewFrameRef}
|
||||
className={styles.previewFrame}
|
||||
title="Колесо — зум; перетаскивание СКМ/ПКМ или Space+ЛКМ — пан"
|
||||
title={t('control.previewPanHint')}
|
||||
>
|
||||
<div
|
||||
ref={previewHostRef}
|
||||
@@ -3051,7 +3179,7 @@ export function ControlApp() {
|
||||
style={{ zIndex: 48 }}
|
||||
/>
|
||||
) : null}
|
||||
<SessionTimerLayer viewport={previewContentRect} editable />
|
||||
<SessionTimerLayer viewport={markerViewport} editable />
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
@@ -3099,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}
|
||||
@@ -3460,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,
|
||||
@@ -3470,6 +3616,8 @@ export function ControlApp() {
|
||||
const pathVisible = Boolean(tokenPathSession?.presentationVisible[pathKey]);
|
||||
if (inactive) {
|
||||
return (
|
||||
<>
|
||||
{hideFromPlayersItem}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
@@ -3485,6 +3633,7 @@ export function ControlApp() {
|
||||
>
|
||||
{t('npcs.makeActive')}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const isSessionSpawn = Boolean(
|
||||
@@ -3492,6 +3641,7 @@ export function ControlApp() {
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{hideFromPlayersItem}
|
||||
{isSessionSpawn ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -3667,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,6 +107,9 @@ export function PresentationView({
|
||||
sceneNpcTokensSession.spawned,
|
||||
session?.currentSceneId,
|
||||
);
|
||||
const presentationNpcTokens = visibleNpcTokens.filter(
|
||||
(token) => !sceneNpcTokensSession.byPlacementId[String(token.id)]?.hiddenFromPlayers,
|
||||
);
|
||||
useTokenPathLivePoses({
|
||||
tokens: scene?.tokens ?? [],
|
||||
npcTokens: visibleNpcTokens,
|
||||
@@ -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}
|
||||
@@ -268,6 +271,7 @@ export function PresentationView({
|
||||
session={sceneNpcTokensSession}
|
||||
viewport={contentRect}
|
||||
grid={scene.grid}
|
||||
concealFromPlayers
|
||||
/>
|
||||
) : null}
|
||||
{USERS_BRANCH_FEATURES_ENABLED &&
|
||||
@@ -338,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}>
|
||||
|
||||
@@ -35,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: '';
|
||||
|
||||
@@ -51,6 +51,8 @@ function NpcSprite({
|
||||
gridFit,
|
||||
disposition,
|
||||
inactive,
|
||||
hiddenFromPlayers,
|
||||
concealed,
|
||||
onMove,
|
||||
onResize,
|
||||
resizeTitle,
|
||||
@@ -71,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;
|
||||
@@ -153,6 +157,8 @@ function NpcSprite({
|
||||
styles.token,
|
||||
canDrag ? styles.editable : '',
|
||||
initiativeActive ? styles.initiativeActive : '',
|
||||
hiddenFromPlayers && !concealed ? styles.hiddenFromPlayers : '',
|
||||
concealed ? styles.concealedFromPlayers : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
@@ -274,6 +280,7 @@ export function SceneNpcTokensOverlay({
|
||||
snapNorm,
|
||||
poseOverrides = null,
|
||||
dragEnabledById = null,
|
||||
concealFromPlayers = false,
|
||||
}: {
|
||||
placements: readonly SceneNpcToken[];
|
||||
library: readonly ProjectNpc[];
|
||||
@@ -289,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;
|
||||
@@ -305,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
|
||||
@@ -321,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>
|
||||
|
||||
@@ -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,104 @@
|
||||
import { computePathPlaybackSample } from '../types/tokenPathPlayback';
|
||||
import type { GameSaveBody } from '../types/gameSave';
|
||||
import { GAME_SAVE_SCHEMA_VERSION } from '../types/gameSave';
|
||||
import type { Project } from '../types/domain';
|
||||
import type { TokenPath } from '../types/tokenPath';
|
||||
import type { TokenPathPlaybackEntry } from '../types/tokenPathSession';
|
||||
|
||||
export function defaultGameSaveName(now = new Date()): string {
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(now.getDate())}.${p(now.getMonth() + 1)}.${now.getFullYear()} ${p(now.getHours())}:${p(now.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function canonicalGameSave(body: GameSaveBody): string {
|
||||
return JSON.stringify(sortValue(body));
|
||||
}
|
||||
|
||||
function sortValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(sortValue);
|
||||
if (value && typeof value === 'object') {
|
||||
const src = value as Record<string, unknown>;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(src).sort()) out[key] = sortValue(src[key]);
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function pathForPlacement(project: Project, kind: 'token' | 'npcToken', placementId: string): TokenPath | null {
|
||||
for (const scene of Object.values(project.scenes)) {
|
||||
const list = kind === 'token' ? scene.tokens : scene.npcTokens;
|
||||
const found = (list ?? []).find((token) => String(token.id) === placementId);
|
||||
if (found?.path && found.path.points.length >= 2) return found.path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Движение и задержка становятся паузой в текущей точке пути. */
|
||||
export function freezeTokenPathPlayback(
|
||||
playback: Record<string, TokenPathPlaybackEntry>,
|
||||
pathFor: (entry: TokenPathPlaybackEntry) => TokenPath | null,
|
||||
nowMs: number,
|
||||
): Record<string, TokenPathPlaybackEntry> {
|
||||
const out: Record<string, TokenPathPlaybackEntry> = {};
|
||||
for (const [key, entry] of Object.entries(playback)) {
|
||||
out[key] = freezeEntry(entry, pathFor(entry), nowMs);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function freezeEntry(entry: TokenPathPlaybackEntry, path: TokenPath | null, nowMs: number): TokenPathPlaybackEntry {
|
||||
if (entry.phase === 'stopped' || entry.phase === 'done' || !path) {
|
||||
const phase = entry.phase === 'moving' || entry.phase === 'delay' ? 'stopped' : entry.phase;
|
||||
return { ...entry, phase, rejoinDist: null, segmentStartedAtMs: 0 };
|
||||
}
|
||||
const sample = computePathPlaybackSample({ path, entry, nowMs });
|
||||
if (!sample) {
|
||||
return { ...entry, phase: 'stopped', rejoinDist: null, segmentStartedAtMs: 0 };
|
||||
}
|
||||
const phase = sample.markDone || sample.phase === 'done' ? 'done' : 'stopped';
|
||||
return {
|
||||
...entry,
|
||||
phase,
|
||||
baseDist: sample.sample.dist,
|
||||
rejoinDist: null,
|
||||
segmentStartedAtMs: 0,
|
||||
direction: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function emptyGameSaveBody(projectId: string): GameSaveBody {
|
||||
return {
|
||||
schemaVersion: GAME_SAVE_SCHEMA_VERSION,
|
||||
projectId,
|
||||
graphNodeId: null,
|
||||
sceneId: null,
|
||||
darknessByCard: {},
|
||||
markersByCard: {},
|
||||
trapsByCard: {},
|
||||
effectsByCard: {},
|
||||
sceneTokensByPlacementId: {},
|
||||
npcTokens: { byPlacementId: {}, scale: 1, spawned: {} },
|
||||
tokenPaths: { presentationVisible: {}, playback: {} },
|
||||
timer: {
|
||||
durationMs: 0,
|
||||
remainingMs: 0,
|
||||
visibleToPlayers: false,
|
||||
layout: { cx: 0, cy: 0, scale: 1, rotationDeg: 0 },
|
||||
},
|
||||
playerTokens: { selectedPlayerIds: [], visible: false, byPlayerId: {} },
|
||||
camera: { scale: 1, ox: 0.5, oy: 0.5 },
|
||||
materials: { activeMaterialIds: [], layouts: {}, legendLayouts: {}, focusMaterialId: null },
|
||||
npcAvatars: { activeNpcIds: [], layouts: {}, focusNpcId: null },
|
||||
initiative: {
|
||||
enabled: false,
|
||||
entries: {},
|
||||
order: [],
|
||||
reserve: [],
|
||||
activeId: null,
|
||||
conditions: {},
|
||||
playersVisible: false,
|
||||
},
|
||||
tokenGridSnap: false,
|
||||
};
|
||||
}
|
||||
@@ -22,6 +22,7 @@ function scene(id: string): Scene {
|
||||
previewVideoAutostart: false,
|
||||
previewRotationDeg: 0,
|
||||
darkenScene: false,
|
||||
darknessStrokes: [],
|
||||
traps: [],
|
||||
tokens: [],
|
||||
npcTokens: [],
|
||||
|
||||
@@ -43,6 +43,7 @@ function scene(id: string, title: string): Scene {
|
||||
previewVideoAutostart: false,
|
||||
previewRotationDeg: 0,
|
||||
darkenScene: false,
|
||||
darknessStrokes: [],
|
||||
traps: [],
|
||||
tokens: [],
|
||||
npcTokens: [],
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Сохранение игры
|
||||
|
||||
Слепок показа по явной кнопке мастера. Файл партии не меняется. Новый «Запустить» по-прежнему открывает пустую сессию.
|
||||
|
||||
## Как мастер этим пользуется
|
||||
|
||||
- На пульте, в ряду над «Предпросмотр экрана», сразу после текстовой кнопки «Завершить» — кнопка-иконка с дискетой. Подпись на кнопке нет. Тултип и имя для чтения с экрана: «Сохранить» (EN: Save). Высота как у соседней «Завершить», вид как у иконок инструментов (`iconOnly`).
|
||||
- Вторая «Завершить» в пустой ветке («Нет вариантов перехода») без дискеты. Сохранение одно, над предпросмотром.
|
||||
- Клик по дискете открывает модалку в стиле остальных подтверждений пульта. Заголовок «Сохранить игру». Список — только сохранения открытой партии. Клик по строке не пишет файл: он подставляет имя и помечает эту запись на перезапись. Новое сохранение — другое имя в поле и ни одна строка не выбрана. Пишет файл кнопка «Сохранить» внизу. Рядом «Отмена». Второго подтверждения нет. Показ не закрывается. Пустое имя подставляется датой и временем.
|
||||
- Имя хранится внутри файла. На диске у сохранения свой id, чтобы два одинаковых имени не затирали друг друга.
|
||||
- «Загрузить сохранение» — пункт меню кнопки «Запустить». Он открывает отдельное окно со списком, в том же стиле, что выбор игроков у «Запустить с игроками», а не подменю имён. Клик по строке её выбирает, показ открывает кнопка «Загрузить». «Отмена» оставляет редактор. Если сохранений этой партии нет, пункт меню неактивен и окно не открывается. Пункт «Запустить с этой сцены» на графе сохранение не грузит: слепок сам открывает карточку, на которой остановились.
|
||||
- Все пути закрытия показа (обе кнопки «Завершить», Escape, крестик презентации и пульта) открывают «Завершить показ?». Если в этом показе ещё не сохранялись или стол изменился после последнего сохранения, в окне три действия: «Сохранить и завершить», «Завершить без сохранения», «Отмена». «Сохранить и завершить» закрывает этот вопрос и открывает модалку дискеты; после успешной записи показ закрывается. «Отмена» в модалке сохранения оставляет показ открытым. «Завершить без сохранения» закрывает показ сразу. Если изменений нет, окно как сейчас: «Отмена» и «Завершить». Английские подписи: Save and end, End without saving, Cancel.
|
||||
- Экспорта и импорта сохранений нет. Файл лежит в данных приложения и привязан к `id` партии на этом компьютере.
|
||||
- Копия архива партии на другой компьютер исследование не увозит. Повторный импорт той же партии на этот компьютер получает новый `id`, старые сохранения остаются у исходного файла.
|
||||
|
||||
## Что попадает в слепок
|
||||
|
||||
Уже помнится весь показ и пишется по всем посещённым карточкам графа:
|
||||
|
||||
- карточка, на которой остановились;
|
||||
- штрихи затемнения и маркеры;
|
||||
- состояние ловушек;
|
||||
- сдвиги токенов сцены и НПС, токены НПС, вызванные на сессию, скрытие от игроков, общий масштаб НПС;
|
||||
- путь токена как пауза: точка на пути, без привязки к часам;
|
||||
- таймер как пауза: остаток времени, без абсолютного момента конца.
|
||||
|
||||
Эффекты поля (туман, огонь, дождь, вода) и следы, которые сами не истекают (лёд после заморозки, тёмное пятно после тьмы-действия), пишутся по всем посещённым карточкам. При смене карточки они остаются на той, где их нарисовали, и возвращаются вместе с ней. Ластик и «Очистить эффекты» снимают их с текущей карточки. В сохранение попадает этот же кэш. Ключ карточки тот же, что у затемнения: узел графа, иначе id сцены.
|
||||
|
||||
Токены игроков — только слепок карточки в момент «Сохранить игру», и только если они были показаны. В слепок входят выбранные игроки, флаг показа и их позиции на этой карточке. На уже покинутых карточках позиции игроков не пишутся. После загрузки смена карточки по-прежнему убирает токены игроков с карты. Логика показа, скрытия и сброса при переходах не меняется.
|
||||
|
||||
На момент сохранения, без памяти по прошлым карточкам, пишутся ещё камера, материалы поверх сцены, крупные аватары НПС и трекер инициативы. Это то, что показ и так держит только для текущей карточки.
|
||||
|
||||
Не пишутся черновик незавершённого штриха, идущий бросок костей и сами разовые эффекты действий (молния, луч света, облако яда, взрыв, вспышка заморозки, тьма-действие). Лёд и тёмное пятно, которые после них остаются на карте, пишутся.
|
||||
|
||||
## Справка
|
||||
|
||||
Тексты на русском и английском, в тех же разделах, что уже есть. Ниже русский смысл; английский повторяет его.
|
||||
|
||||
«Запуск сессии» — после абзаца про «Запустить с игроками»:
|
||||
|
||||
Стрелка справа на «Запустить» → «Загрузить сохранение». Открывается отдельное окно со списком сохранений этой партии на этом компьютере. Строку выбирают кликом, показ открывает кнопка «Загрузить»: карточка, где нажали «Сохранить», открытые участки, эффекты поля, токены и остальное из файла. Обычный «Запустить» сохранение не подхватывает. Если сохранений нет, пункт неактивен. «Запустить с этой сцены» по-прежнему открывает выбранную карточку с пустой сессией.
|
||||
|
||||
В шагах «Вернуться к подготовке»: если с последнего сохранения стол изменился или в этом показе ещё не сохранялись, «Завершить показ?» даёт три действия. «Сохранить и завершить» открывает список сохранений и после записи закрывает показ. «Завершить без сохранения» закрывает показ сразу. «Отмена» оставляет показ. Если изменений нет, в окне по-прежнему только «Отмена» и «Завершить».
|
||||
|
||||
«Пульт управления» — рядом с описанием «Завершить»:
|
||||
|
||||
Сразу после «Завершить» над предпросмотром — дискета, подсказка «Сохранить». Она открывает список сохранений этой партии. Клик по строке подставляет её имя для перезаписи, файл пишет кнопка «Сохранить». Новое имя без выбранной строки создаёт ещё одну запись. Показ остаётся открытым. В файл входят исследование карты, эффекты поля на посещённых карточках, лёд и тёмные пятна, маркеры, ловушки, токены сцены и НПС, а также токены игроков на текущей карточке, если они были показаны. Файл остаётся на этом компьютере и не попадает в архив партии. Если закрывать показ с несохранёнными изменениями, «Завершить показ?» предлагает «Сохранить и завершить», «Завершить без сохранения» и «Отмена».
|
||||
|
||||
«Эффекты поля и действий» — после списка полевых кистей:
|
||||
|
||||
Туман, дождь, огонь, вода, а также лёд после заморозки и тёмное пятно после тьмы-действия остаются на карточке, пока идёт показ. Переход на другую сцену их прячет, возврат на ту же карточку показывает снова. Ластик и «Очистить эффекты» стирают их с текущей карточки до конца этого показа. В сохранение через дискету они входят вместе с остальным слепком. Сами эффекты действий при смене сцены по-прежнему пропадают.
|
||||
|
||||
## Чего сохранение не делает
|
||||
|
||||
- Не встраивается в `.ttrpg.zip` и не едет вместе с экспортом партии.
|
||||
- Не продолжает сессию само при обычном «Запустить».
|
||||
- Не начинает помнить позиции игроков на каждой посещённой карточке.
|
||||
@@ -9,16 +9,17 @@
|
||||
**Где:** Пульт → **Инструменты** → 🎲 **Кубики**.
|
||||
|
||||
1. Выберите тип кубика: d4, d6, d8, d10, d12, d20 или d100.
|
||||
2. Задайте **Количество** кнопками **−** / **+** (от 1 до 20).
|
||||
3. Нажмите **Бросить**.
|
||||
4. На экране игроков (презентация) проиграет 3D-анимация; в окне «Кубики» сразу появятся грани и **Сумма**.
|
||||
5. Чтобы убрать кубики с экрана и очистить результат — **Убрать**.
|
||||
6. **Esc** закрывает окно (результат на экране при этом не снимается, пока не нажали **Убрать**).
|
||||
2. Задайте **Количество** кнопками **−** / **+** или введите число вручную (от 0 до 20).
|
||||
3. При необходимости включите **Показать анимацию игрокам** (по умолчанию включено).
|
||||
4. Нажмите **Бросить**.
|
||||
5. Если галочка включена — на экране игроков (презентация) проиграет 3D-анимация; в окне «Кубики» сразу появятся грани и **Сумма**. Если выключена — анимация не показывается, результат виден только мастеру в окне «Кубики».
|
||||
6. Чтобы убрать кубики с экрана и очистить результат — **Убрать**.
|
||||
7. **Esc** (или крестик) закрывает окно. Если была включена галочка показа игрокам и на экране шла анимация — слой кубиков на презентации тоже скрывается (числа в сессии очищаются кнопкой **Убрать**).
|
||||
|
||||
**Важно:**
|
||||
- Анимация показывается на **презентации**; мастер смотрит числа в окне «Кубики».
|
||||
- Анимация показывается на **презентации** только при включённой галочке; мастер всегда смотрит числа в окне «Кубики».
|
||||
- d100 в приложении — кубик **десятков** (грани 10, 20, …, 90, 00/100). Результат совпадает с гранью, а не с диапазоном 20–29.
|
||||
- Оверлей не скрывается сам — только кнопкой **Убрать**.
|
||||
- Оверлей не скрывается сам после броска — только кнопкой **Убрать**, снятием галочки или закрытием окна «Кубики» (при включённом показе игрокам).
|
||||
- Новый бросок можно сделать, не нажимая **Убрать**.
|
||||
|
||||
---
|
||||
@@ -54,7 +55,7 @@
|
||||
3. Включите **Показать игрокам** — виджет появится на предпросмотре и на презентации.
|
||||
4. На предпросмотре пульта: перетащите таймер; углы — размер; ручка сверху — поворот. На презентации только просмотр.
|
||||
|
||||
**Важно:** пока идёт отсчёт, длительность менять нельзя. При нуле таймер останавливается. Без **Показать игрокам** игроки его не видят.
|
||||
**Важно:** пока идёт отсчёт, длительность менять нельзя. При нуле таймер останавливается. Без **Показать игрокам** игроки его не видят. Закрытие окна «Таймер» (Esc / крестик) скрывает виджет с презентации и предпросмотра и ставит отсчёт на паузу, если он шёл.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Затемнение сцены в редакторе
|
||||
|
||||
Согласовано. Стартовый рисунок тьмы живёт в файле партии; показ и сохранение игры рисуют поверх.
|
||||
|
||||
## Интерфейс
|
||||
|
||||
- Галочку «Затемнить сцену» убрать из свойств сцены в инспекторе редактора кампании.
|
||||
- В «Редактор сцены» — аккордеон «Затемнение сцены» (не вкладка).
|
||||
- Внутри: переключатель «Затемнить сцену». При включении превью заливается 100% тьмой; доступны «Кисть Открытия» и «Кисть Закрытия», форма и радиус как на пульте.
|
||||
- При выключенном затемнении кисти недоступны.
|
||||
|
||||
## Данные в партии
|
||||
|
||||
- `darkenScene: boolean` — как сейчас.
|
||||
- Новое поле на **сцене**: стартовые штрихи (reveal/cover, точки, радиус, форма). Не на карточке графа.
|
||||
- Две карточки одной сцены делят одну базу.
|
||||
|
||||
## Показ
|
||||
|
||||
- Вход на карточку с `darkenScene`: кэш затемнения сначала заполняется штрихами из сцены, поверх — то, что нарисовали в этом показе или подняли из сохранения игры.
|
||||
- Обычный «Запустить» = база из сцены.
|
||||
- «Загрузить сохранение» = полный рисунок из слепка.
|
||||
- Кисти на пульте меняют только сессию, сцену в проекте сами не правят.
|
||||
|
||||
## Запись итога вечера в сцену (5b)
|
||||
|
||||
- Действие «Записать затемнение в сцену»: текущие штрихи активной карточки пишутся в поле сцены как новая база.
|
||||
- После этого экспорт/копирование партии увозит «полуоткрытую» карту без отдельного файла сохранения.
|
||||
- Сохранение игры по-прежнему хранит полный текущий рисунок по карточкам и базу сцены само не затирает.
|
||||
|
||||
## Вне объёма этого шага
|
||||
|
||||
- Пожар, туман, маркеры, ловушки в редакторе.
|
||||
- Отдельный импорт «сцена + состояние» между партиями.
|
||||
- Разная база затемнения для двух карточек одной сцены.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 392 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 358 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 648 KiB |
@@ -0,0 +1,36 @@
|
||||
# ВК: общий пост релиза 1.1.0
|
||||
|
||||
Обложка: `docs/vk-covers/vk-cover-release-1-1-0.png` (1:1, без текста на картинке).
|
||||
Запасной вариант с версией на кадре: `docs/vk-covers/vk-cover-1-1-0.png`.
|
||||
|
||||
---
|
||||
|
||||
## Черновик поста
|
||||
|
||||
НРИ Плеер 1.1.0 — инструменты за столом, которые раньше жили в головах и на бумажках.
|
||||
|
||||
Что нового:
|
||||
|
||||
• 3D-кубики в отдельном окне — бросок с анимацией на экране игроков или только у мастера
|
||||
• Трекер инициативы — очередь хода, состояния, подсветка текущего на карте
|
||||
• Таймер сессии поверх сцены — пресеты, пауза, показ игрокам
|
||||
• Маркер на области видимости — рисовать прямо на превью, игроки видят сразу
|
||||
• Библиотека плейлистов — в редакторе и на пульте; громкость в проекте и параллельное воспроизведение
|
||||
• Шаблоны сетки кампании — сохранили стиль один раз, применили на любую сцену
|
||||
• Аккордеон «Сцены» на пульте — вся линия сюжета под рукой, не только история визитов
|
||||
• Выбор монитора презентации при запуске сессии
|
||||
• Окно инструкций и мелочи за столом: токены игроков не пропадают при смене сцены, кисти затемнения с формой и радиусом
|
||||
|
||||
Скачать бесплатно: https://ttrpgplayer.ru
|
||||
Windows · macOS · Linux
|
||||
|
||||
#НРИплеер #TTRPG #DnD #релиз #настолки #мастер
|
||||
|
||||
---
|
||||
|
||||
## Короткая версия
|
||||
|
||||
НРИ Плеер 1.1.0: 3D-кубики, инициатива, таймер, маркер, плейлисты, шаблоны сетки и удобнее пульт за столом.
|
||||
|
||||
https://ttrpgplayer.ru
|
||||
#НРИплеер #TTRPG #релиз
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "TTRPGPlayer",
|
||||
"version": "1.0.29",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "TTRPGPlayer",
|
||||
"version": "1.0.29",
|
||||
"version": "1.1.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "TTRPGPlayer",
|
||||
"version": "1.0.29",
|
||||
"version": "1.1.0",
|
||||
"description": "TTRPG Player — редактор и проигрыватель НРИ",
|
||||
"main": "dist/main/index.cjs",
|
||||
"scripts": {
|
||||
@@ -10,7 +10,7 @@
|
||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
||||
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/shared/fieldEffectStampPoints.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/diceRoll/diceRollSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/ensureParentDir.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/sceneAudioRef.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts app/renderer/shared/tokens/tokenPathLiveDom.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs",
|
||||
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/shared/fieldEffectStampPoints.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/presentation/PresentationApp.escapeConfirm.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/diceRoll/diceRollSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/gameSave.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/ensureParentDir.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/sceneAudioRef.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/gameSystems/gameSystemsStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts app/renderer/shared/tokens/tokenPathLiveDom.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs",
|
||||
"format": "prettier . --check",
|
||||
"format:write": "prettier . --write",
|
||||
"postinstall": "patch-package",
|
||||
|
||||
@@ -63,6 +63,7 @@ export default defineConfig(({ mode }) => {
|
||||
tokenPathEditor: path.resolve(__dirname, 'app/renderer/tokenPathEditor.html'),
|
||||
npcsEditor: path.resolve(__dirname, 'app/renderer/npcsEditor.html'),
|
||||
npcs: path.resolve(__dirname, 'app/renderer/npcs.html'),
|
||||
instructions: path.resolve(__dirname, 'app/renderer/instructions.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user