10 Commits

Author SHA1 Message Date
Ivan Fontosh 5c0c72892d fix(foundry): copy playlists before deleting the unpacked archive
Archive import removed the temp folder before playlist tracks were read, so zip imports with music failed. The dialog hint now names .zip / .fvtt instead of .world.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 21:05:55 +08:00
Ivan Fontosh 877fbaa8e7 feat: save games and edit scene darkness
Persist show state to local saves; author map darkness in scene editor with bake-to-scene and help updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 11:41:42 +08:00
Ivan Fontosh f6c301a12b feat(play): hide NPC tokens from the player screen
A token can be hidden per session from the control menu. The GM still sees it, and its path keeps moving. Scene editor grid labels no longer call a removed helper.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 10:15:32 +08:00
Ivan Fontosh 060bbd471a feat(ui): confirm ending a show and surface help
End-session buttons, Escape, and window close share one confirm. Help is a question-mark icon on the control panel and after the version. About opens directly. Scene editor follows the UI language.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 08:10:54 +08:00
Ivan Fontosh d228b9aab7 fix(play): ask before Escape ends session
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 08:53:48 +08:00
Ivan Fontosh 143d141aa6 chore: release v1.1.0 2026-09-17 15:31:45 +08:00
Ivan Fontosh a29c72b307 feat: окно инструкций, свёрнутая история и обновление спонсоров
Инструкции вынесены в отдельное окно Electron; токены игроков не пропадают при смене сцены; предпросмотр и плейлисты адаптированы; история свёрнута по умолчанию.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 06:21:42 +08:00
Ivan Fontosh 712086ddca feat: кубики/таймер/кисть/спонсоры и задел игровых систем
Показ анимации кубиков игрокам, закрытие оверлеев с окнами, ручной ввод количества, меньший радиус кисти, блок спонсоров в «О программе»; плюс черновик game systems / character sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 15:37:44 +08:00
Ivan Fontosh 3d0474fe97 fix(export): не падать с EPERM при сохранении в корень диска на Windows
Перед записью zip больше не вызываем mkdir для E:\ / C:\ — на Windows это даёт operation not permitted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 18:09:48 +08:00
     Фонтош Иван Сергеевич 48a9c933af fix(effects): снизить лаги сцены на Mac
Pixi режется по contentRect, пульт идёт в preview (12 FPS, 0.5 DPR), штампы тумана/огня/дождя прореживаются, draft темноты и маркера не чаще 20 Гц.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 17:13:37 +08:00
106 changed files with 5132 additions and 633 deletions
+38 -1
View File
@@ -13,12 +13,37 @@ void test('diceRoll: roll produces values in range and notation', () => {
store.dispatch({ kind: 'roll', count: 3, sides: 6 }); store.dispatch({ kind: 'roll', count: 3, sides: 6 });
const st = store.getState(); const st = store.getState();
assert.equal(st.active, true); assert.equal(st.active, true);
assert.equal(st.visibleToPlayers, true);
assert.equal(st.notation, '3d6'); assert.equal(st.notation, '3d6');
assert.equal(st.values.length, 3); assert.equal(st.values.length, 3);
assert.ok(st.values.every((v) => v >= 1 && v <= 6)); assert.ok(st.values.every((v) => v >= 1 && v <= 6));
assert.equal(st.total, st.values.reduce((a, b) => a + b, 0)); 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', () => { void test('diceRoll: d100 values are tens faces only', () => {
const store = new DiceRollSessionStore(); const store = new DiceRollSessionStore();
store.dispatch({ kind: 'roll', count: 5, sides: 100 }); 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)); 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(); const store = new DiceRollSessionStore();
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
store.dispatch({ kind: 'roll', count: 1, sides: 20 }); store.dispatch({ kind: 'roll', count: 1, sides: 20 });
store.dispatch({ kind: 'clear' }); store.dispatch({ kind: 'clear' });
assert.equal(store.getState().active, false); assert.equal(store.getState().active, false);
assert.deepEqual(store.getState().values, []); assert.deepEqual(store.getState().values, []);
assert.equal(store.getState().visibleToPlayers, false);
}); });
void test('diceRoll: hideOverlay keeps values for master UI', () => { 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.deepEqual(store.getState().values, values);
assert.equal(store.getState().total, total); assert.equal(store.getState().total, total);
assert.equal(store.getState().notation, '2d6'); 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', () => { void test('formatPredeterminedDiceNotation', () => {
+18 -2
View File
@@ -27,6 +27,19 @@ export class DiceRollSessionStore {
this.state = { this.state = {
...createInitialDiceRollState(), ...createInitialDiceRollState(),
revision: this.state.revision + 1, 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; return this.state;
} }
@@ -43,15 +56,18 @@ export class DiceRollSessionStore {
case 'roll': { case 'roll': {
if (!isDiceSides(event.sides)) return this.state; if (!isDiceSides(event.sides)) return this.state;
const count = clampDiceCount(event.count); const count = clampDiceCount(event.count);
if (count < 1) return this.state;
const values = rollDiceValues(count, event.sides); const values = rollDiceValues(count, event.sides);
const total = values.reduce((a, b) => a + b, 0); const total = values.reduce((a, b) => a + b, 0);
const showToPlayers = this.state.visibleToPlayers;
this.state = { this.state = {
revision: this.state.revision + 1, revision: this.state.revision + 1,
active: true, active: showToPlayers,
visibleToPlayers: showToPlayers,
notation: formatDiceNotation(count, event.sides), notation: formatDiceNotation(count, event.sides),
values, values,
total, total,
startedAt: Date.now(), startedAt: showToPlayers ? Date.now() : null,
}; };
return this.state; return this.state;
} }
+76 -1
View File
@@ -1,7 +1,13 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { applyFieldEffectEraserStroke } from '../../shared/fieldEffectEraser'; 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 { function nowMs(): number {
return Date.now(); return Date.now();
@@ -23,6 +29,9 @@ export class EffectsStore {
tool: defaultTool(), tool: defaultTool(),
instances: [], instances: [],
}; };
/** Постоянные эффекты по карточке (graphNodeId или sceneId) на время показа. */
private cache = new Map<string, EffectInstance[]>();
private currentKey: string | null = null;
getState(): EffectsState { getState(): EffectsState {
// Всегда обновляем serverNowMs при чтении — это наш "таймкод" для рендереров. // Всегда обновляем serverNowMs при чтении — это наш "таймкод" для рендереров.
@@ -36,13 +45,70 @@ export class EffectsStore {
serverNowMs: nowMs(), serverNowMs: nowMs(),
instances: [], instances: [],
}; };
this.commitCurrent();
return this.state; 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 { dispatch(event: EffectsEvent): EffectsState {
const s = this.state; const s = this.state;
const next: EffectsState = applyEvent(s, event, (prefix) => this.makeId(prefix)); const next: EffectsState = applyEvent(s, event, (prefix) => this.makeId(prefix));
this.state = next; this.state = next;
this.commitCurrent();
return next; return next;
} }
@@ -78,9 +144,18 @@ export class EffectsStore {
serverNowMs: now, serverNowMs: now,
instances: kept, instances: kept,
}; };
this.commitCurrent();
return true; 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 { makeId(prefix: string): string {
return `${prefix}_${crypto.randomBytes(6).toString('hex')}_${String(nowMs())}`; return `${prefix}_${crypto.randomBytes(6).toString('hex')}_${String(nowMs())}`;
} }
+108
View File
@@ -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); 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-штрих сохраняется в кэше сцены', () => { void test('SceneDarknessStore: cover-штрих сохраняется в кэше сцены', () => {
const store = new SceneDarknessStore(); const store = new SceneDarknessStore();
store.switchScene('scene_a', true); store.switchScene('scene_a', true);
+44 -4
View File
@@ -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) { 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; this.currentKey = cacheKey;
const strokes = let strokes: SceneDarknessRevealStroke[] = [];
cacheKey && enabled ? [...(this.cache.get(cacheKey) ?? [])] : []; 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 = { this.state = {
revision: this.state.revision + 1, revision: this.state.revision + 1,
enabled, 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 })) };
} }
+26
View File
@@ -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 })) };
} }
+1
View File
@@ -392,6 +392,7 @@ export async function buildProjectFromFoundryDocuments(
previewVideoAutostart: previewAssetType === 'video', previewVideoAutostart: previewAssetType === 'video',
previewRotationDeg: 0, previewRotationDeg: 0,
darkenScene: false, darkenScene: false,
darknessStrokes: [],
traps: [], traps: [],
tokens: [], tokens: [],
npcTokens: [], npcTokens: [],
+84
View File
@@ -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);
});
+129
View File
@@ -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
View File
@@ -1,13 +1,14 @@
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; 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'; import { installStdoutEpipeGuards } from './safeConsole';
installStdoutEpipeGuards(); installStdoutEpipeGuards();
import { openDialogFilterLabel } from '../shared/appBranding'; import { openDialogFilterLabel } from '../shared/appBranding';
import type { SystemDefinition } from '../shared/characterSheet/types';
import { USERS_BRANCH_FEATURES_ENABLED } from '../shared/features/usersBranchFeatures'; import { USERS_BRANCH_FEATURES_ENABLED } from '../shared/features/usersBranchFeatures';
import { ipcChannels, type ScenePreviewImportEvent, type SessionState } from '../shared/ipc/contracts'; import { ipcChannels, type ScenePreviewImportEvent, type SessionState } from '../shared/ipc/contracts';
import { import {
@@ -19,7 +20,9 @@ import {
stripProjectZipExtension, stripProjectZipExtension,
} from '../shared/project/projectZipExtension'; } from '../shared/project/projectZipExtension';
import type { Project } from '../shared/types'; 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 { listVisibleSceneNpcTokens } from '../shared/types/appPlayers';
import { remapProjectSceneTokenIds } from '../shared/graph/storylineExportImport'; import { remapProjectSceneTokenIds } from '../shared/graph/storylineExportImport';
import { import {
@@ -27,7 +30,9 @@ import {
previewRotationStepsCw, previewRotationStepsCw,
} from '../shared/types/scenePreviewRotation'; } from '../shared/types/scenePreviewRotation';
import { getSessionTimerRemainingMs } from '../shared/types/sessionTimer';
import { EffectsStore, effectsDefaultTool } from './effects/effectsStore'; import { EffectsStore, effectsDefaultTool } from './effects/effectsStore';
import { GameSavesStore } from './gameSave/gameSavesStore';
import { SceneDarknessStore } from './effects/sceneDarknessStore'; import { SceneDarknessStore } from './effects/sceneDarknessStore';
import { SceneMarkerStore } from './effects/sceneMarkerStore'; import { SceneMarkerStore } from './effects/sceneMarkerStore';
import { SceneTrapsStore } from './sceneTraps/sceneTrapsStore'; import { SceneTrapsStore } from './sceneTraps/sceneTrapsStore';
@@ -43,6 +48,7 @@ import { ZipProjectStore } from './project/zipStore';
import { SceneViewStore } from './sceneView/sceneViewStore'; import { SceneViewStore } from './sceneView/sceneViewStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol'; import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { PlayersStore } from './players/playersStore'; import { PlayersStore } from './players/playersStore';
import { GameSystemsStore } from './gameSystems/gameSystemsStore';
import { PlaylistsStore } from './playlists/playlistsStore'; import { PlaylistsStore } from './playlists/playlistsStore';
import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore'; import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore';
import { ScenePlayerTokensSessionStore } from './players/scenePlayerTokensSessionStore'; import { ScenePlayerTokensSessionStore } from './players/scenePlayerTokensSessionStore';
@@ -81,6 +87,8 @@ import {
openTokenPathEditorWindow, openTokenPathEditorWindow,
openNpcsWindow, openNpcsWindow,
openSceneDescriptionWindow, openSceneDescriptionWindow,
openInstructionsWindow,
getInstructionsSection,
closeMaterialsWindow, closeMaterialsWindow,
closePlaylistsWindow, closePlaylistsWindow,
closeTimerWindow, closeTimerWindow,
@@ -90,7 +98,10 @@ import {
closeSceneEditorWindow, closeSceneEditorWindow,
closeTokenPathEditorWindow, closeTokenPathEditorWindow,
closeNpcsWindow, closeNpcsWindow,
requestCloseMultiWindowConfirm,
sendToAppWindows, sendToAppWindows,
setDiceWindowClosedListener,
setTimerWindowClosedListener,
syncAllWindowChromeTitles, syncAllWindowChromeTitles,
togglePresentationFullscreen, togglePresentationFullscreen,
waitForEditorWindowReady, waitForEditorWindowReady,
@@ -197,8 +208,12 @@ const tokenPathSessionStore = new TokenPathSessionStore();
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore(); const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore(); const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore();
const tokenGridSnapSessionStore = new TokenGridSnapSessionStore(); const tokenGridSnapSessionStore = new TokenGridSnapSessionStore();
let gameSavesStore: GameSavesStore | null = null;
/** Канонический слепок последнего сохранения этого показа. null — ещё не сохраняли. */
let lastSaveCanonical: string | null = null;
let tokensStore: TokensStore | null = null; let tokensStore: TokensStore | null = null;
let playersStore: PlayersStore | null = null; let playersStore: PlayersStore | null = null;
let gameSystemsStore: GameSystemsStore | null = null;
let playlistsStore: PlaylistsStore | null = null; let playlistsStore: PlaylistsStore | null = null;
function emitTokenPathSessionState(): void { 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 { function emitInitiativeState(): void {
const state = initiativeSessionStore.getState(); const state = initiativeSessionStore.getState();
for (const win of BrowserWindow.getAllWindows()) { 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 { function syncInitiativePresenceFromSession(): void {
const project = projectStore.getOpenProject(); const project = projectStore.getOpenProject();
const sceneId = project?.currentSceneId ?? null; const sceneId = project?.currentSceneId ?? null;
@@ -481,7 +521,168 @@ function syncSceneDarknessForProject(project: Project): void {
const enabled = const enabled =
Boolean(scene?.darkenScene) && Boolean(scene?.darkenScene) &&
(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video'); (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 { function syncSceneMarkerForProject(project: Project): void {
@@ -633,6 +834,9 @@ async function main() {
playersStore = new PlayersStore(app.getPath('userData')); playersStore = new PlayersStore(app.getPath('userData'));
await playersStore.ensureLoaded(); await playersStore.ensureLoaded();
} }
gameSystemsStore = new GameSystemsStore(app.getPath('userData'));
gameSavesStore = new GameSavesStore(app.getPath('userData'));
await gameSystemsStore.ensureLoaded();
playlistsStore = new PlaylistsStore(app.getPath('userData')); playlistsStore = new PlaylistsStore(app.getPath('userData'));
await playlistsStore.ensureLoaded(); await playlistsStore.ensureLoaded();
setLicenseAssert(() => { setLicenseAssert(() => {
@@ -650,11 +854,23 @@ async function main() {
buildNumber: getOptionalBuildNumber(), buildNumber: getOptionalBuildNumber(),
packaged: app.isPackaged, 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.getStatus, () => licenseService.getStatus());
registerHandler(ipcChannels.license.setToken, async ({ token }) => licenseService.setToken(token)); registerHandler(ipcChannels.license.setToken, async ({ token }) => licenseService.setToken(token));
registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken()); registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken());
registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version)); registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version));
registerHandler(ipcChannels.windows.openMultiWindow, (req) => { registerHandler(ipcChannels.windows.openMultiWindow, async (req) => {
sceneDarknessStore.resetSession(); sceneDarknessStore.resetSession();
sceneMarkerStore.resetSession(); sceneMarkerStore.resetSession();
sceneTrapsStore.resetSession(); sceneTrapsStore.resetSession();
@@ -667,23 +883,38 @@ async function main() {
timerSessionStore.clear(); timerSessionStore.clear();
initiativeSessionStore.clear(); initiativeSessionStore.clear();
diceRollSessionStore.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) : []; const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : [];
if (playerIds.length > 0) { if (playerIds.length > 0) {
scenePlayerTokensSessionStore.dispatch({ kind: 'setSelection', playerIds }); scenePlayerTokensSessionStore.dispatch({ kind: 'setSelection', playerIds });
} }
} }
effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() });
openMultiWindow(); openMultiWindow();
const project = projectStore.getOpenProject(); project = projectStore.getOpenProject();
if (project) { if (project && !saveId) {
syncSceneDarknessForProject(project); syncSceneDarknessForProject(project);
syncSceneMarkerForProject(project); syncSceneMarkerForProject(project);
syncSceneTrapsForProject(project); syncSceneTrapsForProject(project);
syncSceneEffectsForProject(project);
seedTokenPathPlaybackForProject(project); seedTokenPathPlaybackForProject(project);
} else { } else if (!project) {
emitTokenPathSessionState(); emitTokenPathSessionState();
} }
if (project && saveId) {
lastSaveCanonical = canonicalGameSave(captureGameSaveBody(project));
}
emitSceneDarknessState(); emitSceneDarknessState();
emitSceneMarkerState(); emitSceneMarkerState();
emitSceneTrapsState(); emitSceneTrapsState();
@@ -692,11 +923,31 @@ async function main() {
emitScenePlayerTokensSessionState(); emitScenePlayerTokensSessionState();
emitTokenGridSnapState(); emitTokenGridSnapState();
emitEffectsState(); emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
emitSceneViewState();
emitSessionTimerState(); emitSessionTimerState();
syncInitiativePresenceFromSession(); if (!saveId) syncInitiativePresenceFromSession();
emitInitiativeState(); emitInitiativeState();
emitDiceRollState(); 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, () => { registerHandler(ipcChannels.windows.closeMultiWindow, () => {
closeMultiWindow(); closeMultiWindow();
@@ -710,6 +961,10 @@ async function main() {
emitTokenGridSnapState(); emitTokenGridSnapState();
return { ok: true }; return { ok: true };
}); });
registerHandler(ipcChannels.windows.requestCloseMultiWindowConfirm, () => {
requestCloseMultiWindowConfirm();
return { ok: true };
});
registerHandler(ipcChannels.windows.syncChromeTitles, ({ localeTag }) => { registerHandler(ipcChannels.windows.syncChromeTitles, ({ localeTag }) => {
syncAllWindowChromeTitles(localeTag); syncAllWindowChromeTitles(localeTag);
return { ok: true }; return { ok: true };
@@ -739,6 +994,13 @@ async function main() {
openSceneDescriptionWindow(html); openSceneDescriptionWindow(html);
return { ok: true }; return { ok: true };
}); });
registerHandler(ipcChannels.windows.openInstructions, ({ section }) => {
openInstructionsWindow(section);
return { ok: true };
});
registerHandler(ipcChannels.windows.getInstructionsSection, () => {
return { section: getInstructionsSection() };
});
registerHandler(ipcChannels.windows.closeSceneDescription, () => { registerHandler(ipcChannels.windows.closeSceneDescription, () => {
closeSceneDescriptionWindow(); closeSceneDescriptionWindow();
return { ok: true }; return { ok: true };
@@ -903,6 +1165,8 @@ async function main() {
sceneNpcTokensSessionStore.reset(); sceneNpcTokensSessionStore.reset();
clearNpcMapSpawnDrag(); clearNpcMapSpawnDrag();
scenePlayerTokensSessionStore.reset(); scenePlayerTokensSessionStore.reset();
effectsStore.resetSession();
lastSaveCanonical = null;
syncSceneMarkerForProject(project); syncSceneMarkerForProject(project);
emitSceneViewState(); emitSceneViewState();
emitSceneTokensSessionState(); emitSceneTokensSessionState();
@@ -916,7 +1180,8 @@ async function main() {
registerHandler(ipcChannels.project.close, async () => { registerHandler(ipcChannels.project.close, async () => {
closeNpcsEditorWindow(); closeNpcsEditorWindow();
await projectStore.closeOpenProject(); await projectStore.closeOpenProject();
effectsStore.clear(); effectsStore.resetSession();
lastSaveCanonical = null;
materialsOverlayStore.clear(); materialsOverlayStore.clear();
npcsOverlayStore.clear(); npcsOverlayStore.clear();
timerSessionStore.clear(); timerSessionStore.clear();
@@ -957,7 +1222,8 @@ async function main() {
}); });
registerHandler(ipcChannels.project.setCurrentScene, async ({ sceneId }) => { registerHandler(ipcChannels.project.setCurrentScene, async ({ sceneId }) => {
await projectStore.updateProject((p) => ({ ...p, currentSceneId: sceneId, currentGraphNodeId: null })); await projectStore.updateProject((p) => ({ ...p, currentSceneId: sceneId, currentGraphNodeId: null }));
effectsStore.clear(); const sceneProject = projectStore.getOpenProject();
if (sceneProject) syncSceneEffectsForProject(sceneProject);
materialsOverlayStore.clear(); materialsOverlayStore.clear();
npcsOverlayStore.clear(); npcsOverlayStore.clear();
sceneViewStore.reset(); sceneViewStore.reset();
@@ -988,7 +1254,8 @@ async function main() {
if (!open) throw new Error('No open project'); if (!open) throw new Error('No open project');
const gn = graphNodeId ? open.sceneGraphNodes.find((n) => n.id === graphNodeId) : null; const gn = graphNodeId ? open.sceneGraphNodes.find((n) => n.id === graphNodeId) : null;
projectStore.setSessionPointer(graphNodeId, gn ? gn.sceneId : null); projectStore.setSessionPointer(graphNodeId, gn ? gn.sceneId : null);
effectsStore.clear(); const switched = projectStore.getOpenProject();
if (switched) syncSceneEffectsForProject(switched);
materialsOverlayStore.clear(); materialsOverlayStore.clear();
npcsOverlayStore.clear(); npcsOverlayStore.clear();
sceneViewStore.reset(); sceneViewStore.reset();
@@ -1022,7 +1289,10 @@ async function main() {
const before = projectStore.getOpenProject()?.scenes[sceneId]; const before = projectStore.getOpenProject()?.scenes[sceneId];
const next = await projectStore.updateScene(sceneId, patch); const next = await projectStore.updateScene(sceneId, patch);
const project = projectStore.getOpenProject(); const project = projectStore.getOpenProject();
if (project?.currentSceneId === sceneId && patch.darkenScene !== undefined) { if (
project?.currentSceneId === sceneId &&
(patch.darkenScene !== undefined || patch.darknessStrokes !== undefined)
) {
syncSceneDarknessForProject(project); syncSceneDarknessForProject(project);
emitSceneDarknessState(); emitSceneDarknessState();
} }
@@ -1623,7 +1893,9 @@ async function main() {
registerHandler(ipcChannels.project.importFoundry, async ({ sourcePath }) => { registerHandler(ipcChannels.project.importFoundry, async ({ sourcePath }) => {
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт Foundry…' }); emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт Foundry…' });
try { try {
const { project, pendingAppPlaylists } = await projectStore.importProjectFromFoundry(sourcePath, (p) => { const { project } = await projectStore.importProjectFromFoundry(
sourcePath,
(p) => {
emitZipProgress({ emitZipProgress({
kind: 'import', kind: 'import',
stage: stage:
@@ -1631,8 +1903,9 @@ async function main() {
percent: p.percent, percent: p.percent,
...(p.detail ? { detail: p.detail } : null), ...(p.detail ? { detail: p.detail } : null),
}); });
}); },
if (playlistsStore && pendingAppPlaylists.length > 0) { async (pendingAppPlaylists) => {
if (!playlistsStore || pendingAppPlaylists.length === 0) return;
for (const pl of pendingAppPlaylists) { for (const pl of pendingAppPlaylists) {
const created = await playlistsStore.createPlaylist(pl.name); const created = await playlistsStore.createPlaylist(pl.name);
const paths = pl.tracks.map((t) => t.absPath); const paths = pl.tracks.map((t) => t.absPath);
@@ -1641,7 +1914,8 @@ async function main() {
} }
} }
emitPlaylistsState(); emitPlaylistsState();
} },
);
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' }); emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState(); emitSessionState();
return { project }; return { project };
@@ -1945,6 +2219,50 @@ async function main() {
return { url: playersStore.getImageUrl(id) }; 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 () => { registerHandler(ipcChannels.playlists.getState, async () => {
if (!playlistsStore) return { playlists: [], audios: [] }; if (!playlistsStore) return { playlists: [], audios: [] };
await playlistsStore.ensureLoaded(); await playlistsStore.ensureLoaded();
@@ -315,4 +315,16 @@ export class InitiativeSessionStore {
this.state = next; this.state = next;
return this.state; 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;
}
} }
+1
View File
@@ -18,6 +18,7 @@ function channelRequiresLicense(channel: string): boolean {
if (channel.startsWith('license.')) return false; if (channel.startsWith('license.')) return false;
if (channel.startsWith('app.')) return false; if (channel.startsWith('app.')) return false;
if (channel === ipcChannels.windows.closeMultiWindow) 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.closeSceneDescription) return false;
if (channel === ipcChannels.windows.closeMaterials) return false; if (channel === ipcChannels.windows.closeMaterials) return false;
if (channel === ipcChannels.windows.closeNpcs) return false; if (channel === ipcChannels.windows.closeNpcs) return false;
@@ -225,4 +225,16 @@ export class MaterialsOverlayStore {
}; };
return this.state; 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;
}
} }
+13
View File
@@ -256,4 +256,17 @@ export class NpcsOverlayStore {
}; };
return this.state; 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;
}
} }
+16
View File
@@ -188,6 +188,8 @@ export class PlayersStore {
input.imageScale ?? existing?.imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE, 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) { if (existing) {
this.players = this.players.map((p) => (p.id === player.id ? player : p)); this.players = this.players.map((p) => (p.id === player.id ? player : p));
@@ -287,6 +289,20 @@ export class PlayersStore {
return this.getById(playerId); 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> { private async persist(): Promise<void> {
await fs.mkdir(this.rootDir, { recursive: true }); await fs.mkdir(this.rootDir, { recursive: true });
const payload: PlayersManifest = { players: this.players, teams: this.teams }; 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); 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', () => { void test('SceneNpcTokensSessionStore setSize is session-only and survives move', () => {
const store = new SceneNpcTokensSessionStore(); const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({ kind: 'setSize', placementId: 'a', sizeN: 0.2 }); const s1 = store.dispatch({ kind: 'setSize', placementId: 'a', sizeN: 0.2 });
+40 -1
View File
@@ -21,7 +21,8 @@ function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
p.ny === undefined && p.ny === undefined &&
p.sizeN === undefined && p.sizeN === undefined &&
!p.disposition && !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?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
...(prev?.disposition ? { disposition: prev.disposition } : {}), ...(prev?.disposition ? { disposition: prev.disposition } : {}),
...(prev?.inactive ? { inactive: true } : {}), ...(prev?.inactive ? { inactive: true } : {}),
...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
}; };
} }
@@ -238,6 +240,7 @@ export class SceneNpcTokensSessionStore {
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}), ...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
...(prev?.disposition ? { disposition: prev.disposition } : {}), ...(prev?.disposition ? { disposition: prev.disposition } : {}),
...(inactive ? { inactive: true } : {}), ...(inactive ? { inactive: true } : {}),
...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
}; };
if (isEmptyPlacement(next)) { if (isEmptyPlacement(next)) {
this.state = withoutPlacement(this.state, placementId); this.state = withoutPlacement(this.state, placementId);
@@ -254,6 +257,32 @@ export class SceneNpcTokensSessionStore {
}; };
return this.state; 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': { case 'setSize': {
const placementId = String(event.placementId ?? ''); const placementId = String(event.placementId ?? '');
if (!placementId) return this.state; 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;
}
} }
+26
View File
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { ensureParentDir } from './ensureParentDir';
void test('ensureParentDir: creates nested parent', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-mkdir-'));
try {
const file = path.join(root, 'a', 'b', 'out.ttrpg.zip');
await ensureParentDir(file);
const st = await fs.stat(path.dirname(file));
assert.equal(st.isDirectory(), true);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
void test('ensureParentDir: drive root is a no-op (Windows)', async () => {
if (process.platform !== 'win32') return;
// Must not throw EPERM the way fs.mkdir('E:\\') often does.
await ensureParentDir('E:\\campaign.ttrpg.zip');
await ensureParentDir('C:\\file.ttrpg.zip');
});
+14
View File
@@ -0,0 +1,14 @@
import fs from 'node:fs/promises';
import path from 'node:path';
/**
* Создаёт родительский каталог для файла.
* На Windows `mkdir('E:\\')` даёт EPERM — корень диска пропускаем.
*/
export async function ensureParentDir(filePath: string): Promise<void> {
const dir = path.dirname(filePath);
if (!dir || dir === '.') return;
const root = path.parse(path.resolve(filePath)).root;
if (root && path.resolve(dir) === path.resolve(root)) return;
await fs.mkdir(dir, { recursive: true });
}
@@ -67,6 +67,15 @@ void test('zipStore: closeOpenProject is serialized with open on projectSwitchCh
assert.match(src, /Открытие проекта отменено/); 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', () => { void test('zipStore: exportProjectZipToPath flushes saveNow for currently open project', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /async exportProjectZipToPath/); assert.match(src, /async exportProjectZipToPath/);
@@ -139,6 +148,16 @@ void test('main index: full zip import loads app-tokens like playlists', () => {
assert.match(importFromPath[0], /importAppTokensFromOpenProjectCache/); assert.match(importFromPath[0], /importAppTokensFromOpenProjectCache/);
}); });
void test('zipStore: zipDir uses ensureParentDir (no mkdir of drive root)', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /from '\.\/ensureParentDir'/);
const i = src.indexOf('async function zipDir(');
assert.ok(i >= 0);
const block = src.slice(i, i + 800);
assert.match(block, /await ensureParentDir\(outZipPath\)/);
assert.doesNotMatch(block, /fs\.mkdir\(path\.dirname\(outZipPath\)/);
});
void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => { void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => {
const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8'); const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8');
const i = src.indexOf('export async function replaceFileAtomic'); const i = src.indexOf('export async function replaceFileAtomic');
+28 -12
View File
@@ -52,6 +52,7 @@ import type {
SceneTrap, SceneTrap,
} from '../../shared/types'; } from '../../shared/types';
import { normalizeSceneAudioRef, PROJECT_SCHEMA_VERSION } from '../../shared/types'; import { normalizeSceneAudioRef, PROJECT_SCHEMA_VERSION } from '../../shared/types';
import { normalizeSceneDarknessStrokes } from '../../shared/types/sceneDarkness';
import { normalizeMaterialLegend } from '../../shared/types/materialLegend'; import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens'; import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens';
import { import {
@@ -107,11 +108,13 @@ import {
buildProjectFromFoundryDocuments, buildProjectFromFoundryDocuments,
loadFoundryDocumentsForImport, loadFoundryDocumentsForImport,
type FoundryImportProgress, type FoundryImportProgress,
type FoundryPendingAppPlaylist,
} from '../foundry/foundryImport'; } from '../foundry/foundryImport';
import { getAppSemanticVersion } from '../versionInfo'; import { getAppSemanticVersion } from '../versionInfo';
import { reconcileAssetFiles } from './assetPrune'; import { reconcileAssetFiles } from './assetPrune';
import { recoverOrphanProjectZipTmpInRoot, replaceFileAtomic } from './atomicReplace'; import { recoverOrphanProjectZipTmpInRoot, replaceFileAtomic } from './atomicReplace';
import { ensureParentDir } from './ensureParentDir';
import { rmWithRetries } from './fsRetry'; import { rmWithRetries } from './fsRetry';
import { optimizeImageBufferVisuallyLossless } from './optimizeImageImport.lib.mjs'; import { optimizeImageBufferVisuallyLossless } from './optimizeImageImport.lib.mjs';
import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths'; import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths';
@@ -475,7 +478,7 @@ export class ZipProjectStore {
const relPath = `assets/${id}_${safeOrig}`; const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath); const abs = path.join(open.cacheDir, relPath);
const sha256 = crypto.createHash('sha256').update(buf).digest('hex'); const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
await fs.mkdir(path.dirname(abs), { recursive: true }); await ensureParentDir(abs);
await fs.writeFile(abs, buf); await fs.writeFile(abs, buf);
const asset = buildMediaAsset(id, kind0, orig, relPath, sha256, buf.length); const asset = buildMediaAsset(id, kind0, orig, relPath, sha256, buf.length);
@@ -771,6 +774,7 @@ export class ZipProjectStore {
previewVideoAutostart: false, previewVideoAutostart: false,
previewRotationDeg: 0, previewRotationDeg: 0,
darkenScene: false, darkenScene: false,
darknessStrokes: [],
traps: [], traps: [],
tokens: [], tokens: [],
npcTokens: [], npcTokens: [],
@@ -797,6 +801,9 @@ export class ZipProjectStore {
? { previewRotationDeg: patch.previewRotationDeg } ? { previewRotationDeg: patch.previewRotationDeg }
: null), : null),
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null), ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
...(patch.darknessStrokes !== undefined
? { darknessStrokes: normalizeSceneDarknessStrokes(patch.darknessStrokes) }
: null),
...(patch.traps !== undefined ...(patch.traps !== undefined
? { ? {
traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)), traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)),
@@ -1165,7 +1172,7 @@ export class ZipProjectStore {
const safeOrig = sanitizeFileName(orig); const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`; const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath); const abs = path.join(open.cacheDir, relPath);
await fs.mkdir(path.dirname(abs), { recursive: true }); await ensureParentDir(abs);
await fs.copyFile(filePath, abs); await fs.copyFile(filePath, abs);
staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length)); staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length));
} }
@@ -1226,7 +1233,7 @@ export class ZipProjectStore {
const safeOrig = sanitizeFileName(orig); const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`; const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath); const abs = path.join(open.cacheDir, relPath);
await fs.mkdir(path.dirname(abs), { recursive: true }); await ensureParentDir(abs);
await fs.copyFile(filePath, abs); await fs.copyFile(filePath, abs);
staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length)); staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length));
} }
@@ -1316,7 +1323,7 @@ export class ZipProjectStore {
const safeOrig = sanitizeFileName(orig); const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`; const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath); const abs = path.join(open.cacheDir, relPath);
await fs.mkdir(path.dirname(abs), { recursive: true }); await ensureParentDir(abs);
await fs.writeFile(abs, buf); await fs.writeFile(abs, buf);
stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length); stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
nextAssetId = id; nextAssetId = id;
@@ -1551,7 +1558,7 @@ export class ZipProjectStore {
const safeOrig = sanitizeFileName(orig); const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`; const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath); const abs = path.join(open.cacheDir, relPath);
await fs.mkdir(path.dirname(abs), { recursive: true }); await ensureParentDir(abs);
await fs.writeFile(abs, buf); await fs.writeFile(abs, buf);
stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length); stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
nextAssetId = id; nextAssetId = id;
@@ -2038,7 +2045,7 @@ export class ZipProjectStore {
private async packZipFromCache(cacheDir: string, zipPath: string): Promise<void> { private async packZipFromCache(cacheDir: string, zipPath: string): Promise<void> {
const tmpPath = `${zipPath}.tmp`; const tmpPath = `${zipPath}.tmp`;
await fs.mkdir(path.dirname(zipPath), { recursive: true }); await ensureParentDir(zipPath);
await zipDir(cacheDir, tmpPath); await zipDir(cacheDir, tmpPath);
const st = await fs.stat(tmpPath).catch(() => null); const st = await fs.stat(tmpPath).catch(() => null);
if (!st?.isFile() || st.size < 22) { if (!st?.isFile() || st.size < 22) {
@@ -2123,7 +2130,8 @@ export class ZipProjectStore {
async importProjectFromFoundry( async importProjectFromFoundry(
sourcePath: string, sourcePath: string,
onProgress?: (p: FoundryImportProgress) => void, 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(); await this.ensureRoots();
const loaded = await loadFoundryDocumentsForImport(sourcePath, onProgress); const loaded = await loadFoundryDocumentsForImport(sourcePath, onProgress);
try { try {
@@ -2153,6 +2161,10 @@ export class ZipProjectStore {
await this.writeCacheProject(cacheDir, project); await this.writeCacheProject(cacheDir, project);
onProgress?.({ stage: 'zip', percent: 90, detail: 'Сборка проекта…' }); onProgress?.({ stage: 'zip', percent: 90, detail: 'Сборка проекта…' });
await this.enqueuePack(cacheDir, zipPath); await this.enqueuePack(cacheDir, zipPath);
// Архив распакован во временную папку. Плейлисты читают её по absPath — копировать до cleanup.
if (importPlaylists && pendingAppPlaylists.length > 0) {
await importPlaylists(pendingAppPlaylists);
}
onProgress?.({ stage: 'done', percent: 100, detail: 'Готово' }); onProgress?.({ stage: 'done', percent: 100, detail: 'Готово' });
return { project: this.openProject.project, pendingAppPlaylists }; return { project: this.openProject.project, pendingAppPlaylists };
} finally { } finally {
@@ -2178,7 +2190,7 @@ export class ZipProjectStore {
} }
const src = path.join(getProjectsRootDir(), entry.fileName); const src = path.join(getProjectsRootDir(), entry.fileName);
const dest = path.resolve(destinationPath); const dest = path.resolve(destinationPath);
await fs.mkdir(path.dirname(dest), { recursive: true }); await ensureParentDir(dest);
if (onProgress) onProgress({ stage: 'copy', percent: 1, detail: 'Копирование…' }); if (onProgress) onProgress({ stage: 'copy', percent: 1, detail: 'Копирование…' });
await copyFileWithProgress(src, dest, (pct) => { await copyFileWithProgress(src, dest, (pct) => {
if (onProgress) onProgress({ stage: 'copy', percent: pct, detail: 'Копирование…' }); if (onProgress) onProgress({ stage: 'copy', percent: pct, detail: 'Копирование…' });
@@ -2291,7 +2303,7 @@ export class ZipProjectStore {
if (!a) continue; if (!a) continue;
const srcAbs = path.join(snap.cacheDir, a.relPath); const srcAbs = path.join(snap.cacheDir, a.relPath);
const destAbs = path.join(exportCache, a.relPath); const destAbs = path.join(exportCache, a.relPath);
await fs.mkdir(path.dirname(destAbs), { recursive: true }); await ensureParentDir(destAbs);
await fs.copyFile(srcAbs, destAbs); await fs.copyFile(srcAbs, destAbs);
if (onProgress) { if (onProgress) {
const pct = Math.round(((i + 1) / Math.max(1, assetIds.length)) * 75); const pct = Math.round(((i + 1) / Math.max(1, assetIds.length)) * 75);
@@ -2428,7 +2440,7 @@ export class ZipProjectStore {
if (!srcAsset || !tgtMeta) continue; if (!srcAsset || !tgtMeta) continue;
const srcAbs = path.join(sourceCache, srcAsset.relPath); const srcAbs = path.join(sourceCache, srcAsset.relPath);
const destAbs = path.join(targetCache, tgtMeta.relPath); const destAbs = path.join(targetCache, tgtMeta.relPath);
await fs.mkdir(path.dirname(destAbs), { recursive: true }); await ensureParentDir(destAbs);
await fs.copyFile(srcAbs, destAbs); await fs.copyFile(srcAbs, destAbs);
if (onProgress) { if (onProgress) {
const pct = Math.round(((i + 1) / Math.max(1, assetCopies.length)) * 90); const pct = Math.round(((i + 1) / Math.max(1, assetCopies.length)) * 90);
@@ -2618,6 +2630,9 @@ function normalizeScene(s: Scene): Scene {
const previewThumbAssetId = const previewThumbAssetId =
(s as unknown as { previewThumbAssetId?: AssetId | null }).previewThumbAssetId ?? null; (s as unknown as { previewThumbAssetId?: AssetId | null }).previewThumbAssetId ?? null;
const darkenScene = Boolean((s as unknown as { darkenScene?: boolean }).darkenScene); 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 rawTraps = (s as unknown as { traps?: unknown[] }).traps;
const traps = (Array.isArray(rawTraps) ? rawTraps : []) const traps = (Array.isArray(rawTraps) ? rawTraps : [])
.map((t) => normalizeSceneTrap(t)) .map((t) => normalizeSceneTrap(t))
@@ -2665,6 +2680,7 @@ function normalizeScene(s: Scene): Scene {
previewVideoAutostart, previewVideoAutostart,
previewRotationDeg, previewRotationDeg,
darkenScene, darkenScene,
darknessStrokes,
traps, traps,
tokens, tokens,
npcTokens, npcTokens,
@@ -2894,7 +2910,7 @@ async function copyFileWithProgress(
onPercent(100); onPercent(100);
return; return;
} }
await fs.mkdir(path.dirname(dest), { recursive: true }); await ensureParentDir(dest);
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
let done = 0; let done = 0;
const rs = fssync.createReadStream(src); const rs = fssync.createReadStream(src);
@@ -3027,7 +3043,7 @@ async function zipDir(srcDir: string, outZipPath: string): Promise<void> {
} }
zipfile.addBuffer(buf, rel, zipOptionsForRelativeEntry(rel)); zipfile.addBuffer(buf, rel, zipOptionsForRelativeEntry(rel));
} }
await fs.mkdir(path.dirname(outZipPath), { recursive: true }); await ensureParentDir(outZipPath);
const out = fssync.createWriteStream(outZipPath); const out = fssync.createWriteStream(outZipPath);
const done = new Promise<void>((resolve, reject) => { const done = new Promise<void>((resolve, reject) => {
out.on('close', resolve); out.on('close', resolve);
+25
View File
@@ -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().visibleToPlayers, false);
assert.equal(store.getState().running, 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; 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;
}
} }
+13
View File
@@ -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 }; export type { TokenPathTargetRef };
@@ -52,6 +52,19 @@ void test('createWindows: окно материалов закрывается
assert.match(src, /export function closeMultiWindow[\s\S]*closeMaterialsWindow/); 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', () => { void test('createWindows: окно таймера закрывается с multi-window', () => {
const src = readCreateWindows(); const src = readCreateWindows();
assert.ok(src.includes('openTimerWindow')); assert.ok(src.includes('openTimerWindow'));
@@ -76,6 +89,16 @@ void test('createWindows: окно кубиков закрывается с mult
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closeDiceWindow/); 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', () => { void test('createWindows: окно НПС закрывается с multi-window', () => {
const src = readCreateWindows(); const src = readCreateWindows();
assert.ok(src.includes('openNpcsWindow')); assert.ok(src.includes('openNpcsWindow'));
@@ -89,10 +112,11 @@ void test('createWindows: окно НПС закрывается с multi-window
assert.ok(src.includes('will-attach-webview')); assert.ok(src.includes('will-attach-webview'));
}); });
void test('createWindows: закрытие пульта закрывает сессионные окна и презентацию', () => { void test('createWindows: закрытие пульта и презентации спрашивает confirm', () => {
const src = readCreateWindows(); const src = readCreateWindows();
assert.match(src, /kind === 'control'/); assert.match(src, /kind === 'control'[\s\S]*?e\.preventDefault\(\)[\s\S]*?requestCloseMultiWindowConfirm/);
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closePresentationWindow/); 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://)', () => { void test('createWindows: production — loadFile для HTML (не только file://)', () => {
+133 -26
View File
@@ -1,6 +1,6 @@
import path from 'node:path'; 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 type { PresentationSettings } from '../../shared/types/presentationSettings';
import { readPresentationSettings } from '../preferences/appPreferences'; import { readPresentationSettings } from '../preferences/appPreferences';
@@ -27,7 +27,8 @@ export type WindowKind =
| 'npcsEditor' | 'npcsEditor'
| 'sceneEditor' | 'sceneEditor'
| 'tokenPathEditor' | 'tokenPathEditor'
| 'npcs'; | 'npcs'
| 'instructions';
/** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */ /** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */
export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [ export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [
@@ -53,6 +54,7 @@ let appQuitting = false;
/** Защита от каскада close(control) ↔ close(presentation). */ /** Защита от каскада close(control) ↔ close(presentation). */
let closingPlaySession = false; let closingPlaySession = false;
let pendingSceneDescriptionHtml = ''; let pendingSceneDescriptionHtml = '';
let pendingInstructionsSection = 'overview';
/** Окно материалов — только колонка списка. */ /** Окно материалов — только колонка списка. */
const MATERIALS_WINDOW_WIDTH = 300; const MATERIALS_WINDOW_WIDTH = 300;
@@ -60,19 +62,38 @@ const MATERIALS_WINDOW_HEIGHT = 720;
/** Окно плейлистов на пульте. */ /** Окно плейлистов на пульте. */
const PLAYLISTS_WINDOW_WIDTH = 720; const PLAYLISTS_WINDOW_WIDTH = 720;
const PLAYLISTS_WINDOW_MIN_WIDTH = 640;
const PLAYLISTS_WINDOW_HEIGHT = 800; const PLAYLISTS_WINDOW_HEIGHT = 800;
/** Компактное окно таймера сессии. */ /** Компактное окно таймера сессии. */
const TIMER_WINDOW_WIDTH = 360; const TIMER_WINDOW_WIDTH = 360;
const TIMER_WINDOW_HEIGHT = 420; 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_WIDTH = 920;
const INITIATIVE_WINDOW_HEIGHT = 420; const INITIATIVE_WINDOW_HEIGHT = 420;
/** Компактное окно броска кубиков. */ /** Компактное окно броска кубиков. */
const DICE_WINDOW_WIDTH = 380; 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; 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 { export function markAppQuitting(): void {
appQuitting = true; appQuitting = true;
@@ -190,20 +220,6 @@ function closePlaySessionAuxiliaryWindows(): void {
closeNpcsWindow(); 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 { function quitAppFromEditorClose(): void {
markAppQuitting(); markAppQuitting();
app.quit(); app.quit();
@@ -251,6 +267,8 @@ function pageNameForKind(kind: WindowKind): string {
return 'tokenPathEditor.html'; return 'tokenPathEditor.html';
case 'npcs': case 'npcs':
return 'npcs.html'; 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 === 'sceneEditor') return { width: 1280, height: 800 };
if (kind === 'tokenPathEditor') return { width: 1100, height: 760 }; if (kind === 'tokenPathEditor') return { width: 1100, height: 760 };
if (kind === 'npcs') return { width: NPCS_WINDOW_WIDTH, height: NPCS_WINDOW_HEIGHT }; 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 }; return { width: 1280, height: 800 };
} }
@@ -352,6 +371,14 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
minHeight: 480, minHeight: 480,
} }
: {}), : {}),
...(kind === 'playlists'
? {
width: PLAYLISTS_WINDOW_WIDTH,
height: PLAYLISTS_WINDOW_HEIGHT,
minWidth: PLAYLISTS_WINDOW_MIN_WIDTH,
minHeight: 480,
}
: {}),
...(kind === 'npcsEditor' ...(kind === 'npcsEditor'
? { ? {
width: NPCS_EDITOR_WINDOW_WIDTH, width: NPCS_EDITOR_WINDOW_WIDTH,
@@ -385,6 +412,14 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
minHeight: 560, minHeight: 560,
} }
: {}), : {}),
...(kind === 'instructions'
? {
width: INSTRUCTIONS_WINDOW_WIDTH,
height: INSTRUCTIONS_WINDOW_HEIGHT,
minWidth: 640,
minHeight: 480,
}
: {}),
show: false, show: false,
backgroundColor: '#09090B', backgroundColor: '#09090B',
...(icon ? { icon } : {}), ...(icon ? { icon } : {}),
@@ -444,7 +479,8 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
kind === 'npcsEditor' || kind === 'npcsEditor' ||
kind === 'sceneEditor' || kind === 'sceneEditor' ||
kind === 'tokenPathEditor' || kind === 'tokenPathEditor' ||
kind === 'npcs' kind === 'npcs' ||
kind === 'instructions'
) { ) {
win.setMenuBarVisibility(false); win.setMenuBarVisibility(false);
} }
@@ -452,6 +488,17 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
win.webContents.on('preload-error', (_event, preloadPath, error) => { win.webContents.on('preload-error', (_event, preloadPath, error) => {
safeConsoleError(`[preload-error] ${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) => { win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
// -3 ERR_ABORTED: частый артефакт при navigate/maximize/закрытии — не шумим. // -3 ERR_ABORTED: частый артефакт при navigate/maximize/закрытии — не шумим.
if (errorCode === -3) return; if (errorCode === -3) return;
@@ -474,23 +521,31 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
}); });
} }
if (kind === 'control') { if (kind === 'control') {
win.on('close', () => { win.on('close', (e) => {
if (appQuitting || closingPlaySession) return; if (appQuitting || closingPlaySession) return;
closingPlaySession = true; e.preventDefault();
closePlaySessionAuxiliaryWindows(); requestCloseMultiWindowConfirm();
closePresentationWindow();
}); });
} }
if (kind === 'presentation') { if (kind === 'presentation') {
bindPresentationContentSizeTracking(win); bindPresentationContentSizeTracking(win);
win.on('close', () => { win.on('close', (e) => {
if (appQuitting || closingPlaySession) return; if (appQuitting || closingPlaySession) return;
closingPlaySession = true; e.preventDefault();
closePlaySessionAuxiliaryWindows(); requestCloseMultiWindowConfirm();
closeControlWindow();
}); });
} }
win.on('closed', () => windows.delete(kind)); win.on('closed', () => windows.delete(kind));
if (kind === 'timer') {
win.on('closed', () => {
timerWindowClosedListener?.();
});
}
if (kind === 'dice') {
win.on('closed', () => {
diceWindowClosedListener?.();
});
}
if (kind === 'sceneEditor') { if (kind === 'sceneEditor') {
win.on('closed', () => { win.on('closed', () => {
closeTokenPathEditorWindow(); 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 { export function isMultiWindowOpen(): boolean {
return windows.has('presentation') || windows.has('control'); return windows.has('presentation') || windows.has('control');
} }
@@ -745,6 +813,44 @@ export function getSceneDescriptionContent(): string {
return pendingSceneDescriptionHtml; 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 { export function openSceneDescriptionWindow(html: string): void {
pendingSceneDescriptionHtml = html; pendingSceneDescriptionHtml = html;
@@ -821,6 +927,7 @@ export function openMaterialsWindow(): void {
export function openPlaylistsWindow(): void { export function openPlaylistsWindow(): void {
const existing = windows.get('playlists'); const existing = windows.get('playlists');
if (existing && !existing.isDestroyed()) { if (existing && !existing.isDestroyed()) {
existing.setMinimumSize(PLAYLISTS_WINDOW_MIN_WIDTH, 480);
if (existing.isMinimized()) existing.restore(); if (existing.isMinimized()) existing.restore();
existing.show(); existing.show();
existing.focus(); existing.focus();
+78 -2
View File
@@ -160,6 +160,50 @@
margin-top: 4px; 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 { .descriptionViewBody {
min-height: 360px; min-height: 360px;
max-height: min(560px, calc(100vh - 200px)); max-height: min(560px, calc(100vh - 200px));
@@ -339,19 +383,39 @@
.previewHeader { .previewHeader {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px 12px;
} }
.previewTitle { .previewTitle {
font-size: var(--text-md); font-size: var(--text-md);
font-weight: 900; font-weight: 900;
flex: 0 1 auto;
display: flex;
align-items: center;
min-height: 34px;
line-height: 1.2;
} }
.previewActions { .previewActions {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: flex-end;
gap: 10px; 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 { .npcTokenScale {
@@ -359,6 +423,7 @@
align-items: center; align-items: center;
gap: 8px; gap: 8px;
min-width: 0; min-width: 0;
min-height: 34px;
} }
.snapToGrid { .snapToGrid {
@@ -366,26 +431,37 @@
align-items: center; align-items: center;
gap: 6px; gap: 6px;
min-width: 0; min-width: 0;
min-height: 34px;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
} }
.snapToGrid input {
margin: 0;
flex-shrink: 0;
}
.snapToGridLabel { .snapToGridLabel {
color: var(--text2); color: var(--text2);
font-size: var(--text-xs); font-size: var(--text-xs);
font-weight: 700; font-weight: 700;
white-space: nowrap; line-height: 1.25;
} }
.npcTokenScaleLabel { .npcTokenScaleLabel {
color: var(--text2); color: var(--text2);
font-size: var(--text-xs); font-size: var(--text-xs);
font-weight: 700; font-weight: 700;
line-height: 1.25;
white-space: nowrap; white-space: nowrap;
} }
.npcTokenScaleRange { .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); accent-color: var(--color-accent, #c9a227);
} }
+310 -19
View File
@@ -5,6 +5,7 @@ import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
import { fitAspectRect } from '../../shared/geometry/fitAspectRect'; import { fitAspectRect } from '../../shared/geometry/fitAspectRect';
import { ipcChannels } from '../../shared/ipc/contracts'; import { ipcChannels } from '../../shared/ipc/contracts';
import type { SessionState } 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 { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
import { import {
isNodeInMainStoryline, isNodeInMainStoryline,
@@ -95,6 +96,8 @@ import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx';
/** Длительность молнии: быстрый удар + акцент в точке попадания. */ /** Длительность молнии: быстрый удар + акцент в точке попадания. */
const LIGHTNING_EFFECT_MS = 840; const LIGHTNING_EFFECT_MS = 840;
/** Темнота/маркер на презентацию — не чаще 20 Гц (полный canvas replay в обоих окнах). */
const OVERLAY_DRAFT_IPC_MS = 50;
function dispositionMakeKey(d: NpcDisposition): string { function dispositionMakeKey(d: NpcDisposition): string {
if (d === 'hostile') return 'npcs.makeHostile'; if (d === 'hostile') return 'npcs.makeHostile';
@@ -201,6 +204,15 @@ export function ControlApp() {
y: number; y: number;
placementId: string; placementId: string;
} | null>(null); } | 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 [sceneView, sceneViewApi] = useSceneViewState();
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null); const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
const [materialsOverlay, materialsApi] = useMaterialsOverlayState(); const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
@@ -208,7 +220,7 @@ export function ControlApp() {
const [session, setSession] = useState<SessionState | null>(null); const [session, setSession] = useState<SessionState | null>(null);
const historyRef = useRef<GraphNodeId[]>([]); const historyRef = useRef<GraphNodeId[]>([]);
const [history, setHistory] = useState<GraphNodeId[]>([]); const [history, setHistory] = useState<GraphNodeId[]>([]);
const [historyOpen, setHistoryOpen] = useState(true); const [historyOpen, setHistoryOpen] = useState(false);
const [scenesOpen, setScenesOpen] = useState(false); const [scenesOpen, setScenesOpen] = useState(false);
const [darknessBrushShape, setDarknessBrushShape] = useState<SceneDarknessBrushShape>('circle'); const [darknessBrushShape, setDarknessBrushShape] = useState<SceneDarknessBrushShape>('circle');
const darknessBrushShapeRef = useRef(darknessBrushShape); const darknessBrushShapeRef = useRef(darknessBrushShape);
@@ -305,6 +317,7 @@ export function ControlApp() {
const brushCursorElRef = useRef<HTMLDivElement | null>(null); const brushCursorElRef = useRef<HTMLDivElement | null>(null);
const cursorPosRef = useRef<{ x: number; y: number } | null>(null); const cursorPosRef = useRef<{ x: number; y: number } | null>(null);
const draftPaintRafRef = useRef(0); const draftPaintRafRef = useRef(0);
const overlayDraftIpcAtRef = useRef(0);
const effectsOverlayRef = useRef<PixiEffectsOverlayHandle | null>(null); const effectsOverlayRef = useRef<PixiEffectsOverlayHandle | null>(null);
const [explosionDraft, setExplosionDraft] = useState<ExplosionInstance | null>(null); const [explosionDraft, setExplosionDraft] = useState<ExplosionInstance | null>(null);
const draftMetaRef = useRef<{ createdAtMs: number; seed: number } | null>(null); const draftMetaRef = useRef<{ createdAtMs: number; seed: number } | null>(null);
@@ -1152,6 +1165,75 @@ export function ControlApp() {
}; };
}, [npcMapSpawnDrag.dragging, npcMapSpawnDragApi, snapNormActive]); }, [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(() => { useEffect(() => {
if (!markersInteractive) { if (!markersInteractive) {
setNpcSessionCtxMenu(null); setNpcSessionCtxMenu(null);
@@ -1221,7 +1303,7 @@ export function ControlApp() {
const cw = cr ? cr.w : ps.w; const cw = cr ? cr.w : ps.w;
const ch = cr ? cr.h : ps.h; const ch = cr ? cr.h : ps.h;
const minDim = Math.min(cw, ch); 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.left = `${String(ox + p.x * cw)}px`;
el.style.top = `${String(oy + p.y * ch)}px`; el.style.top = `${String(oy + p.y * ch)}px`;
el.style.width = `${String(size)}px`; el.style.width = `${String(size)}px`;
@@ -1387,6 +1469,9 @@ export function ControlApp() {
draftPaintRafRef.current = 0; draftPaintRafRef.current = 0;
pushDraftToPixi(); pushDraftToPixi();
const b = brushRef.current; const b = brushRef.current;
const now = performance.now();
if (now - overlayDraftIpcAtRef.current < OVERLAY_DRAFT_IPC_MS) return;
overlayDraftIpcAtRef.current = now;
if ( if (
(b?.tool === 'exploreBrush' || b?.tool === 'closeBrush') && (b?.tool === 'exploreBrush' || b?.tool === 'closeBrush') &&
b.points b.points
@@ -1930,6 +2015,22 @@ export function ControlApp() {
</svg> </svg>
</span> </span>
</Button> </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>
<div className={styles.spacer12} /> <div className={styles.spacer12} />
<> <>
@@ -2098,25 +2199,43 @@ export function ControlApp() {
<span className={styles.iconGlyph}>⬛</span> <span className={styles.iconGlyph}>⬛</span>
</Button> </Button>
</div> </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> </div>
) : null} ) : null}
<div className={styles.radiusRow}> <div className={styles.radiusRow}>
<div className={styles.radiusLabel}>{t('control.brushRadius')}</div> <div className={styles.radiusLabel}>{t('control.brushRadius')}</div>
<input <input
type="range" type="range"
min={0.015} min={1}
max={0.18} max={36}
step={0.001} step={1}
value={tool.radiusN} value={Math.max(1, Math.min(36, Math.round(tool.radiusN / 0.005)))}
onChange={(e) => { onChange={(e) => {
const v = Number((e.currentTarget as HTMLInputElement).value); const ui = Number((e.currentTarget as HTMLInputElement).value);
const next = Math.max(0.01, Math.min(0.25, Number.isFinite(v) ? v : tool.radiusN)); 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 } }); void fx.dispatch({ kind: 'tool.set', tool: { ...tool, radiusN: next } });
}} }}
className={styles.range} className={styles.range}
aria-label={t('control.brushRadius')} 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> </div>
{tool.tool === 'exploreBrush' || tool.tool === 'closeBrush' ? ( {tool.tool === 'exploreBrush' || tool.tool === 'closeBrush' ? (
<div className={styles.brushShapeRow}> <div className={styles.brushShapeRow}>
@@ -2376,6 +2495,7 @@ export function ControlApp() {
<div className={styles.previewHeader}> <div className={styles.previewHeader}>
<div className={styles.previewTitle}>{t('control.screenPreview')}</div> <div className={styles.previewTitle}>{t('control.screenPreview')}</div>
<div className={styles.previewActions}> <div className={styles.previewActions}>
<div className={styles.previewTokenTools}>
<label <label
className={styles.snapToGrid} className={styles.snapToGrid}
title={ title={
@@ -2399,7 +2519,6 @@ export function ControlApp() {
<span className={styles.snapToGridLabel}>{t('control.snapTokensToGrid')}</span> <span className={styles.snapToGridLabel}>{t('control.snapTokensToGrid')}</span>
</label> </label>
{USERS_BRANCH_FEATURES_ENABLED ? ( {USERS_BRANCH_FEATURES_ENABLED ? (
<>
<label className={styles.npcTokenScale}> <label className={styles.npcTokenScale}>
<span className={styles.npcTokenScaleLabel}>{t('control.npcTokenScale')}</span> <span className={styles.npcTokenScaleLabel}>{t('control.npcTokenScale')}</span>
<input <input
@@ -2418,7 +2537,9 @@ export function ControlApp() {
}} }}
/> />
</label> </label>
{scenePlayerTokensSession.selectedPlayerIds.length > 0 ? ( ) : null}
</div>
{USERS_BRANCH_FEATURES_ENABLED && scenePlayerTokensSession.selectedPlayerIds.length > 0 ? (
<Button <Button
data-testid="toggle-session-players" data-testid="toggle-session-players"
onClick={() => { onClick={() => {
@@ -2465,10 +2586,23 @@ export function ControlApp() {
: t('control.showPlayers')} : t('control.showPlayers')}
</Button> </Button>
) : null} ) : null}
</> <Button onClick={openStopConfirm}>{t('control.stopPresentation')}</Button>
) : null} <Button
<Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}> variant="ghost"
{t('control.stopPresentation')} 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> </Button>
</div> </div>
</div> </div>
@@ -2477,7 +2611,7 @@ export function ControlApp() {
<div <div
ref={previewFrameRef} ref={previewFrameRef}
className={styles.previewFrame} className={styles.previewFrame}
title="Колесо — зум; перетаскивание СКМ/ПКМ или Space+ЛКМ — пан" title={t('control.previewPanHint')}
> >
<div <div
ref={previewHostRef} ref={previewHostRef}
@@ -2512,6 +2646,7 @@ export function ControlApp() {
<PixiEffectsOverlay <PixiEffectsOverlay
ref={effectsOverlayRef} ref={effectsOverlayRef}
state={fxState} state={fxState}
perfProfile="preview"
style={{ zIndex: 6 }} style={{ zIndex: 6 }}
viewport={ viewport={
previewContentRect previewContentRect
@@ -3044,7 +3179,7 @@ export function ControlApp() {
style={{ zIndex: 48 }} style={{ zIndex: 48 }}
/> />
) : null} ) : null}
<SessionTimerLayer viewport={previewContentRect} editable /> <SessionTimerLayer viewport={markerViewport} editable />
</div> </div>
</Surface> </Surface>
@@ -3092,9 +3227,9 @@ export function ControlApp() {
<Button <Button
variant="primary" variant="primary"
disabled={!session?.project?.currentGraphNodeId} disabled={!session?.project?.currentGraphNodeId}
onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})} onClick={openStopConfirm}
> >
{t('control.endPresentation')} {t('control.stopPresentation')}
</Button> </Button>
</div> </div>
) : null} ) : null}
@@ -3453,6 +3588,24 @@ export function ControlApp() {
override?.disposition, override?.disposition,
); );
const inactive = Boolean(override?.inactive); 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 path = placement.path;
const pathTarget = { const pathTarget = {
kind: 'npcToken' as const, kind: 'npcToken' as const,
@@ -3463,6 +3616,8 @@ export function ControlApp() {
const pathVisible = Boolean(tokenPathSession?.presentationVisible[pathKey]); const pathVisible = Boolean(tokenPathSession?.presentationVisible[pathKey]);
if (inactive) { if (inactive) {
return ( return (
<>
{hideFromPlayersItem}
<button <button
type="button" type="button"
className={styles.ctxItem} className={styles.ctxItem}
@@ -3478,6 +3633,7 @@ export function ControlApp() {
> >
{t('npcs.makeActive')} {t('npcs.makeActive')}
</button> </button>
</>
); );
} }
const isSessionSpawn = Boolean( const isSessionSpawn = Boolean(
@@ -3485,6 +3641,7 @@ export function ControlApp() {
); );
return ( return (
<> <>
{hideFromPlayersItem}
{isSessionSpawn ? ( {isSessionSpawn ? (
<button <button
type="button" type="button"
@@ -3660,6 +3817,140 @@ export function ControlApp() {
document.body, document.body,
) )
: null} : 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> </div>
); );
} }
@@ -32,6 +32,8 @@ void test('ControlApp: draft кисти без корневого draftFxTick',
assert.ok(pixi.includes('setDraft')); assert.ok(pixi.includes('setDraft'));
assert.ok(pixi.includes('mergeDraftState')); assert.ok(pixi.includes('mergeDraftState'));
assert.ok(pixi.includes('forwardRef')); assert.ok(pixi.includes('forwardRef'));
assert.ok(app.includes('perfProfile="preview"'));
assert.ok(app.includes('OVERLAY_DRAFT_IPC_MS'));
}); });
void test('ControlApp: path playback не гоняет setState корня', () => { void test('ControlApp: path playback не гоняет setState корня', () => {
@@ -97,6 +97,8 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
assert.ok(src.includes("t('control.darknessControl')")); assert.ok(src.includes("t('control.darknessControl')"));
assert.ok(src.includes("t('control.explorerBrush')")); assert.ok(src.includes("t('control.explorerBrush')"));
assert.ok(src.includes("t('control.closerBrush')")); 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("tool: 'closeBrush'") || src.includes("selectEffectTool('closeBrush')"));
assert.ok(src.includes("mode: b.tool === 'closeBrush' ? 'cover' : 'reveal'") || src.includes("'cover'")); assert.ok(src.includes("mode: b.tool === 'closeBrush' ? 'cover' : 'reveal'") || src.includes("'cover'"));
assert.ok(src.includes('SceneDarknessOverlay')); assert.ok(src.includes('SceneDarknessOverlay'));
@@ -135,16 +137,20 @@ void test('ControlApp: чекбокс привязки токенов к сет
assert.ok(src.includes('snapAllTokensToGrid')); assert.ok(src.includes('snapAllTokensToGrid'));
assert.ok(src.includes('snapNorm={snapNormActive}')); assert.ok(src.includes('snapNorm={snapNormActive}'));
assert.ok(css.includes('.snapToGrid')); 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 src = readControlApp();
const css = readControlAppCss(); const css = readControlAppCss();
const story = src.indexOf("t('control.storyLine')"); const story = src.indexOf("t('control.storyLine')");
assert.ok(story !== -1); assert.ok(story !== -1);
assert.ok(src.includes('data-testid="control-history-accordion"')); assert.ok(src.includes('data-testid="control-history-accordion"'));
assert.ok(src.includes('aria-expanded={historyOpen}')); 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.ok(src.includes('className={styles.storyScroll}'));
assert.match(css, /\.storyScroll[\s\S]*?justify-content:\s*flex-start/); assert.match(css, /\.storyScroll[\s\S]*?justify-content:\s*flex-start/);
assert.match(css, /\.storyScroll[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/); 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(!/\bproject\b/.test(depList));
assert.ok(!/\bcampaignAudioRefs\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'));
});
+17
View File
@@ -49,6 +49,14 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 8px; padding: 8px;
background: var(--color-overlay-dark-3, rgba(0, 0, 0, 0.28)); 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 { .actions {
@@ -58,6 +66,15 @@
margin-top: 4px; margin-top: 4px;
} }
.checkRow {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--text-sm);
font-weight: 600;
user-select: none;
}
.actions > * { .actions > * {
width: 100%; width: 100%;
max-width: none; max-width: none;
+93 -7
View File
@@ -1,7 +1,13 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { ipcChannels } from '../../shared/ipc/contracts'; 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 { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi'; import { getDndApi } from '../shared/dndApi';
import { useDiceRollState } from '../shared/dice/useDiceRollState'; import { useDiceRollState } from '../shared/dice/useDiceRollState';
@@ -15,6 +21,7 @@ export function DiceApp() {
const [state, diceApi] = useDiceRollState(); const [state, diceApi] = useDiceRollState();
const [sides, setSides] = useState<DiceSides>(20); const [sides, setSides] = useState<DiceSides>(20);
const [count, setCount] = useState(1); const [count, setCount] = useState(1);
const [countDraft, setCountDraft] = useState('1');
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@@ -26,6 +33,32 @@ export function DiceApp() {
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [api]); }, [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 ( return (
<div className={styles.page} data-testid="dice-app"> <div className={styles.page} data-testid="dice-app">
<div className={styles.sectionLabel}>{t('dice.sides')}</div> <div className={styles.sectionLabel}>{t('dice.sides')}</div>
@@ -46,27 +79,80 @@ export function DiceApp() {
<div className={styles.countRow}> <div className={styles.countRow}>
<Button <Button
variant="ghost" variant="ghost"
onClick={() => setCount((c) => Math.max(1, c - 1))} onClick={() => setCountClamped(count - 1)}
ariaLabel={t('dice.dec')} ariaLabel={t('dice.dec')}
disabled={count <= DICE_COUNT_MIN}
> >
− −
</Button> </Button>
<div className={styles.countValue} data-testid="dice-count"> <input
{count} className={styles.countValue}
</div> 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 <Button
variant="ghost" variant="ghost"
onClick={() => setCount((c) => Math.min(20, c + 1))} onClick={() => setCountClamped(count + 1)}
ariaLabel={t('dice.inc')} ariaLabel={t('dice.inc')}
disabled={count >= DICE_COUNT_MAX}
> >
+ +
</Button> </Button>
</div> </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}> <div className={styles.actions}>
<Button <Button
variant="primary" 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" data-testid="dice-roll"
> >
{t('dice.roll')} {t('dice.roll')}
+47 -107
View File
@@ -53,6 +53,12 @@
user-select: none; user-select: none;
} }
.helpMark {
font-size: 18px;
font-weight: 400;
line-height: 1;
}
.headerActions { .headerActions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -379,6 +385,11 @@
font: inherit; font: inherit;
} }
.fileMenuItem:disabled {
opacity: 0.45;
cursor: default;
}
.fileMenuItemDanger { .fileMenuItemDanger {
text-align: left; text-align: left;
padding: 10px; padding: 10px;
@@ -519,6 +530,30 @@
box-sizing: border-box; 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 { .licenseBlockTitle {
font-weight: 900; font-weight: 900;
font-size: var(--text-lg); font-size: var(--text-lg);
@@ -550,113 +585,6 @@
line-height: 1.45; 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 { .aboutBody {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -688,6 +616,13 @@
margin-top: 4px; margin-top: 4px;
} }
.aboutSponsorsList {
display: flex;
flex-direction: column;
gap: 4px;
line-height: 1.45;
}
.aboutLink { .aboutLink {
color: var(--color-accent, #a78bfa); color: var(--color-accent, #a78bfa);
text-decoration: none; text-decoration: none;
@@ -1015,6 +950,11 @@
width: 100%; width: 100%;
} }
.actionsRowHalf > button {
min-width: 0;
width: 100%;
}
.actionsRowHalf > span { .actionsRowHalf > span {
display: flex; display: flex;
min-width: 0; min-width: 0;
+200 -100
View File
@@ -16,6 +16,7 @@ import {
} from '../../shared/ipc/contracts'; } from '../../shared/ipc/contracts';
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion'; import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot'; import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
import type { GameSaveListItem } from '../../shared/types/gameSave';
import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension'; import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension';
import type { import type {
AssetId, AssetId,
@@ -37,7 +38,7 @@ import { Button, Input } from '../shared/ui/controls';
import { LayoutShell } from '../shared/ui/LayoutShell'; import { LayoutShell } from '../shared/ui/LayoutShell';
import { useAssetUrl } from '../shared/useAssetImageUrl'; import { useAssetUrl } from '../shared/useAssetImageUrl';
import { AppAboutModal, InstructionsModal } from './about/EditorAboutModals'; import { AppAboutModal } from './about/EditorAboutModals';
import styles from './EditorApp.module.css'; import styles from './EditorApp.module.css';
import { EditorAudioTrackList } from './EditorAudioTrackList'; import { EditorAudioTrackList } from './EditorAudioTrackList';
import { import {
@@ -62,6 +63,7 @@ import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
import { PlayersManagerModal } from './PlayersModals'; import { PlayersManagerModal } from './PlayersModals';
import { LaunchPlayersModal } from './LaunchPlayersModal'; import { LaunchPlayersModal } from './LaunchPlayersModal';
import { PresentationSettingsModal } from './PresentationSettingsModal'; import { PresentationSettingsModal } from './PresentationSettingsModal';
import { GameSystemsModal } from './GameSystemsModal';
import { PlaylistsManagerModal } from './PlaylistsModal'; import { PlaylistsManagerModal } from './PlaylistsModal';
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml'; import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
import { SceneDescriptionModal } from './SceneDescriptionModal'; import { SceneDescriptionModal } from './SceneDescriptionModal';
@@ -129,10 +131,7 @@ export function EditorApp() {
const [projectMenuOpen, setProjectMenuOpen] = useState(false); const [projectMenuOpen, setProjectMenuOpen] = useState(false);
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false); const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
const [settingsLangSubOpen, setSettingsLangSubOpen] = useState(false); const [settingsLangSubOpen, setSettingsLangSubOpen] = useState(false);
const [aboutMenuOpen, setAboutMenuOpen] = useState(false);
const [appAboutOpen, setAppAboutOpen] = useState(false); const [appAboutOpen, setAppAboutOpen] = useState(false);
const [instructionsOpen, setInstructionsOpen] = useState(false);
const [instructionsSection, setInstructionsSection] = useState<HelpSectionId>('overview');
const [renameOpen, setRenameOpen] = useState(false); const [renameOpen, setRenameOpen] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false);
const [importSourceOpen, setImportSourceOpen] = useState(false); const [importSourceOpen, setImportSourceOpen] = useState(false);
@@ -164,12 +163,34 @@ export function EditorApp() {
const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false); const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
const [playersManagerOpen, setPlayersManagerOpen] = useState(false); const [playersManagerOpen, setPlayersManagerOpen] = useState(false);
const [launchPlayersOpen, setLaunchPlayersOpen] = 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 [playlistsManagerOpen, setPlaylistsManagerOpen] = useState(false);
const [presentationSettingsOpen, setPresentationSettingsOpen] = useState(false); const [presentationSettingsOpen, setPresentationSettingsOpen] = useState(false);
const [gameSystemsOpen, setGameSystemsOpen] = useState(false);
const [runMenuOpen, setRunMenuOpen] = useState(false); const [runMenuOpen, setRunMenuOpen] = useState(false);
const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | null>(null); const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | null>(null);
const runSplitRef = useRef<HTMLDivElement | null>(null); const runSplitRef = useRef<HTMLDivElement | null>(null);
const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(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( const onProjectNotice = useCallback(
(code: ProjectNoticeCode) => { (code: ProjectNoticeCode) => {
const handlers: Record<ProjectNoticeCode, () => void> = { const handlers: Record<ProjectNoticeCode, () => void> = {
@@ -262,11 +283,9 @@ export function EditorApp() {
const fileMenuBtnRef = useRef<HTMLButtonElement | null>(null); const fileMenuBtnRef = useRef<HTMLButtonElement | null>(null);
const projectMenuBtnRef = useRef<HTMLButtonElement | null>(null); const projectMenuBtnRef = useRef<HTMLButtonElement | null>(null);
const settingsMenuBtnRef = 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 [fileMenuPos, setFileMenuPos] = useState<{ left: number; top: number } | null>(null);
const [projectMenuPos, setProjectMenuPos] = 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 [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 [selectedGraphNodeId, setSelectedGraphNodeId] = useState<GraphNodeId | null>(null);
const [sceneListDrop, setSceneListDrop] = useState<{ const [sceneListDrop, setSceneListDrop] = useState<{
targetId: SceneId; targetId: SceneId;
@@ -365,6 +384,55 @@ export function EditorApp() {
const runHelpSection: HelpSectionId = !licenseActive ? 'license' : 'graph'; const runHelpSection: HelpSectionId = !licenseActive ? 'license' : 'graph';
const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene'); 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( const launchFromGraphNode = useCallback(
(graphNodeId: GraphNodeId, playerIds?: string[]) => { (graphNodeId: GraphNodeId, playerIds?: string[]) => {
if (!licenseActive || launching) return; if (!licenseActive || launching) return;
@@ -464,26 +532,6 @@ export function EditorApp() {
if (!settingsMenuOpen) setSettingsLangSubOpen(false); if (!settingsMenuOpen) setSettingsLangSubOpen(false);
}, [settingsMenuOpen]); }, [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(() => { useEffect(() => {
if (!runMenuOpen) return; if (!runMenuOpen) return;
const r = runSplitRef.current?.getBoundingClientRect() ?? null; const r = runSplitRef.current?.getBoundingClientRect() ?? null;
@@ -793,7 +841,6 @@ export function EditorApp() {
onClick={() => { onClick={() => {
setFileMenuOpen(false); setFileMenuOpen(false);
setProjectMenuOpen(false); setProjectMenuOpen(false);
setAboutMenuOpen(false);
setSettingsMenuOpen((v) => { setSettingsMenuOpen((v) => {
const next = !v; const next = !v;
if (next) setSettingsLangSubOpen(false); if (next) setSettingsLangSubOpen(false);
@@ -804,16 +851,14 @@ export function EditorApp() {
{t('top.settings')} {t('top.settings')}
</button> </button>
<button <button
ref={aboutMenuBtnRef}
type="button" type="button"
data-aboutmenu-root="1"
className={styles.fileMenuTrigger} className={styles.fileMenuTrigger}
onClick={() => { onClick={() => {
setFileMenuOpen(false); setFileMenuOpen(false);
setProjectMenuOpen(false); setProjectMenuOpen(false);
setSettingsMenuOpen(false); setSettingsMenuOpen(false);
setSettingsLangSubOpen(false); setSettingsLangSubOpen(false);
setAboutMenuOpen((v) => !v); setAppAboutOpen(true);
}} }}
> >
{t('top.aboutApp')} {t('top.aboutApp')}
@@ -829,7 +874,6 @@ export function EditorApp() {
if (!licenseActive) return; if (!licenseActive) return;
setFileMenuOpen(false); setFileMenuOpen(false);
setSettingsMenuOpen(false); setSettingsMenuOpen(false);
setAboutMenuOpen(false);
setProjectMenuOpen((v) => !v); setProjectMenuOpen((v) => !v);
}} }}
> >
@@ -847,7 +891,6 @@ export function EditorApp() {
if (!licenseActive) return; if (!licenseActive) return;
setProjectMenuOpen(false); setProjectMenuOpen(false);
setSettingsMenuOpen(false); setSettingsMenuOpen(false);
setAboutMenuOpen(false);
setFileMenuOpen((v) => !v); setFileMenuOpen((v) => !v);
}} }}
> >
@@ -865,7 +908,6 @@ export function EditorApp() {
if (!licenseActive) return; if (!licenseActive) return;
setProjectMenuOpen(false); setProjectMenuOpen(false);
setSettingsMenuOpen(false); setSettingsMenuOpen(false);
setAboutMenuOpen(false);
setFileMenuOpen(false); setFileMenuOpen(false);
setPlayersManagerOpen(true); setPlayersManagerOpen(true);
}} }}
@@ -883,7 +925,6 @@ export function EditorApp() {
if (!licenseActive) return; if (!licenseActive) return;
setProjectMenuOpen(false); setProjectMenuOpen(false);
setSettingsMenuOpen(false); setSettingsMenuOpen(false);
setAboutMenuOpen(false);
setFileMenuOpen(false); setFileMenuOpen(false);
setPlaylistsManagerOpen(true); setPlaylistsManagerOpen(true);
}} }}
@@ -897,6 +938,19 @@ export function EditorApp() {
{appVersionText} {appVersionText}
</div> </div>
) : null} ) : 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}> <div className={styles.headerActions}>
{state.project ? ( {state.project ? (
<> <>
@@ -935,6 +989,7 @@ export function EditorApp() {
data-testid="run-menu-btn" data-testid="run-menu-btn"
onClick={() => { onClick={() => {
if (runDisabled || launching) return; if (runDisabled || launching) return;
refreshGameSaves();
setRunMenuOpen((v) => !v); setRunMenuOpen((v) => !v);
}} }}
> >
@@ -971,8 +1026,7 @@ export function EditorApp() {
title={runHelpTooltip} title={runHelpTooltip}
tooltipPlacement="bottom-left" tooltipPlacement="bottom-left"
onClick={() => { onClick={() => {
setInstructionsSection(runHelpSection); openInstructions(runHelpSection);
setInstructionsOpen(true);
}} }}
> >
? ?
@@ -1212,7 +1266,6 @@ export function EditorApp() {
previewVideoAutostart={sc?.previewVideoAutostart ?? false} previewVideoAutostart={sc?.previewVideoAutostart ?? false}
previewVideoLoop={sc?.settings.loopVideo ?? false} previewVideoLoop={sc?.settings.loopVideo ?? false}
previewRotationDeg={sc?.previewRotationDeg ?? 0} previewRotationDeg={sc?.previewRotationDeg ?? 0}
darkenScene={sc?.darkenScene ?? false}
previewBusy={previewBusy} previewBusy={previewBusy}
previewBusyText={previewBusyText} previewBusyText={previewBusyText}
mediaAssets={sceneMediaAssets} mediaAssets={sceneMediaAssets}
@@ -1226,7 +1279,6 @@ export function EditorApp() {
onPreviewVideoLoopChange={(next) => onPreviewVideoLoopChange={(next) =>
void actions.updateScene(sid, { settings: { loopVideo: next } }) void actions.updateScene(sid, { settings: { loopVideo: next } })
} }
onDarkenSceneChange={(next) => void actions.updateScene(sid, { darkenScene: next })}
onTitleChange={(title) => void actions.updateScene(sid, { title })} onTitleChange={(title) => void actions.updateScene(sid, { title })}
onDescriptionChange={(description) => onDescriptionChange={(description) =>
void actions.updateScene(sid, { description }) void actions.updateScene(sid, { description })
@@ -1360,6 +1412,18 @@ export function EditorApp() {
> >
{t('menu.presentationSettings')} {t('menu.presentationSettings')}
</button> </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"> <div className={styles.fileMenuSubHost} role="presentation">
<button <button
type="button" type="button"
@@ -1441,46 +1505,6 @@ export function EditorApp() {
snapshot={licenseSnap} snapshot={licenseSnap}
/> />
<AppAboutModal open={appAboutOpen} onClose={() => setAppAboutOpen(false)} appVersion={appVersionText} /> <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 {projectMenuOpen && projectMenuPos
? createPortal( ? createPortal(
<div <div
@@ -1682,6 +1706,22 @@ export function EditorApp() {
open={presentationSettingsOpen} open={presentationSettingsOpen}
onClose={() => setPresentationSettingsOpen(false)} 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 <PlaylistsManagerModal
open={playlistsManagerOpen} open={playlistsManagerOpen}
onClose={() => setPlaylistsManagerOpen(false)} onClose={() => setPlaylistsManagerOpen(false)}
@@ -1697,6 +1737,69 @@ export function EditorApp() {
launchFromGraphNode(graphStartGraphNodeId, playerIds); 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} ) : null}
{USERS_BRANCH_FEATURES_ENABLED && runMenuOpen && runMenuPos {USERS_BRANCH_FEATURES_ENABLED && runMenuOpen && runMenuPos
@@ -1719,6 +1822,22 @@ export function EditorApp() {
> >
{t('top.runWithPlayers')} {t('top.runWithPlayers')}
</button> </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>, </div>,
document.body, document.body,
) )
@@ -2499,7 +2618,6 @@ type SceneInspectorProps = {
previewVideoAutostart: boolean; previewVideoAutostart: boolean;
previewVideoLoop: boolean; previewVideoLoop: boolean;
previewRotationDeg: 0 | 90 | 180 | 270; previewRotationDeg: 0 | 90 | 180 | 270;
darkenScene: boolean;
previewBusy: boolean; previewBusy: boolean;
previewBusyText: string; previewBusyText: string;
mediaAssets: MediaAsset[]; mediaAssets: MediaAsset[];
@@ -2507,7 +2625,6 @@ type SceneInspectorProps = {
onAudioRefsChange: (next: SceneAudioRef[]) => void; onAudioRefsChange: (next: SceneAudioRef[]) => void;
onPreviewVideoAutostartChange: (next: boolean) => void; onPreviewVideoAutostartChange: (next: boolean) => void;
onPreviewVideoLoopChange: (next: boolean) => void; onPreviewVideoLoopChange: (next: boolean) => void;
onDarkenSceneChange: (next: boolean) => void;
onTitleChange: (v: string) => void; onTitleChange: (v: string) => void;
onDescriptionChange: (v: string) => void; onDescriptionChange: (v: string) => void;
onImportPreview: () => void; onImportPreview: () => void;
@@ -2580,7 +2697,6 @@ function SceneInspector({
previewVideoAutostart, previewVideoAutostart,
previewVideoLoop, previewVideoLoop,
previewRotationDeg, previewRotationDeg,
darkenScene,
previewBusy, previewBusy,
previewBusyText, previewBusyText,
mediaAssets, mediaAssets,
@@ -2588,7 +2704,6 @@ function SceneInspector({
onAudioRefsChange, onAudioRefsChange,
onPreviewVideoAutostartChange, onPreviewVideoAutostartChange,
onPreviewVideoLoopChange, onPreviewVideoLoopChange,
onDarkenSceneChange,
onTitleChange, onTitleChange,
onDescriptionChange, onDescriptionChange,
onImportPreview, onImportPreview,
@@ -2742,8 +2857,7 @@ function SceneInspector({
</div> </div>
) : null} ) : null}
{previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? ( {previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? (
<> <div className={styles.actionsRowHalf}>
<div className={styles.spacer6} />
<Button <Button
onClick={() => { onClick={() => {
const next = ((previewRotationDeg + 90) % 360) as 0 | 90 | 180 | 270; const next = ((previewRotationDeg + 90) % 360) as 0 | 90 | 180 | 270;
@@ -2752,28 +2866,14 @@ function SceneInspector({
> >
{t('scene.rotate')} {t('scene.rotate')}
</Button> </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 <Button
onClick={() => { onClick={() => {
void getDndApi().invoke(ipcChannels.windows.openSceneEditor, {}); void getDndApi().invoke(ipcChannels.windows.openSceneEditor, {});
}} }}
> >
Редактор сцены {t('sceneEditor.open')}
</Button> </Button>
</> </div>
) : null} ) : null}
<div className={styles.spacer6} /> <div className={styles.spacer6} />
<div className={styles.labelSm}>{t('scene.audio')}</div> <div className={styles.labelSm}>{t('scene.audio')}</div>
+134
View File
@@ -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>
);
}
+28 -126
View File
@@ -1,18 +1,24 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { APP_DISPLAY_NAME_EN, APP_DISPLAY_NAME_RU } from '../../../shared/appBranding'; import { APP_DISPLAY_NAME_EN, APP_DISPLAY_NAME_RU } from '../../../shared/appBranding';
import { Button } from '../../shared/ui/controls'; import { Button } from '../../shared/ui/controls';
import styles from '../EditorApp.module.css'; 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'; 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 = { type AppAboutModalProps = {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@@ -80,123 +86,25 @@ export function AppAboutModal({ open, onClose, appVersion }: AppAboutModalProps)
{t('app.about.websiteUrl')} {t('app.about.websiteUrl')}
</a> </a>
</div> </div>
</div> <div className={styles.fieldLabel}>{t('app.about.sponsorsLabel')}</div>
</div> <div className={styles.aboutSponsorsList}>
<div className={styles.modalFooter}> {ABOUT_SPONSORS.map((sponsor) =>
<Button variant="primary" onClick={onClose}> sponsor.kind === 'link' ? (
{t('common.close')} <a
</Button> key={sponsor.name}
</div> className={styles.aboutLink}
</div> href={sponsor.url}
</>, target="_blank"
document.body, rel="noreferrer"
);
}
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}
> >
× {sponsor.name}
</button> </a>
</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>
) : ( ) : (
<button <div key={sponsor.name}>{sponsor.name}</div>
key={j}
type="button"
className={styles.instructionsInlineLink}
onClick={() => goToSection(part.id)}
>
{part.value}
</button>
), ),
)} )}
</p>
))}
</div> </div>
<nav ref={navRef} className={styles.instructionsNav} aria-label={t('help.navAria')}> </div>
{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}> <div className={styles.modalFooter}>
<Button variant="primary" onClick={onClose}> <Button variant="primary" onClick={onClose}>
@@ -208,9 +116,3 @@ function InstructionsModalBody({
document.body, 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} />;
}
+7 -1
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import type { HelpSectionId } from './helpSections'; import { parseHelpSectionId, type HelpSectionId } from './helpSections';
import { buildHelpLinkCatalog, findHelpLinkRanges, splitHelpTextWithLinks } from './helpLinkify'; import { buildHelpLinkCatalog, findHelpLinkRanges, splitHelpTextWithLinks } from './helpLinkify';
const RU_TITLES: Record<HelpSectionId, string> = { const RU_TITLES: Record<HelpSectionId, string> = {
@@ -80,3 +80,9 @@ void test('findHelpLinkRanges: полное «Пульт управления»
assert.equal(ranges[0]?.id, 'controlPanel'); assert.equal(ranges[0]?.id, 'controlPanel');
assert.equal(ranges[0]?.text, '«Пульт управления»'); 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');
});
+1
View File
@@ -8,6 +8,7 @@ export const HELP_SECTION_LINK_ALIASES: Partial<Record<HelpSectionId, readonly s
effects: ['Эффекты', 'Effects'], effects: ['Эффекты', 'Effects'],
presentation: ['Презентация', 'Presentation'], presentation: ['Презентация', 'Presentation'],
grid: ['Сетка', 'Grid'], grid: ['Сетка', 'Grid'],
sceneEditor: ['Затемнение сцены', 'Scene darkness', 'Редактор сцены', 'Scene editor'],
controlPanel: ['Пульт'], controlPanel: ['Пульт'],
players: ['Игроки', 'Players'], players: ['Игроки', 'Players'],
tokens: ['Токены', 'Tokens'], tokens: ['Токены', 'Tokens'],
+6
View File
@@ -40,3 +40,9 @@ export function helpSectionTitleKey(id: HelpSectionId): string {
export function helpSectionBodyKey(id: HelpSectionId): string { export function helpSectionBodyKey(id: HelpSectionId): string {
return `help.section.${id}.body`; 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
+14
View File
@@ -43,6 +43,7 @@ type Actions = {
createProject: (name: string) => Promise<void>; createProject: (name: string) => Promise<void>;
openProject: (id: ProjectId) => Promise<void>; openProject: (id: ProjectId) => Promise<void>;
closeProject: () => Promise<void>; closeProject: () => Promise<void>;
noteProject: (project: Project) => void;
createScene: () => Promise<void>; createScene: () => Promise<void>;
createScenesFromMediaPaths: ( createScenesFromMediaPaths: (
items: { filePath: string; title: string }[], items: { filePath: string; title: string }[],
@@ -71,6 +72,7 @@ type Actions = {
previewVideoAutostart?: boolean; previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270; previewRotationDeg?: 0 | 90 | 180 | 270;
darkenScene?: boolean; darkenScene?: boolean;
darknessStrokes?: Scene['darknessStrokes'];
settings?: Partial<Scene['settings']>; settings?: Partial<Scene['settings']>;
media?: Partial<Scene['media']>; media?: Partial<Scene['media']>;
layout?: { x: number; y: number }; layout?: { x: number; y: number };
@@ -354,6 +356,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
previewVideoAutostart: false, previewVideoAutostart: false,
previewRotationDeg: 0, previewRotationDeg: 0,
darkenScene: false, darkenScene: false,
darknessStrokes: [],
traps: [], traps: [],
tokens: [], tokens: [],
npcTokens: [], npcTokens: [],
@@ -564,6 +567,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
previewVideoAutostart?: boolean; previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270; previewRotationDeg?: 0 | 90 | 180 | 270;
darkenScene?: boolean; darkenScene?: boolean;
darknessStrokes?: Scene['darknessStrokes'];
traps?: import('../../../shared/types').SceneTrap[]; traps?: import('../../../shared/types').SceneTrap[];
tokens?: import('../../../shared/types').SceneToken[]; tokens?: import('../../../shared/types').SceneToken[];
npcTokens?: import('../../../shared/types').SceneNpcToken[]; npcTokens?: import('../../../shared/types').SceneNpcToken[];
@@ -594,6 +598,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
? { previewRotationDeg: patch.previewRotationDeg } ? { previewRotationDeg: patch.previewRotationDeg }
: null), : null),
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null), ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
...(patch.darknessStrokes !== undefined ? { darknessStrokes: patch.darknessStrokes } : null),
...(patch.traps !== undefined ? { traps: patch.traps } : null), ...(patch.traps !== undefined ? { traps: patch.traps } : null),
...(patch.tokens !== undefined ? { tokens: patch.tokens } : null), ...(patch.tokens !== undefined ? { tokens: patch.tokens } : null),
...(patch.npcTokens !== undefined ? { npcTokens: patch.npcTokens } : 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) => { const deleteProject = async (projectId: ProjectId) => {
projectDataEpochRef.current += 1; projectDataEpochRef.current += 1;
await api.invoke(ipcChannels.project.deleteProject, { projectId }); await api.invoke(ipcChannels.project.deleteProject, { projectId });
@@ -936,6 +949,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
createProject, createProject,
openProject, openProject,
closeProject, closeProject,
noteProject,
createScene, createScene,
createScenesFromMediaPaths, createScenesFromMediaPaths,
selectScene, selectScene,
+13
View File
@@ -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} />;
}
+23
View File
@@ -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(() => { useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
void api.invoke(ipcChannels.windows.closeMultiWindow, {}); e.preventDefault();
void api.invoke(ipcChannels.windows.requestCloseMultiWindowConfirm, {});
return; return;
} }
if (e.altKey && (e.key === 'Enter' || e.code === 'Enter' || e.code === 'NumpadEnter')) { if (e.altKey && (e.key === 'Enter' || e.code === 'Enter' || e.code === 'NumpadEnter')) {
@@ -86,6 +86,39 @@
user-select: none; 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 { .field {
display: grid; display: grid;
gap: 6px; gap: 6px;
+437 -37
View File
@@ -7,14 +7,21 @@ import type {
NpcDisposition, NpcDisposition,
NpcId, NpcId,
ProjectNpc, ProjectNpc,
Scene,
SceneDarknessBrushShape,
SceneDarknessDraft,
SceneDarknessRevealStroke,
SceneDarknessState,
SceneGrid, SceneGrid,
SceneGridTemplate, SceneGridTemplate,
SceneId,
SceneNpcToken, SceneNpcToken,
SceneToken, SceneToken,
SceneTrap, SceneTrap,
SceneTrapType, SceneTrapType,
TokenId, TokenId,
} from '../../shared/types'; } from '../../shared/types';
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
import { import {
asSceneNpcTokenId, asSceneNpcTokenId,
clampSceneNpcTokenSizeN, clampSceneNpcTokenSizeN,
@@ -38,13 +45,11 @@ import {
SCENE_GRID_SIZE_MAX, SCENE_GRID_SIZE_MAX,
SCENE_GRID_SIZE_MIN, SCENE_GRID_SIZE_MIN,
sceneGridTokenFitFactor, sceneGridTokenFitFactor,
sceneGridTypeLabelRu,
} from '../../shared/types/sceneGrid'; } from '../../shared/types/sceneGrid';
import { import {
asSceneTrapId, asSceneTrapId,
DEFAULT_SCENE_TRAP_SIZE_N, DEFAULT_SCENE_TRAP_SIZE_N,
SCENE_TRAP_TYPES, SCENE_TRAP_TYPES,
trapTypeLabelRu,
} from '../../shared/types/sceneTraps'; } from '../../shared/types/sceneTraps';
import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView'; import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures'; 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 }) { function NpcPaletteTile({ npc }: { npc: ProjectNpc }) {
const imageUrl = useAssetUrl(npc.avatarAssetId); const imageUrl = useAssetUrl(npc.avatarAssetId);
const disposition = normalizeNpcDisposition(npc.disposition); const disposition = normalizeNpcDisposition(npc.disposition);
@@ -272,8 +295,28 @@ export function SceneEditorApp() {
const [session, setSession] = useState<SessionState | null>(null); const [session, setSession] = useState<SessionState | null>(null);
const [trapsOpen, setTrapsOpen] = useState(false); const [trapsOpen, setTrapsOpen] = useState(false);
const [gridOpen, setGridOpen] = useState(false); const [gridOpen, setGridOpen] = useState(false);
const [darknessOpen, setDarknessOpen] = useState(false);
const [tokensOpen, setTokensOpen] = useState(false); const [tokensOpen, setTokensOpen] = useState(false);
const [npcsOpen, setNpcsOpen] = 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 [tokenSearch, setTokenSearch] = useState('');
const [npcSearch, setNpcSearch] = useState(''); const [npcSearch, setNpcSearch] = useState('');
const [tokenModal, setTokenModal] = useState< const [tokenModal, setTokenModal] = useState<
@@ -293,16 +336,23 @@ export function SceneEditorApp() {
const [selected, setSelected] = useState<Selection>(null); const [selected, setSelected] = useState<Selection>(null);
const [view, setView] = useState<LocalView>({ scale: 1, ox: 0.5, oy: 0.5 }); 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 [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 hostRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<DragMode>(null); const dragRef = useRef<DragMode>(null);
const saveTrapsTimerRef = useRef(0); const saveTrapsTimerRef = useRef(0);
const saveTokensTimerRef = useRef(0); const saveTokensTimerRef = useRef(0);
const saveNpcTokensTimerRef = useRef(0); const saveNpcTokensTimerRef = useRef(0);
const saveGridTimerRef = 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 spaceDownRef = useRef(false);
const sceneIdRef = useRef<SceneId | null>(null);
const project = session?.project ?? null; const project = session?.project ?? null;
const sceneId = project?.currentSceneId ?? null; const sceneId = project?.currentSceneId ?? null;
sceneIdRef.current = sceneId;
const scene = sceneId && project ? project.scenes[sceneId] : undefined; const scene = sceneId && project ? project.scenes[sceneId] : undefined;
const url = useAssetUrl(scene?.previewAssetId ?? null); const url = useAssetUrl(scene?.previewAssetId ?? null);
const rot = scene?.previewRotationDeg ?? 0; const rot = scene?.previewRotationDeg ?? 0;
@@ -318,6 +368,7 @@ export function SceneEditorApp() {
const trapsRef = useRef<SceneTrap[]>([]); const trapsRef = useRef<SceneTrap[]>([]);
const tokensRef = useRef<SceneToken[]>([]); const tokensRef = useRef<SceneToken[]>([]);
const npcTokensRef = useRef<SceneNpcToken[]>([]); const npcTokensRef = useRef<SceneNpcToken[]>([]);
const localDarknessStrokesRef = useRef<SceneDarknessRevealStroke[]>([]);
useEffect(() => { useEffect(() => {
trapsRef.current = localTraps; trapsRef.current = localTraps;
@@ -326,12 +377,28 @@ export function SceneEditorApp() {
}, [localNpcTokens, localTokens, localTraps]); }, [localNpcTokens, localTokens, localTraps]);
useEffect(() => { 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 ?? []); setLocalTraps(scene?.traps ?? []);
setLocalTokens((scene?.tokens ?? []).filter((t) => appTokens.some((a) => a.id === t.tokenId))); setLocalTokens((scene?.tokens ?? []).filter((t) => appTokens.some((a) => a.id === t.tokenId)));
setLocalNpcTokens( setLocalNpcTokens(
(scene?.npcTokens ?? []).filter((t) => project?.npcs.some((npc) => npc.id === t.npcId)), (scene?.npcTokens ?? []).filter((t) => project?.npcs.some((npc) => npc.id === t.npcId)),
); );
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID }); 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); setSelected(null);
setView({ scale: 1, ox: 0.5, oy: 0.5 }); setView({ scale: 1, ox: 0.5, oy: 0.5 });
}, [sceneId, scene?.previewAssetId]); }, [sceneId, scene?.previewAssetId]);
@@ -358,6 +425,16 @@ export function SceneEditorApp() {
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID }); setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
}, [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(() => { useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then(({ project: p }) => { void api.invoke(ipcChannels.project.get, {}).then(({ project: p }) => {
setSession({ project: p, currentSceneId: p?.currentSceneId ?? null }); setSession({ project: p, currentSceneId: p?.currentSceneId ?? null });
@@ -406,6 +483,99 @@ export function SceneEditorApp() {
[api, sceneId], [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( const persistGrid = useCallback(
(next: SceneGrid) => { (next: SceneGrid) => {
if (!sceneId) return; if (!sceneId) return;
@@ -422,8 +592,8 @@ export function SceneEditorApp() {
const defaultTemplateName = useMemo(() => { const defaultTemplateName = useMemo(() => {
const sizeLabel = Math.round(localGrid.sizeN * 100); const sizeLabel = Math.round(localGrid.sizeN * 100);
return `${sceneGridTypeLabelRu(localGrid.type)} · ${String(sizeLabel)}`; return `${t(localGrid.type === 'hex' ? 'sceneEditor.gridHex' : 'sceneEditor.gridSquare')} · ${String(sizeLabel)}`;
}, [localGrid.sizeN, localGrid.type]); }, [localGrid.sizeN, localGrid.type, t]);
const saveGridTemplate = async () => { const saveGridTemplate = async () => {
if (!sceneId) return; 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]); const viewCamera = useMemo(() => view, [view]);
useEffect(() => { useEffect(() => {
@@ -634,21 +832,32 @@ export function SceneEditorApp() {
const isVideo = scene?.previewAssetType === 'video' && Boolean(url); const isVideo = scene?.previewAssetType === 'video' && Boolean(url);
const hasMapMedia = isImage || isVideo; 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 ( return (
<div className={styles.page}> <div className={styles.page}>
<aside className={styles.sidebar}> <aside className={styles.sidebar}>
<EllipsisText <EllipsisText
text={scene?.title ?? 'Сцена'} text={scene?.title ?? t('sceneEditor.untitledScene')}
className={[styles.sideTitle, ellipsisStyles.root].join(' ')} className={[styles.sideTitle, ellipsisStyles.root].join(' ')}
/> />
<div className={styles.hint}> <div className={styles.hint}>
Колесо — зум. СКМ / Space+ЛКМ — пан. Delete — удалить выбранное. {t('sceneEditor.mapHint')}
</div> </div>
<div className={styles.accordionScroll}> <div className={styles.accordionScroll}>
<div className={styles.accordion}> <div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setGridOpen((v) => !v)}> <button type="button" className={styles.accordionHead} onClick={() => setGridOpen((v) => !v)}>
Сетка {gridOpen ? '▾' : '▸'} {t('sceneEditor.grid')} {gridOpen ? '▾' : '▸'}
</button> </button>
{gridOpen ? ( {gridOpen ? (
<div className={styles.gridPanel}> <div className={styles.gridPanel}>
@@ -658,17 +867,17 @@ export function SceneEditorApp() {
checked={localGrid.enabled} checked={localGrid.enabled}
onChange={(e) => persistGrid({ ...localGrid, enabled: e.target.checked })} onChange={(e) => persistGrid({ ...localGrid, enabled: e.target.checked })}
/> />
<span>Наложить сетку</span> <span>{t('sceneEditor.enableGrid')}</span>
</label> </label>
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}> <label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
<span className={styles.fieldLabel}>Тип</span> <span className={styles.fieldLabel}>{t('sceneEditor.gridType')}</span>
<Select <Select
disabled={!localGrid.enabled} disabled={!localGrid.enabled}
value={localGrid.type} value={localGrid.type}
ariaLabel="Тип сетки" ariaLabel={t('sceneEditor.gridTypeAria')}
options={[ options={[
{ value: 'square', label: sceneGridTypeLabelRu('square') }, { value: 'square', label: t('sceneEditor.gridSquare') },
{ value: 'hex', label: sceneGridTypeLabelRu('hex') }, { value: 'hex', label: t('sceneEditor.gridHex') },
]} ]}
onChange={(next) => onChange={(next) =>
persistGrid({ persistGrid({
@@ -679,19 +888,19 @@ export function SceneEditorApp() {
/> />
</label> </label>
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}> <label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
<span className={styles.fieldLabel}>Цвет</span> <span className={styles.fieldLabel}>{t('sceneEditor.gridColor')}</span>
<input <input
type="color" type="color"
className={styles.colorInput} className={styles.colorInput}
disabled={!localGrid.enabled} disabled={!localGrid.enabled}
value={localGrid.color} value={localGrid.color}
onChange={(e) => persistGrid({ ...localGrid, color: e.target.value })} onChange={(e) => persistGrid({ ...localGrid, color: e.target.value })}
aria-label="Цвет сетки" aria-label={t('sceneEditor.gridColorAria')}
/> />
</label> </label>
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}> <label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
<span className={styles.fieldLabel}> <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> </span>
<input <input
type="range" type="range"
@@ -784,7 +993,7 @@ export function SceneEditorApp() {
<div className={styles.templateName}>{tpl.name}</div> <div className={styles.templateName}>{tpl.name}</div>
)} )}
<div className={styles.templateHint}> <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> </div>
<div className={styles.templateActions}> <div className={styles.templateActions}>
@@ -830,24 +1039,116 @@ export function SceneEditorApp() {
) : null} ) : null}
</div> </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 ? ( {USERS_BRANCH_FEATURES_ENABLED ? (
<div className={styles.accordion} data-testid="scene-npc-accordion"> <div className={styles.accordion} data-testid="scene-npc-accordion">
<button type="button" className={styles.accordionHead} onClick={() => setNpcsOpen((v) => !v)}> <button type="button" className={styles.accordionHead} onClick={() => setNpcsOpen((v) => !v)}>
НПС {npcsOpen ? '▾' : '▸'} {t('sceneEditor.npcs')} {npcsOpen ? '▾' : '▸'}
</button> </button>
{npcsOpen ? ( {npcsOpen ? (
<div className={styles.tokensPanel}> <div className={styles.tokensPanel}>
<Input <Input
value={npcSearch} value={npcSearch}
onChange={setNpcSearch} onChange={setNpcSearch}
placeholder="Поиск НПС…" placeholder={t('sceneEditor.npcSearch')}
onKeyDown={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}
/> />
<div className={styles.tokenGrid}> <div className={styles.tokenGrid}>
{filteredNpcs.map((npc) => ( {filteredNpcs.map((npc) => (
<NpcPaletteTile key={npc.id} npc={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>
</div> </div>
) : null} ) : null}
@@ -856,22 +1157,22 @@ export function SceneEditorApp() {
<div className={styles.accordion}> <div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setTokensOpen((v) => !v)}> <button type="button" className={styles.accordionHead} onClick={() => setTokensOpen((v) => !v)}>
Неигровые токены {tokensOpen ? '▾' : '▸'} {t('sceneEditor.tokens')} {tokensOpen ? '▾' : '▸'}
</button> </button>
{tokensOpen ? ( {tokensOpen ? (
<div className={styles.tokensPanel}> <div className={styles.tokensPanel}>
<div className={styles.tokensToolbar}> <div className={styles.tokensToolbar}>
<Button onClick={() => setTokenModal({ mode: 'create' })}>Добавить</Button> <Button onClick={() => setTokenModal({ mode: 'create' })}>{t('sceneEditor.add')}</Button>
</div> </div>
<Input <Input
value={tokenSearch} value={tokenSearch}
onChange={setTokenSearch} onChange={setTokenSearch}
placeholder="Поиск…" placeholder={t('sceneEditor.tokenSearch')}
onKeyDown={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}
/> />
<div className={styles.tokenGrid}> <div className={styles.tokenGrid}>
{filteredTokens.length === 0 ? ( {filteredTokens.length === 0 ? (
<div className={styles.hint}>Нет токенов</div> <div className={styles.hint}>{t('sceneEditor.noTokens')}</div>
) : ( ) : (
filteredTokens.map((token) => ( filteredTokens.map((token) => (
<TokenTile <TokenTile
@@ -889,7 +1190,7 @@ export function SceneEditorApp() {
<div className={styles.accordion}> <div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setTrapsOpen((v) => !v)}> <button type="button" className={styles.accordionHead} onClick={() => setTrapsOpen((v) => !v)}>
Ловушки {trapsOpen ? '▾' : '▸'} {t('sceneEditor.traps')} {trapsOpen ? '▾' : '▸'}
</button> </button>
{trapsOpen ? ( {trapsOpen ? (
<div className={styles.palette}> <div className={styles.palette}>
@@ -902,10 +1203,10 @@ export function SceneEditorApp() {
e.dataTransfer.setData('application/x-dnd-trap-type', type); e.dataTransfer.setData('application/x-dnd-trap-type', type);
e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.effectAllowed = 'copy';
}} }}
title="Перетащите на карту" title={t('sceneEditor.dragToMap')}
> >
<TrapGlyph type={type} size={22} /> <TrapGlyph type={type} size={22} />
<span className={styles.paletteLabel}>{trapTypeLabelRu(type)}</span> <span className={styles.paletteLabel}>{t(`sceneEditor.trap.${type}`)}</span>
</div> </div>
))} ))}
</div> </div>
@@ -940,7 +1241,7 @@ export function SceneEditorApp() {
}); });
}} }}
> >
Очистить сцену {t('sceneEditor.clearScene')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -948,7 +1249,7 @@ export function SceneEditorApp() {
<div className={styles.stage}> <div className={styles.stage}>
{!hasMapMedia ? ( {!hasMapMedia ? (
<div className={styles.empty}>Нужно изображение или видео сцены</div> <div className={styles.empty}>{t('sceneEditor.needMedia')}</div>
) : ( ) : (
<div <div
ref={hostRef} ref={hostRef}
@@ -1073,6 +1374,105 @@ export function SceneEditorApp() {
/> />
)} )}
<SceneGridOverlay grid={localGrid} viewport={contentRect} /> <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 <TokenPathsOverlay
tokens={localTokens} tokens={localTokens}
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? localNpcTokens : []} npcTokens={USERS_BRANCH_FEATURES_ENABLED ? localNpcTokens : []}
@@ -1278,13 +1678,13 @@ export function SceneEditorApp() {
<> <>
<button <button
type="button" type="button"
aria-label="Закрыть" aria-label={t('common.close')}
className={editorStyles.modalBackdrop} className={editorStyles.modalBackdrop}
onClick={() => setPendingDeleteToken(null)} onClick={() => setPendingDeleteToken(null)}
/> />
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}> <div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}> <div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>Удалить токен</div> <div className={editorStyles.modalTitle}>{t('sceneEditor.deleteTokenTitle')}</div>
<button <button
type="button" type="button"
className={editorStyles.modalClose} className={editorStyles.modalClose}
@@ -1294,10 +1694,10 @@ export function SceneEditorApp() {
</button> </button>
</div> </div>
<div> <div>
Удалить токен «{pendingDeleteToken.name}» из пула? Он также будет убран с текущей сцены. {t('sceneEditor.deleteTokenBody', { name: pendingDeleteToken.name })}
</div> </div>
<div className={editorStyles.modalFooter}> <div className={editorStyles.modalFooter}>
<Button onClick={() => setPendingDeleteToken(null)}>Отмена</Button> <Button onClick={() => setPendingDeleteToken(null)}>{t('common.cancel')}</Button>
<Button <Button
variant="primary" variant="primary"
onClick={() => { onClick={() => {
@@ -1307,7 +1707,7 @@ export function SceneEditorApp() {
persistTokens(tokensRef.current.filter((t) => t.tokenId !== id)); persistTokens(tokensRef.current.filter((t) => t.tokenId !== id));
}} }}
> >
Удалить {t('common.delete')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -1345,7 +1745,7 @@ export function SceneEditorApp() {
.catch((err) => console.error('[sceneEditor] openTokenPathEditor', err)); .catch((err) => console.error('[sceneEditor] openTokenPathEditor', err));
}} }}
> >
Указать движение {t('sceneEditor.setPath')}
</button> </button>
{(() => { {(() => {
const tok = localTokens.find((item) => item.id === tokenCtxMenu.placementId); const tok = localTokens.find((item) => item.id === tokenCtxMenu.placementId);
@@ -1369,7 +1769,7 @@ export function SceneEditorApp() {
setTokenCtxMenu(null); setTokenCtxMenu(null);
}} }}
> >
Сбросить на старт пути {t('sceneEditor.resetPath')}
</button> </button>
); );
})()} })()}
@@ -1423,7 +1823,7 @@ export function SceneEditorApp() {
.catch((err) => console.error('[sceneEditor] openTokenPathEditor', err)); .catch((err) => console.error('[sceneEditor] openTokenPathEditor', err));
}} }}
> >
Указать движение {t('sceneEditor.setPath')}
</button> </button>
{(() => { {(() => {
const tok = localNpcTokens.find((item) => item.id === npcCtxMenu.placementId); const tok = localNpcTokens.find((item) => item.id === npcCtxMenu.placementId);
@@ -1441,7 +1841,7 @@ export function SceneEditorApp() {
setNpcCtxMenu(null); setNpcCtxMenu(null);
}} }}
> >
Сбросить на старт пути {t('sceneEditor.resetPath')}
</button> </button>
); );
})()} })()}
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import type { SceneToken } from '../../shared/types'; import type { SceneToken } from '../../shared/types';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { useTokenImageUrl } from '../shared/tokens/useTokenImageUrl'; import { useTokenImageUrl } from '../shared/tokens/useTokenImageUrl';
import styles from './SceneEditorApp.module.css'; import styles from './SceneEditorApp.module.css';
@@ -30,6 +31,7 @@ export function SceneTokenMarker({
onResizePointerDown, onResizePointerDown,
onRotatePointerDown, onRotatePointerDown,
}: Props) { }: Props) {
const { t } = useEditorI18n();
const url = useTokenImageUrl(token.tokenId); const url = useTokenImageUrl(token.tokenId);
return ( return (
<div <div
@@ -41,7 +43,7 @@ export function SceneTokenMarker({
height: sizePx, height: sizePx,
transform: `translate(-50%, -50%) rotate(${String(token.rotationDeg)}deg)`, transform: `translate(-50%, -50%) rotate(${String(token.rotationDeg)}deg)`,
}} }}
title="Перетащите · ПКМ — удалить" title={t('sceneEditor.dragDelete')}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
onPointerDown={(e) => { onPointerDown={(e) => {
if (e.button !== 0) return; if (e.button !== 0) return;
@@ -56,8 +58,8 @@ export function SceneTokenMarker({
<button <button
type="button" type="button"
className={styles.rotateHandle} className={styles.rotateHandle}
aria-label="Повернуть" aria-label={t('sceneEditor.rotate')}
title="Повернуть" title={t('sceneEditor.rotate')}
onPointerDown={onRotatePointerDown} onPointerDown={onRotatePointerDown}
> >
↻ ↻
+17 -15
View File
@@ -12,6 +12,7 @@ import {
} from '../editor/fileDrop'; } from '../editor/fileDrop';
import editorStyles from '../editor/EditorApp.module.css'; import editorStyles from '../editor/EditorApp.module.css';
import matStyles from '../editor/MaterialsModals.module.css'; import matStyles from '../editor/MaterialsModals.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi'; import { getDndApi } from '../shared/dndApi';
import { Button, Input } from '../shared/ui/controls'; import { Button, Input } from '../shared/ui/controls';
import { useTokenImageUrl } from '../shared/tokens/useTokenImageUrl'; import { useTokenImageUrl } from '../shared/tokens/useTokenImageUrl';
@@ -29,6 +30,7 @@ function normalizeName(input: string): string {
} }
export function TokenEditModal({ open, initial, existingNames, onClose, onSaved }: Props) { export function TokenEditModal({ open, initial, existingNames, onClose, onSaved }: Props) {
const { t } = useEditorI18n();
const api = getDndApi(); const api = getDndApi();
const [name, setName] = useState(''); const [name, setName] = useState('');
const [filePath, setFilePath] = useState<string | null>(null); const [filePath, setFilePath] = useState<string | null>(null);
@@ -94,26 +96,26 @@ export function TokenEditModal({ open, initial, existingNames, onClose, onSaved
return createPortal( 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 role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}> <div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}> <div className={editorStyles.modalTitle}>
{initial ? 'Изменить токен' : 'Добавить токен'} {initial ? t('sceneEditor.editToken') : t('sceneEditor.addToken')}
</div> </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> </button>
</div> </div>
<div className={editorStyles.fieldGrid}> <div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>Название</div> <div className={editorStyles.fieldLabel}>{t('sceneEditor.tokenName')}</div>
<Input value={name} onChange={setName} placeholder="Название токена" /> <Input value={name} onChange={setName} placeholder={t('sceneEditor.tokenNamePlaceholder')} />
{!nameOk ? <div className={editorStyles.fieldError}>Укажите название</div> : null} {!nameOk ? <div className={editorStyles.fieldError}>{t('sceneEditor.tokenNameRequired')}</div> : null}
{nameDup ? <div className={editorStyles.fieldError}>Такое название уже есть</div> : null} {nameDup ? <div className={editorStyles.fieldError}>{t('sceneEditor.tokenNameDuplicate')}</div> : null}
</div> </div>
<div className={editorStyles.fieldGrid}> <div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>Изображение</div> <div className={editorStyles.fieldLabel}>{t('sceneEditor.image')}</div>
<div <div
className={[matStyles.imageDrop, drop.dragOver ? matStyles.imageDropOver : ''].join(' ')} className={[matStyles.imageDrop, drop.dragOver ? matStyles.imageDropOver : ''].join(' ')}
onDragEnter={drop.onDragEnter} onDragEnter={drop.onDragEnter}
@@ -137,12 +139,12 @@ export function TokenEditModal({ open, initial, existingNames, onClose, onSaved
}} }}
> >
{drop.dragOver ? ( {drop.dragOver ? (
<div className={editorStyles.dropHintOverlay}>Отпустите, чтобы загрузить изображение</div> <div className={editorStyles.dropHintOverlay}>{t('sceneEditor.dropImage')}</div>
) : null} ) : null}
{previewSrc ? ( {previewSrc ? (
<img src={previewSrc} alt="" className={matStyles.previewThumb} draggable={false} /> <img src={previewSrc} alt="" className={matStyles.previewThumb} draggable={false} />
) : ( ) : (
<div className={editorStyles.muted}>Перетащите изображение или выберите файл</div> <div className={editorStyles.muted}>{t('sceneEditor.dropOrPick')}</div>
)} )}
<Button <Button
onClick={() => { onClick={() => {
@@ -153,17 +155,17 @@ export function TokenEditModal({ open, initial, existingNames, onClose, onSaved
})(); })();
}} }}
> >
Выбрать изображение {t('sceneEditor.chooseImage')}
</Button> </Button>
</div> </div>
{!hasImage ? <div className={editorStyles.fieldError}>Нужно изображение</div> : null} {!hasImage ? <div className={editorStyles.fieldError}>{t('sceneEditor.imageRequired')}</div> : null}
</div> </div>
{error ? <div className={editorStyles.fieldError}>{error}</div> : null} {error ? <div className={editorStyles.fieldError}>{error}</div> : null}
<div className={editorStyles.modalFooter}> <div className={editorStyles.modalFooter}>
<Button onClick={onClose} disabled={saving}> <Button onClick={onClose} disabled={saving}>
Отмена {t('common.cancel')}
</Button> </Button>
<Button <Button
variant="primary" variant="primary"
@@ -182,14 +184,14 @@ export function TokenEditModal({ open, initial, existingNames, onClose, onSaved
onSaved(token); onSaved(token);
onClose(); onClose();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Не удалось сохранить'); setError(e instanceof Error ? e.message : t('sceneEditor.saveFailed'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
})(); })();
}} }}
> >
Сохранить {t('common.save')}
</Button> </Button>
</div> </div>
</div> </div>
+7 -5
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import type { AppToken } from '../../shared/types'; import type { AppToken } from '../../shared/types';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { useTokenImageUrl } from '../shared/tokens/useTokenImageUrl'; import { useTokenImageUrl } from '../shared/tokens/useTokenImageUrl';
import styles from './SceneEditorApp.module.css'; import styles from './SceneEditorApp.module.css';
@@ -15,6 +16,7 @@ type Props = {
}; };
export function TokenTile({ token, onEdit, onDelete }: Props) { export function TokenTile({ token, onEdit, onDelete }: Props) {
const { t } = useEditorI18n();
const url = useTokenImageUrl(token.id); const url = useTokenImageUrl(token.id);
const menuBtnRef = useRef<HTMLButtonElement | null>(null); const menuBtnRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | 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.setData(TOKEN_DND_MIME, token.id);
e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.effectAllowed = 'copy';
}} }}
title="Перетащите на карту" title={t('sceneEditor.dragToMap')}
> >
<div className={styles.tokenThumb}> <div className={styles.tokenThumb}>
{url ? <img src={url} alt="" draggable={false} /> : null} {url ? <img src={url} alt="" draggable={false} /> : null}
@@ -74,10 +76,10 @@ export function TokenTile({ token, onEdit, onDelete }: Props) {
ref={menuBtnRef} ref={menuBtnRef}
type="button" type="button"
className={styles.tokenTileMore} className={styles.tokenTileMore}
aria-label="Действия" aria-label={t('sceneEditor.actions')}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={menu !== null} aria-expanded={menu !== null}
title="Действия" title={t('sceneEditor.actions')}
onClick={openMenu} onClick={openMenu}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
> >
@@ -102,7 +104,7 @@ export function TokenTile({ token, onEdit, onDelete }: Props) {
onEdit(); onEdit();
}} }}
> >
Изменить {t('sceneEditor.edit')}
</button> </button>
<button <button
type="button" type="button"
@@ -113,7 +115,7 @@ export function TokenTile({ token, onEdit, onDelete }: Props) {
onDelete(); onDelete();
}} }}
> >
Удалить {t('common.delete')}
</button> </button>
</div>, </div>,
document.body, document.body,
+6 -2
View File
@@ -107,6 +107,9 @@ export function PresentationView({
sceneNpcTokensSession.spawned, sceneNpcTokensSession.spawned,
session?.currentSceneId, session?.currentSceneId,
); );
const presentationNpcTokens = visibleNpcTokens.filter(
(token) => !sceneNpcTokensSession.byPlacementId[String(token.id)]?.hiddenFromPlayers,
);
useTokenPathLivePoses({ useTokenPathLivePoses({
tokens: scene?.tokens ?? [], tokens: scene?.tokens ?? [],
npcTokens: visibleNpcTokens, npcTokens: visibleNpcTokens,
@@ -245,7 +248,7 @@ export function PresentationView({
{(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? ( {(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? (
<TokenPathsOverlay <TokenPathsOverlay
tokens={scene.tokens ?? []} tokens={scene.tokens ?? []}
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? visibleNpcTokens : []} npcTokens={USERS_BRANCH_FEATURES_ENABLED ? presentationNpcTokens : []}
viewport={contentRect} viewport={contentRect}
mode="presentation" mode="presentation"
pathSession={tokenPathSession} pathSession={tokenPathSession}
@@ -268,6 +271,7 @@ export function PresentationView({
session={sceneNpcTokensSession} session={sceneNpcTokensSession}
viewport={contentRect} viewport={contentRect}
grid={scene.grid} grid={scene.grid}
concealFromPlayers
/> />
) : null} ) : null}
{USERS_BRANCH_FEATURES_ENABLED && {USERS_BRANCH_FEATURES_ENABLED &&
@@ -338,7 +342,7 @@ export function PresentationView({
))} ))}
{activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null} {activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null}
</SceneOverlayHost> </SceneOverlayHost>
<SessionTimerLayer viewport={contentRect} editable={false} /> <SessionTimerLayer viewport={markerViewport} editable={false} />
<DiceRollLayer /> <DiceRollLayer />
{showTitle ? ( {showTitle ? (
<div className={styles.titleWrap}> <div className={styles.titleWrap}>
@@ -1,9 +1,16 @@
.host { .host {
position: absolute; position: absolute;
inset: 0;
contain: layout paint; contain: layout paint;
} }
.hostFill {
inset: 0;
}
.hostBound {
overflow: hidden;
}
.hostInteractive { .hostInteractive {
pointer-events: auto; pointer-events: auto;
} }
@@ -49,3 +49,16 @@ void test('PxiEffectsOverlay: эффекты перекладываются пр
assert.ok(src.includes('viewportSig')); assert.ok(src.includes('viewportSig'));
assert.match(src, /\$\{contentSig\}@\$\{viewportSig\(viewport\)\}/); assert.match(src, /\$\{contentSig\}@\$\{viewportSig\(viewport\)\}/);
}); });
void test('PxiEffectsOverlay: canvas по contentRect + preview profile', () => {
const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8');
const css = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.module.css'), 'utf8');
assert.ok(css.includes('.hostBound'));
assert.ok(css.includes('.hostFill'));
assert.match(src, /perfProfile\s*=\s*'live'/);
assert.match(src, /resolution:\s*preview\s*\?\s*0\.5\s*:\s*dpr/);
assert.match(src, /maxFPS\s*=\s*preview\s*\?\s*12\s*:\s*32/);
assert.ok(src.includes('decimateFieldStampPoints'));
assert.ok(src.includes('fieldStampPoints'));
assert.match(src, /viewportRef\.current = \{ x: 0, y: 0, w: viewport\.w/);
});
@@ -1,5 +1,6 @@
import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { decimateFieldStampPoints, fieldStampMinSpacingN } from '../../../shared/fieldEffectStampPoints';
import type { import type {
EffectInstance, EffectInstance,
EffectInstanceType, EffectInstanceType,
@@ -9,6 +10,10 @@ import type {
import styles from './PxiEffectsOverlay.module.css'; import styles from './PxiEffectsOverlay.module.css';
function fieldStampPoints(inst: Extract<EffectInstance, { type: 'fog' | 'fire' | 'rain' }>) {
return decimateFieldStampPoints(inst.points, fieldStampMinSpacingN(inst.type, inst.radiusN));
}
export type PixiEffectsOverlayHandle = { export type PixiEffectsOverlayHandle = {
/** Draft-штрих без React re-render родителя; null — сброс. */ /** Draft-штрих без React re-render родителя; null — сброс. */
setDraft: (instance: EffectInstance | null) => void; setDraft: (instance: EffectInstance | null) => void;
@@ -193,6 +198,11 @@ type Props = {
style?: React.CSSProperties; style?: React.CSSProperties;
/** Область “контента” внутри контейнера (например при object-fit: contain). */ /** Область “контента” внутри контейнера (например при object-fit: contain). */
viewport?: { x: number; y: number; w: number; h: number } | undefined; viewport?: { x: number; y: number; w: number; h: number } | undefined;
/**
* `preview` — пульт: ниже FPS, меньший backing store, без покачивания спрайтов.
* Презентация остаётся `live`.
*/
perfProfile?: 'live' | 'preview';
}; };
/** /**
@@ -203,7 +213,7 @@ type Props = {
* - Draft кисти идёт через `setDraft` (imperative), без re-render ControlApp. * - Draft кисти идёт через `setDraft` (imperative), без re-render ControlApp.
*/ */
export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(function PixiEffectsOverlay( export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(function PixiEffectsOverlay(
{ state, interactive = false, style, viewport }, { state, interactive = false, style, viewport, perfProfile = 'live' },
ref, ref,
) { ) {
const hostRef = useRef<HTMLDivElement | null>(null); const hostRef = useRef<HTMLDivElement | null>(null);
@@ -320,17 +330,18 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
const pixi = await import('pixi.js'); const pixi = await import('pixi.js');
pixiRef.current = pixi; pixiRef.current = pixi;
if (destroyed) return; if (destroyed) return;
const preview = perfProfile === 'preview';
app = new pixi.Application(); app = new pixi.Application();
await app.init({ await app.init({
backgroundAlpha: 0, backgroundAlpha: 0,
antialias: false, antialias: false,
powerPreference: 'high-performance', powerPreference: 'high-performance',
resolution: dpr, resolution: preview ? 0.5 : dpr,
autoDensity: true, autoDensity: true,
preference: 'webgl', preference: 'webgl',
}); });
// Меньше кадров — меньше CPU/GPU; анимации эффектов остаются плавными. // live: 32 FPS; preview (пульт): 12 — второй WebGL поверх видео на Mac дешевле.
app.ticker.maxFPS = 32; app.ticker.maxFPS = preview ? 12 : 32;
if (destroyed) return; if (destroyed) return;
host.appendChild(app.canvas); host.appendChild(app.canvas);
// Canvas по умолчанию перехватывает hit-test; оставляем клики «сквозь» оверлей для слоя кисти сверху. // Canvas по умолчанию перехватывает hit-test; оставляем клики «сквозь» оверлей для слоя кисти сверху.
@@ -370,12 +381,15 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
const s = stateRef.current; const s = stateRef.current;
if (!s || s.instances.length === 0) return; if (!s || s.instances.length === 0) return;
const nowMs = Date.now() + timeOffsetRef.current; const nowMs = Date.now() + timeOffsetRef.current;
animateNodes(pixi, nodesRef.current, s, nowMs, sizeRef.current, viewportRef.current); const motion = !preview;
animateNodes(pixi, nodesRef.current, s, nowMs, sizeRef.current, viewportRef.current, {
motion,
});
// Лёгкое “потряхивание” сцены в момент удара молнии. // Лёгкое “потряхивание” сцены в момент удара молнии.
// Делаем через смещение корневого контейнера, чтобы не вмешиваться в рендерер/камера-логику. // Делаем через смещение корневого контейнера, чтобы не вмешиваться в рендерер/камера-логику.
const rootNode = rootRef.current; const rootNode = rootRef.current;
if (rootNode) { if (rootNode && motion) {
const { x, y } = computeSceneShake(s, nowMs, sizeRef.current); const { x, y } = computeSceneShake(s, nowMs, sizeRef.current);
rootNode.x = x; rootNode.x = x;
rootNode.y = y; rootNode.y = y;
@@ -404,7 +418,7 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
host.replaceChildren(); host.replaceChildren();
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [gpuBoot]); }, [gpuBoot, perfProfile]);
useEffect(() => { useEffect(() => {
const host = hostRef.current; const host = hostRef.current;
@@ -420,9 +434,9 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
}, [state]); }, [state]);
useEffect(() => { useEffect(() => {
// Если viewport не задан — используем “весь холст”. // Host уже стоит на contentRect — в Pixi локальные координаты с (0,0).
viewportProvidedRef.current = Boolean(viewport); viewportProvidedRef.current = Boolean(viewport);
if (viewport) viewportRef.current = viewport; if (viewport) viewportRef.current = { x: 0, y: 0, w: viewport.w, h: viewport.h };
else viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h }; else viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h };
const pixi = pixiRef.current; const pixi = pixiRef.current;
const root = rootRef.current; const root = rootRef.current;
@@ -437,9 +451,22 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
} }
}, [viewport]); }, [viewport]);
const hostClass = [styles.host, interactive ? styles.hostInteractive : styles.hostPassthrough].join(' '); const hostClass = [
styles.host,
viewport ? styles.hostBound : styles.hostFill,
interactive ? styles.hostInteractive : styles.hostPassthrough,
].join(' ');
const hostStyle: React.CSSProperties = viewport
? {
...style,
left: viewport.x,
top: viewport.y,
width: viewport.w,
height: viewport.h,
}
: { ...style };
return <div ref={hostRef} className={hostClass} style={style} />; return <div ref={hostRef} className={hostClass} style={hostStyle} />;
}); });
function syncNodes( function syncNodes(
@@ -530,8 +557,9 @@ function createInstanceNode(
const r = inst.radiusN * minDim; const r = inst.radiusN * minDim;
const textures = getFogVfxFrameTextures(pixi); const textures = getFogVfxFrameTextures(pixi);
const c = new pixi.Container(); const c = new pixi.Container();
for (let i = 0; i < inst.points.length; i += 1) { const stamps = fieldStampPoints(inst);
const p = inst.points[i]; for (let i = 0; i < stamps.length; i += 1) {
const p = stamps[i];
if (!p) continue; if (!p) continue;
const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY); const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
s.anchor?.set?.(0.5, 0.5); s.anchor?.set?.(0.5, 0.5);
@@ -570,8 +598,9 @@ function createInstanceNode(
const r = inst.radiusN * minDim; const r = inst.radiusN * minDim;
const textures = getGroundFireVfxFrameTextures(pixi); const textures = getGroundFireVfxFrameTextures(pixi);
const c = new pixi.Container(); const c = new pixi.Container();
for (let i = 0; i < inst.points.length; i += 1) { const stamps = fieldStampPoints(inst);
const p = inst.points[i]; for (let i = 0; i < stamps.length; i += 1) {
const p = stamps[i];
if (!p) continue; if (!p) continue;
const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY); const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
s.anchor?.set?.(0.5, 0.62); s.anchor?.set?.(0.5, 0.62);
@@ -613,8 +642,9 @@ function createInstanceNode(
const r = inst.radiusN * minDim; const r = inst.radiusN * minDim;
const textures = getRainVfxFrameTextures(pixi); const textures = getRainVfxFrameTextures(pixi);
const c = new pixi.Container(); const c = new pixi.Container();
for (let i = 0; i < inst.points.length; i += 1) { const stamps = fieldStampPoints(inst);
const p = inst.points[i]; for (let i = 0; i < stamps.length; i += 1) {
const p = stamps[i];
if (!p) continue; if (!p) continue;
const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY); const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
s.anchor?.set?.(0.5, 0.5); s.anchor?.set?.(0.5, 0.5);
@@ -885,6 +915,7 @@ function animateNodes(
nowMs: number, nowMs: number,
size: { w: number; h: number }, size: { w: number; h: number },
viewport: { x: number; y: number; w: number; h: number }, viewport: { x: number; y: number; w: number; h: number },
opts?: { motion?: boolean },
) { ) {
const instById = new Map(state.instances.map((i) => [i.id, i])); const instById = new Map(state.instances.map((i) => [i.id, i]));
void pixi; void pixi;
@@ -940,12 +971,14 @@ function animateNodes(
child.texture = frame; child.texture = frame;
} }
} }
if (opts?.motion !== false) {
const wobble = typeof fx.wobble === 'number' ? fx.wobble : 2; const wobble = typeof fx.wobble === 'number' ? fx.wobble : 2;
child.x = fx.bx + Math.sin(0.0016 * t + (fx.phase ?? 0) * 6.28) * wobble; child.x = fx.bx + Math.sin(0.0016 * t + (fx.phase ?? 0) * 6.28) * wobble;
child.y = fx.by + Math.cos(0.0019 * t + (fx.phase ?? 0) * 6.28) * wobble * 0.45; child.y = fx.by + Math.cos(0.0019 * t + (fx.phase ?? 0) * 6.28) * wobble * 0.45;
const local = 0.93 + 0.07 * Math.sin(0.0042 * t + (fx.phase ?? 0) * 12.56); const local = 0.93 + 0.07 * Math.sin(0.0042 * t + (fx.phase ?? 0) * 12.56);
child.width = (typeof fx.w0 === 'number' ? fx.w0 : child.width) * local; child.width = (typeof fx.w0 === 'number' ? fx.w0 : child.width) * local;
child.height = (typeof fx.h0 === 'number' ? fx.h0 : child.height) * (0.96 + (local - 0.93) * 0.7); child.height = (typeof fx.h0 === 'number' ? fx.h0 : child.height) * (0.96 + (local - 0.93) * 0.7);
}
child.alpha = 1; child.alpha = 1;
} }
} }
@@ -991,9 +1024,11 @@ function animateNodes(
} }
} }
const alphaMul = typeof fx.alphaMul === 'number' ? fx.alphaMul : 1; const alphaMul = typeof fx.alphaMul === 'number' ? fx.alphaMul : 1;
if (opts?.motion !== false) {
const wob = typeof fx.drift === 'number' ? fx.drift : 0; const wob = typeof fx.drift === 'number' ? fx.drift : 0;
child.x = fx.bx + Math.sin(0.0011 * t + (fx.phase ?? 0) * 6.28) * wob; child.x = fx.bx + Math.sin(0.0011 * t + (fx.phase ?? 0) * 6.28) * wob;
child.y = fx.by + Math.cos(0.0014 * t + (fx.phase ?? 0) * 6.28) * wob * 0.35; child.y = fx.by + Math.cos(0.0014 * t + (fx.phase ?? 0) * 6.28) * wob * 0.35;
}
child.width = typeof fx.w0 === 'number' ? fx.w0 : child.width; child.width = typeof fx.w0 === 'number' ? fx.w0 : child.width;
child.height = typeof fx.h0 === 'number' ? fx.h0 : child.height; child.height = typeof fx.h0 === 'number' ? fx.h0 : child.height;
child.alpha = Math.max(0.25, Math.min(1, inst.opacity * base * alphaMul)); child.alpha = Math.max(0.25, Math.min(1, inst.opacity * base * alphaMul));
@@ -1867,10 +1902,11 @@ function relayoutInstanceNode(
if (inst.type === 'fog') { if (inst.type === 'fog') {
const r = inst.radiusN * minDim; const r = inst.radiusN * minDim;
const fogSize = Math.max(4, r * 2); const fogSize = Math.max(4, r * 2);
const stamps = fieldStampPoints(inst);
const children = node.children ?? []; const children = node.children ?? [];
let pi = 0; let pi = 0;
for (const child of children) { for (const child of children) {
const p = inst.points[pi]; const p = stamps[pi];
pi += 1; pi += 1;
if (!p) continue; if (!p) continue;
const fx = (child as any).__fx ?? {}; const fx = (child as any).__fx ?? {};
@@ -1891,10 +1927,11 @@ function relayoutInstanceNode(
const r = inst.radiusN * minDim; const r = inst.radiusN * minDim;
const flameWidth = Math.max(r * 3.2, minDim * 0.05); const flameWidth = Math.max(r * 3.2, minDim * 0.05);
const flameHeight = flameWidth / GROUND_FIRE_VFX_FRAME_ASPECT; const flameHeight = flameWidth / GROUND_FIRE_VFX_FRAME_ASPECT;
const stamps = fieldStampPoints(inst);
const children = node.children ?? []; const children = node.children ?? [];
let pi = 0; let pi = 0;
for (const child of children) { for (const child of children) {
const p = inst.points[pi]; const p = stamps[pi];
pi += 1; pi += 1;
if (!p) continue; if (!p) continue;
const fx = (child as any).__fx ?? {}; const fx = (child as any).__fx ?? {};
@@ -1915,10 +1952,11 @@ function relayoutInstanceNode(
const r = inst.radiusN * minDim; const r = inst.radiusN * minDim;
const rainWidth = Math.max(r * 3.8, minDim * 0.08); const rainWidth = Math.max(r * 3.8, minDim * 0.08);
const rainHeight = rainWidth / RAIN_VFX_FRAME_ASPECT; const rainHeight = rainWidth / RAIN_VFX_FRAME_ASPECT;
const stamps = fieldStampPoints(inst);
const children = node.children ?? []; const children = node.children ?? [];
let pi = 0; let pi = 0;
for (const child of children) { for (const child of children) {
const p = inst.points[pi]; const p = stamps[pi];
pi += 1; pi += 1;
if (!p) continue; if (!p) continue;
const fx = (child as any).__fx ?? {}; const fx = (child as any).__fx ?? {};
@@ -114,7 +114,7 @@ export function SceneDarknessOverlay({
const h = Math.max(1, Math.round(viewport.h)); const h = Math.max(1, Math.round(viewport.h));
if (canvas.width !== w) canvas.width = w; if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h; if (canvas.height !== h) canvas.height = h;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
if (!ctx) return; if (!ctx) return;
ctx.globalCompositeOperation = 'source-over'; ctx.globalCompositeOperation = 'source-over';
@@ -98,7 +98,7 @@ export function SceneMarkerOverlay({ state, viewport, style }: SceneMarkerOverla
const h = Math.max(1, Math.round(viewport.h)); const h = Math.max(1, Math.round(viewport.h));
if (canvas.width !== w) canvas.width = w; if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h; if (canvas.height !== h) canvas.height = h;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
if (!ctx) return; if (!ctx) return;
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
@@ -70,7 +70,7 @@ export function SceneGridOverlay({ grid, viewport }: Props) {
canvas.style.width = `${w}px`; canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`; canvas.style.height = `${h}px`;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
if (!ctx) return; if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
@@ -35,6 +35,23 @@
z-index: 10; 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). */ /* Кольцо строго по центру круга токена (обёртка = sizePx × sizePx). */
.initiativeActive::before { .initiativeActive::before {
content: ''; content: '';
@@ -51,6 +51,8 @@ function NpcSprite({
gridFit, gridFit,
disposition, disposition,
inactive, inactive,
hiddenFromPlayers,
concealed,
onMove, onMove,
onResize, onResize,
resizeTitle, resizeTitle,
@@ -71,6 +73,8 @@ function NpcSprite({
gridFit: number; gridFit: number;
disposition: NpcDisposition; disposition: NpcDisposition;
inactive: boolean; inactive: boolean;
hiddenFromPlayers: boolean;
concealed: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void; onMove?: (placementId: string, nx: number, ny: number) => void;
onResize?: (placementId: string, sizeN: number) => void; onResize?: (placementId: string, sizeN: number) => void;
resizeTitle?: string; resizeTitle?: string;
@@ -153,6 +157,8 @@ function NpcSprite({
styles.token, styles.token,
canDrag ? styles.editable : '', canDrag ? styles.editable : '',
initiativeActive ? styles.initiativeActive : '', initiativeActive ? styles.initiativeActive : '',
hiddenFromPlayers && !concealed ? styles.hiddenFromPlayers : '',
concealed ? styles.concealedFromPlayers : '',
] ]
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
@@ -274,6 +280,7 @@ export function SceneNpcTokensOverlay({
snapNorm, snapNorm,
poseOverrides = null, poseOverrides = null,
dragEnabledById = null, dragEnabledById = null,
concealFromPlayers = false,
}: { }: {
placements: readonly SceneNpcToken[]; placements: readonly SceneNpcToken[];
library: readonly ProjectNpc[]; library: readonly ProjectNpc[];
@@ -289,6 +296,8 @@ export function SceneNpcTokensOverlay({
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number }; snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
poseOverrides?: Record<string, { nx: number; ny: number }> | null; poseOverrides?: Record<string, { nx: number; ny: number }> | null;
dragEnabledById?: Record<string, boolean> | null; dragEnabledById?: Record<string, boolean> | null;
/** На презентации скрытый от игроков токен остаётся в DOM, чтобы путь продолжал идти. */
concealFromPlayers?: boolean;
}) { }) {
const [initiative] = useInitiativeState(); const [initiative] = useInitiativeState();
if (!viewport) return null; if (!viewport) return null;
@@ -305,6 +314,7 @@ export function SceneNpcTokensOverlay({
const override = session?.byPlacementId[key]; const override = session?.byPlacementId[key];
const disposition = resolveNpcTokenDisposition(placement, npc, override?.disposition); const disposition = resolveNpcTokenDisposition(placement, npc, override?.disposition);
const inactive = Boolean(override?.inactive); const inactive = Boolean(override?.inactive);
const hiddenFromPlayers = Boolean(override?.hiddenFromPlayers);
const dragEnabled = dragEnabledById ? Boolean(dragEnabledById[key]) : true; const dragEnabled = dragEnabledById ? Boolean(dragEnabledById[key]) : true;
return ( return (
<NpcSprite <NpcSprite
@@ -321,6 +331,8 @@ export function SceneNpcTokensOverlay({
gridFit={gridFit} gridFit={gridFit}
disposition={disposition} disposition={disposition}
inactive={inactive} inactive={inactive}
hiddenFromPlayers={hiddenFromPlayers}
concealed={concealFromPlayers && hiddenFromPlayers}
initiativeActive={initiativeHighlightForNpc(initiative, key)} initiativeActive={initiativeHighlightForNpc(initiative, key)}
initiativeConditions={initiativeConditionsForNpc(initiative, key)} initiativeConditions={initiativeConditionsForNpc(initiative, key)}
{...(onMove ? { onMove } : {})} {...(onMove ? { onMove } : {})}
@@ -34,7 +34,8 @@ function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
p.ny === undefined && p.ny === undefined &&
p.sizeN === undefined && p.sizeN === undefined &&
!p.disposition && !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?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
...(prev?.disposition ? { disposition: prev.disposition } : {}), ...(prev?.disposition ? { disposition: prev.disposition } : {}),
...(prev?.inactive ? { inactive: true } : {}), ...(prev?.inactive ? { inactive: true } : {}),
...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
}; };
} }
@@ -117,6 +119,7 @@ function applyEvent(
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}), ...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
disposition, disposition,
...(cur?.inactive ? { inactive: true } : {}), ...(cur?.inactive ? { inactive: true } : {}),
...(cur?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
}, },
}, },
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE, scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
@@ -135,6 +138,7 @@ function applyEvent(
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}), ...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
...(cur?.disposition ? { disposition: cur.disposition } : {}), ...(cur?.disposition ? { disposition: cur.disposition } : {}),
...(inactive ? { inactive: true } : {}), ...(inactive ? { inactive: true } : {}),
...(cur?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
}; };
if (isEmptyPlacement(next)) { if (isEmptyPlacement(next)) {
const { [placementId]: _removed, ...rest } = prev.byPlacementId; const { [placementId]: _removed, ...rest } = prev.byPlacementId;
@@ -155,6 +159,36 @@ function applyEvent(
spawned, 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') { if (event.kind === 'setSize') {
const placementId = String(event.placementId ?? ''); const placementId = String(event.placementId ?? '');
if (!placementId) return prev; if (!placementId) return prev;
@@ -187,6 +221,7 @@ function applyEvent(
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}), ...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
...(cur?.disposition ? { disposition: cur.disposition } : {}), ...(cur?.disposition ? { disposition: cur.disposition } : {}),
...(cur?.inactive ? { inactive: true } : {}), ...(cur?.inactive ? { inactive: true } : {}),
...(cur?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
}, },
}, },
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE, scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
@@ -81,29 +81,27 @@ export function useScenePlayerTokensSession(): [
visible: false, visible: false,
byPlayerId: {}, byPlayerId: {},
}); });
const localRevRef = useRef(0); /** Только revision с main. Оптимистичные move/setSize не должны глушить смену сцены. */
const serverRevRef = useRef(0);
useEffect(() => { 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) => { void api.invoke(ipcChannels.scenePlayerTokensSession.getState, {}).then((res) => {
if (res.state.revision < localRevRef.current) return; applyServer(res.state);
localRevRef.current = res.state.revision;
setState(res.state);
}); });
return api.on(ipcChannels.scenePlayerTokensSession.stateChanged, ({ state: next }) => { return api.on(ipcChannels.scenePlayerTokensSession.stateChanged, ({ state: next }) => {
if (next.revision < localRevRef.current) return; applyServer(next);
localRevRef.current = next.revision;
setState(next);
}); });
}, [api]); }, [api]);
const dispatch = useCallback( const dispatch = useCallback(
(event: ScenePlayerTokensSessionEvent) => { (event: ScenePlayerTokensSessionEvent) => {
if (event.kind !== 'seedBottom' && event.kind !== 'show') { if (event.kind !== 'seedBottom' && event.kind !== 'show') {
setState((prev) => { setState((prev) => applyEvent(prev, event));
const next = applyEvent(prev, event);
localRevRef.current = Math.max(localRevRef.current, next.revision);
return next;
});
} }
void api.invoke(ipcChannels.scenePlayerTokensSession.dispatch, { event }); void api.invoke(ipcChannels.scenePlayerTokensSession.dispatch, { event });
}, },
@@ -10,6 +10,7 @@
.sidebar, .sidebar,
.main { .main {
min-height: 0; min-height: 0;
min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
@@ -74,6 +75,8 @@
color: var(--text); color: var(--text);
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
min-width: 0;
overflow: hidden;
} }
.playlistRow:hover, .playlistRow:hover,
@@ -103,10 +106,10 @@
.playlistName, .playlistName,
.trackTitle, .trackTitle,
.playlistNameReadonly,
.trackTitleReadonly { .trackTitleReadonly {
flex: 1; flex: 1 1 0;
min-width: 0; min-width: 0;
width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -114,6 +117,7 @@
} }
.trackTitle { .trackTitle {
display: block;
background: transparent; background: transparent;
border: 0; border: 0;
color: inherit; color: inherit;
@@ -122,18 +126,24 @@
padding: 0; padding: 0;
} }
.playlistNameReadonly { .trackTitleReadonly {
font-weight: 700; display: block;
font-size: var(--text-md, 1rem); color: inherit;
} }
.trackTitleReadonly { .playlistNameReadonly {
color: inherit; min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 700;
font-size: var(--text-md, 1rem);
} }
.playlistCount { .playlistCount {
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--text2); color: var(--text2);
flex-shrink: 0;
} }
.mainHeader { .mainHeader {
@@ -153,6 +163,7 @@
color: var(--text2); color: var(--text2);
cursor: pointer; cursor: pointer;
padding: 0 4px; padding: 0 4px;
flex-shrink: 0;
} }
.empty { .empty {
@@ -309,7 +309,7 @@ export function PlaylistsPanel({
} }
onClick={() => setSelectedId(pl.id)} 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} {playerUi[pl.id]?.playing ? <span className={styles.playlistPlayingMark}>▶</span> : null}
<span className={styles.playlistCount}>{pl.tracks.length}</span> <span className={styles.playlistCount}>{pl.tracks.length}</span>
</button> </button>
@@ -337,7 +337,9 @@ export function PlaylistsPanel({
<> <>
<div className={styles.mainHeader}> <div className={styles.mainHeader}>
{readOnly ? ( {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()} /> <Input value={editingName} onChange={setEditingName} onBlur={() => void saveRename()} />
)} )}
@@ -436,7 +438,9 @@ export function PlaylistsPanel({
▶ ▶
</button> </button>
{readOnly ? ( {readOnly ? (
<span className={styles.trackTitleReadonly}>{title}</span> <span className={styles.trackTitleReadonly} title={title}>
{title}
</span>
) : editingAudioId === tr.audioId ? ( ) : editingAudioId === tr.audioId ? (
<Input <Input
value={editingAudioTitle} value={editingAudioTitle}
@@ -453,11 +457,11 @@ export function PlaylistsPanel({
<button <button
type="button" type="button"
className={styles.trackTitle} className={styles.trackTitle}
title={title}
onClick={() => { onClick={() => {
setEditingAudioId(tr.audioId); setEditingAudioId(tr.audioId);
setEditingAudioTitle(title); setEditingAudioTitle(title);
}} }}
title={labels.renameAudio}
> >
{title} {title}
</button> </button>
@@ -18,6 +18,7 @@ const SECONDARY_WINDOW_MAINS = [
'dice/main.tsx', 'dice/main.tsx',
'sceneEditor/main.tsx', 'sceneEditor/main.tsx',
'sceneDescription/main.tsx', 'sceneDescription/main.tsx',
'instructions/main.tsx',
'control/main.tsx', 'control/main.tsx',
'presentation/main.tsx', 'presentation/main.tsx',
] as const; ] as const;
@@ -119,6 +120,24 @@ void test('Control and Presentation use SceneNpcTokensOverlay', () => {
assert.ok(control.includes('ScenePlayerTokensOverlay')); assert.ok(control.includes('ScenePlayerTokensOverlay'));
assert.ok(presentation.includes('ScenePlayerTokensOverlay')); assert.ok(presentation.includes('ScenePlayerTokensOverlay'));
assert.ok(control.includes('toggle-session-players')); 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', () => { 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')); 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', () => { void test('ControlApp: Timer tool opens window', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'control/ControlApp.tsx'), 'utf8'); const src = fs.readFileSync(path.join(rendererRoot, 'control/ControlApp.tsx'), 'utf8');
assert.ok(src.includes('data-testid="control-timer-tool"')); assert.ok(src.includes('data-testid="control-timer-tool"'));
assert.ok(src.includes('windows.openTimer')); assert.ok(src.includes('windows.openTimer'));
assert.ok(src.includes('SessionTimerLayer')); assert.ok(src.includes('SessionTimerLayer'));
assert.match(src, /<SessionTimerLayer viewport=\{markerViewport\} editable \/>/);
}); });
void test('ControlApp: Initiative tool opens window', () => { void test('ControlApp: Initiative tool opens window', () => {
+4 -2
View File
@@ -29,10 +29,11 @@ export type AppWindowKind =
| 'npcsEditor' | 'npcsEditor'
| 'sceneEditor' | 'sceneEditor'
| 'tokenPathEditor' | 'tokenPathEditor'
| 'npcs'; | 'npcs'
| 'instructions';
const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = { const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
editor: { ru: 'Редактор компании', en: 'Campaign editor' }, editor: { ru: 'Редактор кампании', en: 'Campaign editor' },
presentation: { ru: 'Презентация', en: 'Presentation' }, presentation: { ru: 'Презентация', en: 'Presentation' },
control: { ru: 'Пульт управления', en: 'Control deck' }, control: { ru: 'Пульт управления', en: 'Control deck' },
boot: { ru: 'Загрузка', en: 'Loading' }, boot: { ru: 'Загрузка', en: 'Loading' },
@@ -46,6 +47,7 @@ const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' }, sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' },
tokenPathEditor: { ru: 'Движение токена', en: 'Token path' }, tokenPathEditor: { ru: 'Движение токена', en: 'Token path' },
npcs: { ru: 'НПС', en: 'NPCs' }, npcs: { ru: 'НПС', en: 'NPCs' },
instructions: { ru: 'Инструкция', en: 'Instructions' },
}; };
export function windowChromeTitle(kind: AppWindowKind, localeTag: string): string { export function windowChromeTitle(kind: AppWindowKind, localeTag: string): string {
+171
View File
@@ -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: [] }],
},
};
}
+143
View File
@@ -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);
}
+66
View File
@@ -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;
}
+187
View File
@@ -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;
}
+6
View File
@@ -0,0 +1,6 @@
export * from './types';
export * from './formula';
export * from './derived';
export * from './creation';
export * from './snapshot';
export * from './systemValidate';
+34
View File
@@ -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 };
}
+223
View File
@@ -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[];
};
+46
View File
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
FIELD_STAMP_MAX_SPRITES,
decimateFieldStampPoints,
fieldStampMinSpacingN,
} from './fieldEffectStampPoints';
import type { NPoint } from './types/effects';
function pt(x: number, y: number, tMs = 0): NPoint {
return { x, y, tMs };
}
void test('decimateFieldStampPoints: пустой и одна точка', () => {
assert.deepEqual(decimateFieldStampPoints([], 0.05), []);
const one = [pt(0.2, 0.3)];
assert.deepEqual(decimateFieldStampPoints(one, 0.05), one);
});
void test('decimateFieldStampPoints: оставляет концы и выкидывает близкие середины', () => {
const points = [pt(0, 0.5), pt(0.01, 0.5), pt(0.02, 0.5), pt(0.5, 0.5), pt(0.51, 0.5), pt(1, 0.5)];
const out = decimateFieldStampPoints(points, 0.1);
assert.equal(out[0]?.x, 0);
assert.equal(out[out.length - 1]?.x, 1);
assert.ok(out.length < points.length);
assert.ok(out.length >= 3);
});
void test('decimateFieldStampPoints: maxPoints режет длинный штрих', () => {
const points: NPoint[] = [];
for (let i = 0; i <= 200; i += 1) {
points.push(pt(i / 200, 0.4, i));
}
const out = decimateFieldStampPoints(points, 0.001, 16);
assert.ok(out.length <= 16);
assert.equal(out[0]?.x, 0);
assert.equal(out[out.length - 1]?.x, 1);
});
void test('fieldStampMinSpacingN: огонь реже тумана', () => {
const r = 0.04;
assert.ok(fieldStampMinSpacingN('fire', r) > fieldStampMinSpacingN('fog', r));
assert.ok(fieldStampMinSpacingN('fog', r) >= 0.006);
assert.equal(FIELD_STAMP_MAX_SPRITES, 40);
});
+68
View File
@@ -0,0 +1,68 @@
import type { NPoint } from './types/effects';
/** Верхняя граница спрайтов на один штрих тумана/огня/дождя. */
export const FIELD_STAMP_MAX_SPRITES = 40;
/**
* Минимальное расстояние между центрами спрайтов (норм. 0..1).
* Огонь крупнее пятна радиуса — реже ставим штампы.
*/
export function fieldStampMinSpacingN(type: 'fog' | 'fire' | 'rain', radiusN: number): number {
const factor = type === 'fire' ? 0.75 : type === 'rain' ? 0.55 : 0.5;
return Math.max(0.006, radiusN * factor);
}
/**
* Оставляет концы штриха и точки не ближе `minSpacingN`.
* Если точек всё ещё больше `maxPoints` — равномерный stride.
*/
export function decimateFieldStampPoints(
points: readonly NPoint[],
minSpacingN: number,
maxPoints: number = FIELD_STAMP_MAX_SPRITES,
): NPoint[] {
if (points.length <= 1) return points.slice();
const first = points[0];
const last = points[points.length - 1];
if (!first || !last) {
return points.filter((p): p is NPoint => Boolean(p));
}
const cap = Math.max(2, Math.floor(maxPoints));
const minDistSq = Math.max(0, minSpacingN) * Math.max(0, minSpacingN);
const kept: NPoint[] = [first];
for (let i = 1; i < points.length - 1; i += 1) {
const p = points[i];
const prev = kept[kept.length - 1];
if (!p || !prev) continue;
const dx = p.x - prev.x;
const dy = p.y - prev.y;
if (dx * dx + dy * dy >= minDistSq) kept.push(p);
}
const tail = kept[kept.length - 1];
if (!tail) {
kept.push(last);
} else {
const dx = last.x - tail.x;
const dy = last.y - tail.y;
if (dx * dx + dy * dy > 1e-12) kept.push(last);
}
if (kept.length <= cap) return kept;
const reduced: NPoint[] = [];
const lastIdx = kept.length - 1;
const stride = Math.max(1, Math.ceil(lastIdx / (cap - 1)));
for (let i = 0; i < kept.length; i += stride) {
const p = kept[i];
if (p) reduced.push(p);
}
const reducedTail = reduced[reduced.length - 1];
if (reducedTail !== last) reduced.push(last);
if (reduced.length > cap) {
const head = reduced[0];
if (!head) return kept.slice(0, cap);
return [head, ...reduced.slice(reduced.length - (cap - 1))];
}
return reduced;
}
+104
View File
@@ -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,
};
}
+1
View File
@@ -22,6 +22,7 @@ function scene(id: string): Scene {
previewVideoAutostart: false, previewVideoAutostart: false,
previewRotationDeg: 0, previewRotationDeg: 0,
darkenScene: false, darkenScene: false,
darknessStrokes: [],
traps: [], traps: [],
tokens: [], tokens: [],
npcTokens: [], npcTokens: [],
@@ -43,6 +43,7 @@ function scene(id: string, title: string): Scene {
previewVideoAutostart: false, previewVideoAutostart: false,
previewRotationDeg: 0, previewRotationDeg: 0,
darkenScene: false, darkenScene: false,
darknessStrokes: [],
traps: [], traps: [],
tokens: [], tokens: [],
npcTokens: [], npcTokens: [],
+2
View File
@@ -15,6 +15,8 @@ void test('contracts: players and sceneNpcTokensSession channels exist', () => {
assert.match(src, /sceneNpcTokensSession:\s*\{/); assert.match(src, /sceneNpcTokensSession:\s*\{/);
assert.match(src, /npcMapSpawn:\s*\{/); assert.match(src, /npcMapSpawn:\s*\{/);
assert.match(src, /scenePlayerTokensSession:\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:\s*\{/);
assert.match(src, /tokenGridSnap\.setEnabled/); assert.match(src, /tokenGridSnap\.setEnabled/);
assert.match(src, /npcTokens\?:\s*SceneNpcToken\[\]/); assert.match(src, /npcTokens\?:\s*SceneNpcToken\[\]/);
+87 -2
View File
@@ -34,6 +34,7 @@ import type {
DisplayInfo, DisplayInfo,
Scene, Scene,
SceneDarknessEvent, SceneDarknessEvent,
SceneDarknessRevealStroke,
SceneDarknessState, SceneDarknessState,
SceneMarkerEvent, SceneMarkerEvent,
SceneMarkerState, SceneMarkerState,
@@ -76,11 +77,22 @@ import type {
StorylineListItem, StorylineListItem,
StorylineSelection, StorylineSelection,
} from '../graph/storylineExportImport'; } 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 = { export const ipcChannels = {
app: { app: {
quit: 'app.quit', quit: 'app.quit',
getVersion: 'app.getVersion', getVersion: 'app.getVersion',
openExternal: 'app.openExternal',
}, },
updater: { updater: {
check: 'updater.check', check: 'updater.check',
@@ -155,9 +167,16 @@ export const ipcChannels = {
materialUpsertProgress: 'project.materialUpsertProgress', materialUpsertProgress: 'project.materialUpsertProgress',
npcUpsertProgress: 'project.npcUpsertProgress', npcUpsertProgress: 'project.npcUpsertProgress',
}, },
gameSave: {
list: 'gameSave.list',
write: 'gameSave.write',
isDirty: 'gameSave.isDirty',
},
windows: { windows: {
openMultiWindow: 'windows.openMultiWindow', openMultiWindow: 'windows.openMultiWindow',
closeMultiWindow: 'windows.closeMultiWindow', closeMultiWindow: 'windows.closeMultiWindow',
requestCloseMultiWindowConfirm: 'windows.requestCloseMultiWindowConfirm',
closeMultiWindowConfirmRequested: 'windows.closeMultiWindowConfirmRequested',
togglePresentationFullscreen: 'windows.togglePresentationFullscreen', togglePresentationFullscreen: 'windows.togglePresentationFullscreen',
getMultiWindowState: 'windows.getMultiWindowState', getMultiWindowState: 'windows.getMultiWindowState',
multiWindowStateChanged: 'windows.multiWindowStateChanged', multiWindowStateChanged: 'windows.multiWindowStateChanged',
@@ -165,6 +184,9 @@ export const ipcChannels = {
closeSceneDescription: 'windows.closeSceneDescription', closeSceneDescription: 'windows.closeSceneDescription',
getSceneDescriptionContent: 'windows.getSceneDescriptionContent', getSceneDescriptionContent: 'windows.getSceneDescriptionContent',
sceneDescriptionContent: 'windows.sceneDescriptionContent', sceneDescriptionContent: 'windows.sceneDescriptionContent',
openInstructions: 'windows.openInstructions',
getInstructionsSection: 'windows.getInstructionsSection',
instructionsSection: 'windows.instructionsSection',
openMaterials: 'windows.openMaterials', openMaterials: 'windows.openMaterials',
closeMaterials: 'windows.closeMaterials', closeMaterials: 'windows.closeMaterials',
openPlaylists: 'windows.openPlaylists', openPlaylists: 'windows.openPlaylists',
@@ -284,6 +306,14 @@ export const ipcChannels = {
upsertProgress: 'players.upsertProgress', upsertProgress: 'players.upsertProgress',
stateChanged: 'players.stateChanged', stateChanged: 'players.stateChanged',
}, },
gameSystems: {
list: 'gameSystems.list',
importFromFile: 'gameSystems.importFromFile',
delete: 'gameSystems.delete',
get: 'gameSystems.get',
bindSnapshot: 'gameSystems.bindSnapshot',
getSnapshot: 'gameSystems.getSnapshot',
},
playlists: { playlists: {
getState: 'playlists.getState', getState: 'playlists.getState',
create: 'playlists.create', create: 'playlists.create',
@@ -408,8 +438,10 @@ export type IpcEventMap = {
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState }; [ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot }; [ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean }; [ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
[ipcChannels.windows.closeMultiWindowConfirmRequested]: Record<string, never>;
[ipcChannels.windows.presentationContentSizeChanged]: { width: number; height: number }; [ipcChannels.windows.presentationContentSizeChanged]: { width: number; height: number };
[ipcChannels.windows.sceneDescriptionContent]: { html: string }; [ipcChannels.windows.sceneDescriptionContent]: { html: string };
[ipcChannels.windows.instructionsSection]: { section: string };
[ipcChannels.project.importZipProgress]: ZipProgressEvent; [ipcChannels.project.importZipProgress]: ZipProgressEvent;
[ipcChannels.project.exportZipProgress]: ZipProgressEvent; [ipcChannels.project.exportZipProgress]: ZipProgressEvent;
[ipcChannels.project.scenePreviewImportProgress]: ScenePreviewImportEvent; [ipcChannels.project.scenePreviewImportProgress]: ScenePreviewImportEvent;
@@ -427,6 +459,10 @@ export type IpcInvokeMap = {
req: Record<string, never>; req: Record<string, never>;
res: { version: string; buildNumber: string | null; packaged: boolean }; res: { version: string; buildNumber: string | null; packaged: boolean };
}; };
[ipcChannels.app.openExternal]: {
req: { url: string };
res: { ok: true } | { ok: false; error: string };
};
[ipcChannels.updater.check]: { [ipcChannels.updater.check]: {
req: Record<string, never>; req: Record<string, never>;
res: UpdaterCheckResponse; res: UpdaterCheckResponse;
@@ -757,13 +793,29 @@ export type IpcInvokeMap = {
res: { ok: true }; res: { ok: true };
}; };
[ipcChannels.windows.openMultiWindow]: { [ipcChannels.windows.openMultiWindow]: {
req: { playerIds?: string[] }; req: { playerIds?: string[]; saveId?: string };
res: { ok: true }; 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]: { [ipcChannels.windows.closeMultiWindow]: {
req: Record<string, never>; req: Record<string, never>;
res: { ok: true }; res: { ok: true };
}; };
[ipcChannels.windows.requestCloseMultiWindowConfirm]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.windows.togglePresentationFullscreen]: { [ipcChannels.windows.togglePresentationFullscreen]: {
req: Record<string, never>; req: Record<string, never>;
res: { ok: true; isFullScreen: boolean }; res: { ok: true; isFullScreen: boolean };
@@ -792,6 +844,14 @@ export type IpcInvokeMap = {
req: { html: string }; req: { html: string };
res: { ok: true }; 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]: { [ipcChannels.windows.closeSceneDescription]: {
req: Record<string, never>; req: Record<string, never>;
res: { ok: true }; res: { ok: true };
@@ -1052,6 +1112,30 @@ export type IpcInvokeMap = {
req: { id: PlayerId }; req: { id: PlayerId };
res: { url: string | null }; 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]: { [ipcChannels.playlists.getState]: {
req: Record<string, never>; req: Record<string, never>;
res: PlaylistsState; res: PlaylistsState;
@@ -1202,6 +1286,7 @@ export type ScenePatch = {
previewVideoAutostart?: boolean; previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270; previewRotationDeg?: 0 | 90 | 180 | 270;
darkenScene?: boolean; darkenScene?: boolean;
darknessStrokes?: SceneDarknessRevealStroke[];
traps?: SceneTrap[]; traps?: SceneTrap[];
tokens?: SceneToken[]; tokens?: SceneToken[];
npcTokens?: SceneNpcToken[]; npcTokens?: SceneNpcToken[];
+43 -1
View File
@@ -31,6 +31,16 @@ export type AppPlayer = {
imageOffset: PlayerImageOffset; imageOffset: PlayerImageOffset;
/** Масштаб аватара внутри круга (1 = по умолчанию). */ /** Масштаб аватара внутри круга (1 = по умолчанию). */
imageScale: number; 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; disposition?: NpcDisposition;
/** Session-only: серая рамка и grayscale. */ /** Session-only: серая рамка и grayscale. */
inactive?: boolean; inactive?: boolean;
/** Session-only: токен виден мастеру, но не на презентации. */
hiddenFromPlayers?: boolean;
}; };
/** Подкрепление: токен только на сессию, не пишется в scene.npcTokens. */ /** Подкрепление: токен только на сессию, не пишется в scene.npcTokens. */
@@ -100,6 +112,7 @@ export type SceneNpcTokensSessionEvent =
| { kind: 'setSize'; placementId: string; sizeN: number } | { kind: 'setSize'; placementId: string; sizeN: number }
| { kind: 'setDisposition'; placementId: string; disposition: NpcDisposition } | { kind: 'setDisposition'; placementId: string; disposition: NpcDisposition }
| { kind: 'setInactive'; placementId: string; inactive: boolean } | { kind: 'setInactive'; placementId: string; inactive: boolean }
| { kind: 'setHiddenFromPlayers'; placementId: string; hidden: boolean }
| { kind: 'setScale'; scale: number } | { kind: 'setScale'; scale: number }
| { | {
kind: 'spawn'; kind: 'spawn';
@@ -276,7 +289,7 @@ export function normalizeAppPlayer(raw: unknown, teamIds?: Set<string>): AppPlay
if (typeof obj.teamId === 'string' && obj.teamId) { if (typeof obj.teamId === 'string' && obj.teamId) {
if (!teamIds || teamIds.has(obj.teamId)) teamId = asPlayerTeamId(obj.teamId); if (!teamIds || teamIds.has(obj.teamId)) teamId = asPlayerTeamId(obj.teamId);
} }
return { const out: AppPlayer = {
id: asPlayerId(obj.id), id: asPlayerId(obj.id),
name, name,
imageRelPath: obj.imageRelPath, imageRelPath: obj.imageRelPath,
@@ -286,6 +299,35 @@ export function normalizeAppPlayer(raw: unknown, teamIds?: Set<string>): AppPlay
imageOffset: clampPlayerImageOffset(obj.imageOffset), imageOffset: clampPlayerImageOffset(obj.imageOffset),
imageScale: clampPlayerImageScale((obj as { imageScale?: unknown }).imageScale), 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 { export function normalizeSceneNpcToken(raw: unknown): SceneNpcToken | null {
+11 -3
View File
@@ -14,8 +14,10 @@ export type DiceRollRequest = {
export type DiceRollState = { export type DiceRollState = {
revision: number; revision: number;
/** Идёт показ броска (оверлей виден). */ /** Идёт показ броска (оверлей виден на презентации). */
active: boolean; active: boolean;
/** Показывать 3D-анимацию игрокам на экране презентации. */
visibleToPlayers: boolean;
/** Например "2d20". */ /** Например "2d20". */
notation: string; notation: string;
/** Заранее выбранные исходы — для синхрона пульт/презентация. */ /** Заранее выбранные исходы — для синхрона пульт/презентация. */
@@ -27,6 +29,7 @@ export type DiceRollState = {
export type DiceRollEvent = export type DiceRollEvent =
| { kind: 'roll'; count: number; sides: DiceSides } | { kind: 'roll'; count: number; sides: DiceSides }
| { kind: 'setVisibleToPlayers'; visible: boolean }
| { kind: 'hideOverlay' } | { kind: 'hideOverlay' }
| { kind: 'clear' }; | { kind: 'clear' };
@@ -34,6 +37,7 @@ export function createInitialDiceRollState(): DiceRollState {
return { return {
revision: 1, revision: 1,
active: false, active: false,
visibleToPlayers: true,
notation: '', notation: '',
values: [], values: [],
total: 0, 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 { export function clampDiceCount(n: number): number {
if (!Number.isFinite(n)) return 1; if (!Number.isFinite(n)) return DICE_COUNT_MIN;
return Math.max(1, Math.min(20, Math.round(n))); return Math.max(DICE_COUNT_MIN, Math.min(DICE_COUNT_MAX, Math.round(n)));
} }
/** Грани d100 в dice-box-threejs: 10,20,…,90,100 (лицо «00»). */ /** Грани d100 в dice-box-threejs: 10,20,…,90,100 (лицо «00»). */
+6
View File
@@ -13,6 +13,7 @@ import type { SceneToken } from './appTokens';
import type { SceneNpcToken, PlayerImageOffset } from './appPlayers'; import type { SceneNpcToken, PlayerImageOffset } from './appPlayers';
import type { NpcDisposition } from './npcDisposition'; import type { NpcDisposition } from './npcDisposition';
import type { SceneGrid, SceneGridTemplate } from './sceneGrid'; import type { SceneGrid, SceneGridTemplate } from './sceneGrid';
import type { SceneDarknessRevealStroke } from './sceneDarkness';
import type { SceneTrap } from './sceneTraps'; import type { SceneTrap } from './sceneTraps';
export const PROJECT_SCHEMA_VERSION = 12 as const; export const PROJECT_SCHEMA_VERSION = 12 as const;
@@ -185,6 +186,11 @@ export type Scene = {
previewRotationDeg: 0 | 90 | 180 | 270; previewRotationDeg: 0 | 90 | 180 | 270;
/** В режиме показа: сцена начинается полностью затемнённой; мастер «раскрывает» кистью. */ /** В режиме показа: сцена начинается полностью затемнённой; мастер «раскрывает» кистью. */
darkenScene: boolean; darkenScene: boolean;
/**
* Стартовые штрихи затемнения (база в файле партии).
* При входе на карточку без сессионного кэша поднимаются в показ.
*/
darknessStrokes: SceneDarknessRevealStroke[];
/** Ловушки на карте (только для image-превью); расстановка в проекте. */ /** Ловушки на карте (только для image-превью); расстановка в проекте. */
traps: SceneTrap[]; traps: SceneTrap[];
/** Неигровые токены на карте (ссылки на app-local пул). */ /** Неигровые токены на карте (ссылки на app-local пул). */
+12
View File
@@ -199,3 +199,15 @@ export type EffectsEvent =
*/ */
hitMode?: 'inclusive' | 'brush'; hitMode?: 'inclusive' | 'brush';
}; };
/** Туман, огонь, дождь, вода и следы льда/тьмы живут на карточке, пока идёт показ. */
export function isPersistentFieldEffect(type: EffectInstanceType): boolean {
return (
type === 'fog' ||
type === 'fire' ||
type === 'rain' ||
type === 'water' ||
type === 'ice' ||
type === 'shadow'
);
}
+74
View File
@@ -0,0 +1,74 @@
import type { SceneNpcSessionSpawn, SceneNpcTokensSessionPlacement, ScenePlayerTokenPlacement } from './appPlayers';
import type { SceneTokensSessionPlacement } from './appTokens';
import type { EffectInstance } from './effects';
import type { InitiativeState } from './initiative';
import type { MaterialsOverlayLayout } from './materials';
import type { NpcsOverlayLayout } from './npcs';
import type { SceneDarknessRevealStroke } from './sceneDarkness';
import type { SceneMarkerStroke } from './sceneMarker';
import type { SceneTrapRuntime } from './sceneTraps';
import type { SceneViewCamera } from './sceneView';
import type { SessionTimerLayout } from './sessionTimer';
import type { TokenPathPlaybackEntry } from './tokenPathSession';
export const GAME_SAVE_SCHEMA_VERSION = 1;
export type GameSaveListItem = {
id: string;
name: string;
savedAt: string;
};
/** Тело слепка без id/имени/времени записи: по нему сравнивают «стол изменился». */
export type GameSaveBody = {
schemaVersion: typeof GAME_SAVE_SCHEMA_VERSION;
projectId: string;
graphNodeId: string | null;
sceneId: string | null;
darknessByCard: Record<string, SceneDarknessRevealStroke[]>;
markersByCard: Record<string, SceneMarkerStroke[]>;
trapsByCard: Record<string, Record<string, SceneTrapRuntime>>;
effectsByCard: Record<string, EffectInstance[]>;
sceneTokensByPlacementId: Record<string, SceneTokensSessionPlacement>;
npcTokens: {
byPlacementId: Record<string, SceneNpcTokensSessionPlacement>;
scale: number;
spawned: Record<string, SceneNpcSessionSpawn>;
};
tokenPaths: {
presentationVisible: Record<string, true>;
playback: Record<string, TokenPathPlaybackEntry>;
};
timer: {
durationMs: number;
remainingMs: number;
visibleToPlayers: boolean;
layout: SessionTimerLayout;
};
/** null — токены игроков в момент сохранения не были показаны. */
playerTokens: {
selectedPlayerIds: string[];
visible: boolean;
byPlayerId: Record<string, ScenePlayerTokenPlacement>;
};
camera: SceneViewCamera;
materials: {
activeMaterialIds: string[];
layouts: Record<string, MaterialsOverlayLayout>;
legendLayouts: Record<string, MaterialsOverlayLayout>;
focusMaterialId: string | null;
};
npcAvatars: {
activeNpcIds: string[];
layouts: Record<string, NpcsOverlayLayout>;
focusNpcId: string | null;
};
initiative: Omit<InitiativeState, 'revision'>;
tokenGridSnap: boolean;
};
export type GameSaveFile = GameSaveBody & {
id: string;
name: string;
savedAt: string;
};
+1
View File
@@ -3,6 +3,7 @@ export * from './appPlaylists';
export * from './appTokens'; export * from './appTokens';
export * from './domain'; export * from './domain';
export * from './effects'; export * from './effects';
export * from './gameSave';
export * from './ids'; export * from './ids';
export * from './materialLegend'; export * from './materialLegend';
export * from './materials'; export * from './materials';
+43
View File
@@ -3,7 +3,9 @@ import test from 'node:test';
import { import {
normalizeSceneDarknessBrushShape, normalizeSceneDarknessBrushShape,
normalizeSceneDarknessRevealStroke,
normalizeSceneDarknessStrokeMode, normalizeSceneDarknessStrokeMode,
normalizeSceneDarknessStrokes,
} from './sceneDarkness'; } from './sceneDarkness';
void test('normalizeSceneDarknessStrokeMode: reveal по умолчанию', () => { void test('normalizeSceneDarknessStrokeMode: reveal по умолчанию', () => {
@@ -18,3 +20,44 @@ void test('normalizeSceneDarknessBrushShape: circle по умолчанию', ()
assert.equal(normalizeSceneDarknessBrushShape('square'), 'square'); assert.equal(normalizeSceneDarknessBrushShape('square'), 'square');
assert.equal(normalizeSceneDarknessBrushShape('triangle'), 'circle'); assert.equal(normalizeSceneDarknessBrushShape('triangle'), 'circle');
}); });
void test('normalizeSceneDarknessStrokes: отбрасывает мусор, нормализует точки', () => {
assert.deepEqual(normalizeSceneDarknessStrokes(null), []);
assert.deepEqual(normalizeSceneDarknessStrokes('x'), []);
const out = normalizeSceneDarknessStrokes([
{ id: 'bad' },
{
id: 'ok',
seed: 1,
createdAtMs: 10,
radiusN: 2,
mode: 'cover',
shape: 'square',
points: [
{ x: -1, y: 2, tMs: 10 },
{ x: 0.5, y: 0.5 },
],
},
]);
assert.equal(out.length, 1);
assert.equal(out[0]?.id, 'ok');
assert.equal(out[0]?.mode, 'cover');
assert.equal(out[0]?.shape, 'square');
assert.equal(out[0]?.radiusN, 1);
assert.equal(out[0]?.points[0]?.x, 0);
assert.equal(out[0]?.points[0]?.y, 1);
assert.equal(out[0]?.points[1]?.tMs, 0);
});
void test('normalizeSceneDarknessRevealStroke: null без точек', () => {
assert.equal(
normalizeSceneDarknessRevealStroke({
id: 'x',
seed: 1,
createdAtMs: 1,
radiusN: 0.1,
points: [],
}),
null,
);
});
+40
View File
@@ -44,3 +44,43 @@ export function normalizeSceneDarknessStrokeMode(raw: unknown): SceneDarknessStr
export function normalizeSceneDarknessBrushShape(raw: unknown): SceneDarknessBrushShape { export function normalizeSceneDarknessBrushShape(raw: unknown): SceneDarknessBrushShape {
return raw === 'square' ? 'square' : 'circle'; 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));
}
+57
View File
@@ -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. 1. Выберите тип кубика: d4, d6, d8, d10, d12, d20 или d100.
2. Задайте **Количество** кнопками **−** / **+** (от 1 до 20). 2. Задайте **Количество** кнопками **−** / **+** или введите число вручную (от 0 до 20).
3. Нажмите **Бросить**. 3. При необходимости включите **Показать анимацию игрокам** (по умолчанию включено).
4. На экране игроков (презентация) проиграет 3D-анимация; в окне «Кубики» сразу появятся грани и **Сумма**. 4. Нажмите **Бросить**.
5. Чтобы убрать кубики с экрана и очистить результат — **Убрать**. 5. Если галочка включена — на экране игроков (презентация) проиграет 3D-анимация; в окне «Кубики» сразу появятся грани и **Сумма**. Если выключена — анимация не показывается, результат виден только мастеру в окне «Кубики».
6. **Esc** закрывает окно (результат на экране при этом не снимается, пока не нажали **Убрать**). 6. Чтобы убрать кубики с экрана и очистить результат — **Убрать**.
7. **Esc** (или крестик) закрывает окно. Если была включена галочка показа игрокам и на экране шла анимация — слой кубиков на презентации тоже скрывается (числа в сессии очищаются кнопкой **Убрать**).
**Важно:** **Важно:**
- Анимация показывается на **презентации**; мастер смотрит числа в окне «Кубики». - Анимация показывается на **презентации** только при включённой галочке; мастер всегда смотрит числа в окне «Кубики».
- d100 в приложении — кубик **десятков** (грани 10, 20, …, 90, 00/100). Результат совпадает с гранью, а не с диапазоном 20–29. - d100 в приложении — кубик **десятков** (грани 10, 20, …, 90, 00/100). Результат совпадает с гранью, а не с диапазоном 20–29.
- Оверлей не скрывается сам — только кнопкой **Убрать**. - Оверлей не скрывается сам после броска — только кнопкой **Убрать**, снятием галочки или закрытием окна «Кубики» (при включённом показе игрокам).
- Новый бросок можно сделать, не нажимая **Убрать**. - Новый бросок можно сделать, не нажимая **Убрать**.
--- ---
@@ -54,7 +55,7 @@
3. Включите **Показать игрокам** — виджет появится на предпросмотре и на презентации. 3. Включите **Показать игрокам** — виджет появится на предпросмотре и на презентации.
4. На предпросмотре пульта: перетащите таймер; углы — размер; ручка сверху — поворот. На презентации только просмотр. 4. На предпросмотре пульта: перетащите таймер; углы — размер; ручка сверху — поворот. На презентации только просмотр.
**Важно:** пока идёт отсчёт, длительность менять нельзя. При нуле таймер останавливается. Без **Показать игрокам** игроки его не видят. **Важно:** пока идёт отсчёт, длительность менять нельзя. При нуле таймер останавливается. Без **Показать игрокам** игроки его не видят. Закрытие окна «Таймер» (Esc / крестик) скрывает виджет с презентации и предпросмотра и ставит отсчёт на паузу, если он шёл.
--- ---
+35
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More