10 Commits

Author SHA1 Message Date
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
Ivan Fontosh 34dd39791b docs: обложка и текст ВК про успешный краудфандинг
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 16:16:37 +08:00
Ivan Fontosh fdccc0deb5 fix(tokens): импорт неигровых токенов из zip и плавные пути на macOS
Полный импорт/открытие проекта подхватывает app-tokens. Движение по путям пишется в DOM без ре-рендера кадра, у пульта и презентации выключен background throttling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 16:16:30 +08:00
83 changed files with 3345 additions and 673 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;
} }
@@ -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');
}
+125 -4
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 {
@@ -21,6 +22,7 @@ import {
import type { Project } from '../shared/types'; import type { Project } from '../shared/types';
import { asNpcId } from '../shared/types/ids'; import { asNpcId } from '../shared/types/ids';
import { listVisibleSceneNpcTokens } from '../shared/types/appPlayers'; import { listVisibleSceneNpcTokens } from '../shared/types/appPlayers';
import { remapProjectSceneTokenIds } from '../shared/graph/storylineExportImport';
import { import {
asPreviewRotationDeg, asPreviewRotationDeg,
previewRotationStepsCw, previewRotationStepsCw,
@@ -42,6 +44,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';
@@ -80,6 +83,8 @@ import {
openTokenPathEditorWindow, openTokenPathEditorWindow,
openNpcsWindow, openNpcsWindow,
openSceneDescriptionWindow, openSceneDescriptionWindow,
openInstructionsWindow,
getInstructionsSection,
closeMaterialsWindow, closeMaterialsWindow,
closePlaylistsWindow, closePlaylistsWindow,
closeTimerWindow, closeTimerWindow,
@@ -89,7 +94,10 @@ import {
closeSceneEditorWindow, closeSceneEditorWindow,
closeTokenPathEditorWindow, closeTokenPathEditorWindow,
closeNpcsWindow, closeNpcsWindow,
requestCloseMultiWindowConfirm,
sendToAppWindows, sendToAppWindows,
setDiceWindowClosedListener,
setTimerWindowClosedListener,
syncAllWindowChromeTitles, syncAllWindowChromeTitles,
togglePresentationFullscreen, togglePresentationFullscreen,
waitForEditorWindowReady, waitForEditorWindowReady,
@@ -198,6 +206,7 @@ const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore();
const tokenGridSnapSessionStore = new TokenGridSnapSessionStore(); const tokenGridSnapSessionStore = new TokenGridSnapSessionStore();
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 {
@@ -292,6 +301,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()) {
@@ -316,6 +341,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;
@@ -390,6 +424,18 @@ function emitTokensState(): void {
} }
} }
/** Подтянуть `app-tokens/` из открытого кэша (экспорт сюжетной линии / полный zip). */
async function importAppTokensFromOpenProjectCache(): Promise<Project | null> {
const cacheDir = projectStore.getOpenCacheDir();
if (!tokensStore || !cacheDir) return null;
const remap = await tokensStore.importFromExportDir(cacheDir);
if (remap.size === 0) return null;
emitTokensState();
const needsRemap = [...remap.entries()].some(([from, to]) => from !== to);
if (!needsRemap) return null;
return projectStore.updateProject((p) => remapProjectSceneTokenIds(p, remap));
}
function emitSceneTokensSessionState(): void { function emitSceneTokensSessionState(): void {
const state = sceneTokensSessionStore.getState(); const state = sceneTokensSessionStore.getState();
for (const win of BrowserWindow.getAllWindows()) { for (const win of BrowserWindow.getAllWindows()) {
@@ -620,6 +666,8 @@ 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'));
await gameSystemsStore.ensureLoaded();
playlistsStore = new PlaylistsStore(app.getPath('userData')); playlistsStore = new PlaylistsStore(app.getPath('userData'));
await playlistsStore.ensureLoaded(); await playlistsStore.ensureLoaded();
setLicenseAssert(() => { setLicenseAssert(() => {
@@ -637,6 +685,18 @@ 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());
@@ -697,6 +757,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 };
@@ -726,6 +790,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 };
@@ -882,7 +953,9 @@ async function main() {
return { project }; return { project };
}); });
registerHandler(ipcChannels.project.open, async ({ projectId }) => { registerHandler(ipcChannels.project.open, async ({ projectId }) => {
const project = await projectStore.openProjectById(projectId); let project = await projectStore.openProjectById(projectId);
const remapped = await importAppTokensFromOpenProjectCache();
if (remapped) project = remapped;
sceneViewStore.reset(); sceneViewStore.reset();
sceneTokensSessionStore.reset(); sceneTokensSessionStore.reset();
sceneNpcTokensSessionStore.reset(); sceneNpcTokensSessionStore.reset();
@@ -1445,7 +1518,7 @@ async function main() {
} }
const srcPath = filePaths[0]; const srcPath = filePaths[0];
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' }); emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
const project = await projectStore.importProjectFromExternalZip(srcPath, (p) => { let project = await projectStore.importProjectFromExternalZip(srcPath, (p) => {
emitZipProgress({ emitZipProgress({
kind: 'import', kind: 'import',
stage: p.stage, stage: p.stage,
@@ -1454,6 +1527,8 @@ async function main() {
}); });
}); });
const cacheDir = projectStore.getOpenCacheDir(); const cacheDir = projectStore.getOpenCacheDir();
const remapped = await importAppTokensFromOpenProjectCache();
if (remapped) project = remapped;
if (playlistsStore && cacheDir) { if (playlistsStore && cacheDir) {
await playlistsStore.importFromExportDir(cacheDir); await playlistsStore.importFromExportDir(cacheDir);
emitPlaylistsState(); emitPlaylistsState();
@@ -1564,7 +1639,7 @@ async function main() {
); );
registerHandler(ipcChannels.project.importZipFromPath, async ({ filePath }) => { registerHandler(ipcChannels.project.importZipFromPath, async ({ filePath }) => {
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' }); emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
const project = await projectStore.importProjectFromExternalZip(filePath, (p) => { let project = await projectStore.importProjectFromExternalZip(filePath, (p) => {
emitZipProgress({ emitZipProgress({
kind: 'import', kind: 'import',
stage: p.stage, stage: p.stage,
@@ -1573,6 +1648,8 @@ async function main() {
}); });
}); });
const cacheDir = projectStore.getOpenCacheDir(); const cacheDir = projectStore.getOpenCacheDir();
const remapped = await importAppTokensFromOpenProjectCache();
if (remapped) project = remapped;
if (playlistsStore && cacheDir) { if (playlistsStore && cacheDir) {
await playlistsStore.importFromExportDir(cacheDir); await playlistsStore.importFromExportDir(cacheDir);
emitPlaylistsState(); emitPlaylistsState();
@@ -1926,6 +2003,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();
+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;
+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 });
+30 -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;
+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 });
}
@@ -122,6 +122,33 @@ void test('main index: setCurrentGraphNode uses setSessionPointer not updateProj
assert.doesNotMatch(m[0], /updateProject/); assert.doesNotMatch(m[0], /updateProject/);
}); });
void test('main index: full zip import loads app-tokens like playlists', () => {
const src = fs.readFileSync(path.join(here, '..', 'index.ts'), 'utf8');
assert.match(src, /async function importAppTokensFromOpenProjectCache/);
assert.match(src, /tokensStore\.importFromExportDir\(cacheDir\)/);
assert.match(src, /remapProjectSceneTokenIds/);
const importZip = src.match(
/registerHandler\(ipcChannels\.project\.importZip,[\s\S]*?return \{ canceled: false as const, project \}/,
);
assert.ok(importZip);
assert.match(importZip[0], /importAppTokensFromOpenProjectCache/);
const importFromPath = src.match(
/registerHandler\(ipcChannels\.project\.importZipFromPath,[\s\S]*?return \{ project \}/,
);
assert.ok(importFromPath);
assert.match(importFromPath[0], /importAppTokensFromOpenProjectCache/);
});
void test('zipStore: zipDir uses ensureParentDir (no mkdir of drive root)', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /from '\.\/ensureParentDir'/);
const i = src.indexOf('async function zipDir(');
assert.ok(i >= 0);
const block = src.slice(i, i + 800);
assert.match(block, /await ensureParentDir\(outZipPath\)/);
assert.doesNotMatch(block, /fs\.mkdir\(path\.dirname\(outZipPath\)/);
});
void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => { 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');
+12 -11
View File
@@ -112,6 +112,7 @@ 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 +476,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);
@@ -1165,7 +1166,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 +1227,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 +1317,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 +1552,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 +2039,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) {
@@ -2178,7 +2179,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 +2292,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 +2429,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);
@@ -2894,7 +2895,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 +3028,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);
@@ -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);
});
@@ -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://)', () => {
@@ -113,3 +137,10 @@ void test('createWindows: логи окон не валят main через EPIP
assert.ok(src.includes('errorCode === -3')); assert.ok(src.includes('errorCode === -3'));
assert.ok(src.includes('isMainFrame')); assert.ok(src.includes('isMainFrame'));
}); });
void test('createWindows: presentation/control не троттлят RAF в фоне (macOS paths)', () => {
const src = readCreateWindows();
assert.match(src, /kind === 'presentation' \|\| kind === 'control'/);
assert.match(src, /backgroundThrottling:\s*false/);
assert.match(src, /setBackgroundThrottling\(false\)/);
});
+140 -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 } : {}),
@@ -398,6 +433,10 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
autoplayPolicy: 'no-user-gesture-required', autoplayPolicy: 'no-user-gesture-required',
// file:// + бандл Vite: без этого на Windows часто не грузятся чанки; http:// (dev server) оставляем строгим. // file:// + бандл Vite: без этого на Windows часто не грузятся чанки; http:// (dev server) оставляем строгим.
webSecurity: Boolean(process.env.VITE_DEV_SERVER_URL), webSecurity: Boolean(process.env.VITE_DEV_SERVER_URL),
// macOS throttles RAF in unfocused windows — token paths and video stutter on presentation.
...(kind === 'presentation' || kind === 'control' || kind === 'tokenPathEditor'
? { backgroundThrottling: false }
: {}),
...(kind === 'npcs' ...(kind === 'npcs'
? { ? {
webviewTag: true, webviewTag: true,
@@ -416,6 +455,9 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
} }
bindWindowChromeTitle(win, kind); bindWindowChromeTitle(win, kind);
if (kind === 'presentation' || kind === 'control' || kind === 'tokenPathEditor') {
win.webContents.setBackgroundThrottling(false);
}
if (kind === 'npcs') { if (kind === 'npcs') {
win.webContents.on('will-attach-webview', (event, guestPrefs, params) => { win.webContents.on('will-attach-webview', (event, guestPrefs, params) => {
delete guestPrefs.preload; delete guestPrefs.preload;
@@ -437,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);
} }
@@ -445,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;
@@ -467,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();
@@ -606,6 +668,19 @@ export function closeMultiWindow(): void {
} }
} }
/** Фокус пульта + событие: выход из показа спрашивает подтверждение на пульте. */
export function requestCloseMultiWindowConfirm(): void {
const ctrl = windows.get('control');
if (!ctrl || ctrl.isDestroyed()) {
closeMultiWindow();
return;
}
if (ctrl.isMinimized()) ctrl.restore();
ctrl.show();
ctrl.focus();
ctrl.webContents.send(ipcChannels.windows.closeMultiWindowConfirmRequested, {});
}
export function isMultiWindowOpen(): boolean { export function isMultiWindowOpen(): boolean {
return windows.has('presentation') || windows.has('control'); return windows.has('presentation') || windows.has('control');
} }
@@ -738,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;
@@ -814,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();
+34 -2
View File
@@ -339,19 +339,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 +379,7 @@
align-items: center; align-items: center;
gap: 8px; gap: 8px;
min-width: 0; min-width: 0;
min-height: 34px;
} }
.snapToGrid { .snapToGrid {
@@ -366,26 +387,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);
} }
+129 -23
View File
@@ -95,6 +95,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 +203,7 @@ export function ControlApp() {
y: number; y: number;
placementId: string; placementId: string;
} | null>(null); } | null>(null);
const [stopConfirmOpen, setStopConfirmOpen] = 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 +211,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 +308,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);
@@ -1073,7 +1077,7 @@ export function ControlApp() {
/** Действия с токенами/ловушками только без активной кисти эффектов. */ /** Действия с токенами/ловушками только без активной кисти эффектов. */
const markersInteractive = tool.tool === 'none'; const markersInteractive = tool.tool === 'none';
const pathLivePoses = useTokenPathLivePoses({ useTokenPathLivePoses({
tokens: currentScene?.tokens ?? [], tokens: currentScene?.tokens ?? [],
npcTokens: visibleNpcTokens, npcTokens: visibleNpcTokens,
pathSession: tokenPathSession, pathSession: tokenPathSession,
@@ -1152,6 +1156,27 @@ export function ControlApp() {
}; };
}, [npcMapSpawnDrag.dragging, npcMapSpawnDragApi, snapNormActive]); }, [npcMapSpawnDrag.dragging, npcMapSpawnDragApi, snapNormActive]);
useEffect(() => {
return api.on(ipcChannels.windows.closeMultiWindowConfirmRequested, () => {
setStopConfirmOpen(true);
});
}, [api]);
useEffect(() => {
if (npcMapSpawnDrag.dragging) return;
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
e.preventDefault();
if (stopConfirmOpen) {
setStopConfirmOpen(false);
return;
}
setStopConfirmOpen(true);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [npcMapSpawnDrag.dragging, stopConfirmOpen]);
useEffect(() => { useEffect(() => {
if (!markersInteractive) { if (!markersInteractive) {
setNpcSessionCtxMenu(null); setNpcSessionCtxMenu(null);
@@ -1221,7 +1246,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 +1412,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 +1958,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} />
<> <>
@@ -2104,19 +2148,22 @@ export function ControlApp() {
<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 +2423,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 +2447,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 +2465,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,11 +2514,7 @@ export function ControlApp() {
: t('control.showPlayers')} : t('control.showPlayers')}
</Button> </Button>
) : null} ) : null}
</> <Button onClick={() => setStopConfirmOpen(true)}>{t('control.stopPresentation')}</Button>
) : null}
<Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}>
{t('control.stopPresentation')}
</Button>
</div> </div>
</div> </div>
<div className={styles.spacer10} /> <div className={styles.spacer10} />
@@ -2477,7 +2522,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 +2557,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
@@ -2727,7 +2773,6 @@ export function ControlApp() {
viewport={previewContentRect} viewport={previewContentRect}
editable={markersInteractive} editable={markersInteractive}
snapNorm={snapNormActive} snapNorm={snapNormActive}
poseOverrides={pathLivePoses.tokenPoses}
dragEnabledById={tokenPathDragEnabled} dragEnabledById={tokenPathDragEnabled}
onMove={(placementId, nx, ny) => { onMove={(placementId, nx, ny) => {
const snapped = snapNormActive(nx, ny); const snapped = snapNormActive(nx, ny);
@@ -2767,7 +2812,6 @@ export function ControlApp() {
grid={currentScene?.grid ?? null} grid={currentScene?.grid ?? null}
editable={markersInteractive} editable={markersInteractive}
snapNorm={snapNormActive} snapNorm={snapNormActive}
poseOverrides={pathLivePoses.npcPoses}
dragEnabledById={npcPathDragEnabled} dragEnabledById={npcPathDragEnabled}
onMove={(placementId, nx, ny) => { onMove={(placementId, nx, ny) => {
const snapped = snapNormActive(nx, ny); const snapped = snapNormActive(nx, ny);
@@ -3046,7 +3090,7 @@ export function ControlApp() {
style={{ zIndex: 48 }} style={{ zIndex: 48 }}
/> />
) : null} ) : null}
<SessionTimerLayer viewport={previewContentRect} editable /> <SessionTimerLayer viewport={markerViewport} editable />
</div> </div>
</Surface> </Surface>
@@ -3094,9 +3138,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={() => setStopConfirmOpen(true)}
> >
{t('control.endPresentation')} {t('control.stopPresentation')}
</Button> </Button>
</div> </div>
) : null} ) : null}
@@ -3455,6 +3499,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,
@@ -3465,6 +3527,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}
@@ -3480,6 +3544,7 @@ export function ControlApp() {
> >
{t('npcs.makeActive')} {t('npcs.makeActive')}
</button> </button>
</>
); );
} }
const isSessionSpawn = Boolean( const isSessionSpawn = Boolean(
@@ -3487,6 +3552,7 @@ export function ControlApp() {
); );
return ( return (
<> <>
{hideFromPlayersItem}
{isSessionSpawn ? ( {isSessionSpawn ? (
<button <button
type="button" type="button"
@@ -3662,6 +3728,46 @@ 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>{t('control.stopConfirm.body')}</div>
<div className={styles.modalFooter}>
<Button onClick={() => setStopConfirmOpen(false)}>{t('common.cancel')}</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}
</div> </div>
); );
} }
@@ -32,4 +32,18 @@ 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 корня', () => {
const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
const hook = fs.readFileSync(
path.join(here, '../shared/tokens/useTokenPathLivePoses.ts'),
'utf8',
);
assert.doesNotMatch(app, /\bpathLivePoses\b/);
assert.doesNotMatch(hook, /\bsetPoses\b/);
assert.ok(hook.includes('applyLivePathPose'));
assert.ok(hook.includes('requestAnimationFrame'));
}); });
@@ -135,16 +135,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 +316,17 @@ 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, 1);
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')}
+13 -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;
@@ -550,113 +556,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 +587,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;
+72 -77
View File
@@ -37,7 +37,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 +62,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 +130,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);
@@ -166,10 +164,28 @@ export function EditorApp() {
const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false); const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false);
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 +278,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 +379,14 @@ 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 launchFromGraphNode = useCallback( const launchFromGraphNode = useCallback(
(graphNodeId: GraphNodeId, playerIds?: string[]) => { (graphNodeId: GraphNodeId, playerIds?: string[]) => {
if (!licenseActive || launching) return; if (!licenseActive || launching) return;
@@ -464,26 +486,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 +795,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 +805,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 +828,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 +845,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 +862,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 +879,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 +892,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 ? (
<> <>
@@ -971,8 +979,7 @@ export function EditorApp() {
title={runHelpTooltip} title={runHelpTooltip}
tooltipPlacement="bottom-left" tooltipPlacement="bottom-left"
onClick={() => { onClick={() => {
setInstructionsSection(runHelpSection); openInstructions(runHelpSection);
setInstructionsOpen(true);
}} }}
> >
? ?
@@ -1360,6 +1367,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 +1460,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 +1661,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)}
@@ -2771,7 +2766,7 @@ function SceneInspector({
void getDndApi().invoke(ipcChannels.windows.openSceneEditor, {}); void getDndApi().invoke(ipcChannels.windows.openSceneEditor, {});
}} }}
> >
Редактор сцены {t('sceneEditor.open')}
</Button> </Button>
</> </>
) : null} ) : null}
+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');
});
+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';
}
+153 -19
View File
@@ -101,7 +101,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'presentation.overlay': 'Презентация запущена', 'presentation.overlay': 'Презентация запущена',
'presentation.title': 'Презентация запущена', 'presentation.title': 'Презентация запущена',
'presentation.body': 'presentation.body':
'Редактор заблокирован. Закройте окна «Презентация» и «Панель управления», чтобы продолжить.', 'Редактор заблокирован. На пульте нажмите «Завершить» и подтвердите, чтобы продолжить.',
'zip.progress': 'Прогресс операции', 'zip.progress': 'Прогресс операции',
'zip.importTitle': 'Импорт проекта', 'zip.importTitle': 'Импорт проекта',
@@ -132,12 +132,20 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'menu.aboutLicense': 'О лицензии', 'menu.aboutLicense': 'О лицензии',
'menu.checkUpdates': 'Проверить обновления', 'menu.checkUpdates': 'Проверить обновления',
'menu.presentationSettings': 'Экран презентации', 'menu.presentationSettings': 'Экран презентации',
'menu.gameSystems': 'Игровые системы',
'menu.language': 'Язык', 'menu.language': 'Язык',
'menu.langRu': 'Русский', 'menu.langRu': 'Русский',
'menu.langEn': 'English', 'menu.langEn': 'English',
'menu.aboutProgram': 'О программе', 'menu.aboutProgram': 'О программе',
'menu.instructions': 'Инструкция', 'menu.instructions': 'Инструкция',
'gameSystems.title': 'Игровые системы',
'gameSystems.import': 'Импорт…',
'gameSystems.preview': 'Просмотр',
'gameSystems.remove': 'Удалить',
'gameSystems.empty': 'Нет импортированных систем',
'gameSystems.close': 'Закрыть',
'top.aboutApp': 'О приложении', 'top.aboutApp': 'О приложении',
'app.about.title': 'О программе', 'app.about.title': 'О программе',
@@ -152,6 +160,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'app.about.supportEmail': 'player.ttrpg@gmail.com', 'app.about.supportEmail': 'player.ttrpg@gmail.com',
'app.about.websiteLabel': 'САЙТ И ОБНОВЛЕНИЯ', 'app.about.websiteLabel': 'САЙТ И ОБНОВЛЕНИЯ',
'app.about.websiteUrl': 'https://ttrpgplayer.ru/', 'app.about.websiteUrl': 'https://ttrpgplayer.ru/',
'app.about.sponsorsLabel': 'СПОНСОРЫ',
'help.title': 'Инструкция', 'help.title': 'Инструкция',
'help.navAria': 'Разделы инструкции', 'help.navAria': 'Разделы инструкции',
@@ -182,7 +191,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.sceneProps.title': 'Свойства сцены', 'help.section.sceneProps.title': 'Свойства сцены',
'help.section.sceneProps.body': 'help.section.sceneProps.body':
'Выберите сцену в списке слева — справа откроются её свойства.\n\n«Название сцены» — для мастера. «Описание» — заметки мастера с форматированием: рядом с подписью нажмите карандаш, откроется редактор (жирный, курсив, заголовки, списки, ссылки). Под подписью видно фрагмент текста или «описание отсутствует», если поле пустое. Во время сессии описание открывается с пульта в отдельном окне (см. «Пульт управления»), а не в блоке «Сюжетная линия».\n\nЕсли у сцены на карте есть карточка с синей меткой «ПОБОЧНАЯ», появится поле «Название побочной линии».\n\nКартинка или видео для игроков:\n\n1) В «Превью сцены» нажмите «Загрузить» или «Изменить».\n\n2) Выберите файл (PNG, JPG, WebP, GIF, видео и др.).\n\n3) Для картинки и видео можно «Повернуть» (шаг 90°). «Очистить» — убрать превью.\n\n4) Для картинки и видео можно включить «Затемнить сцену»: при показе игроки сначала увидят кадр в полной темноте, а вы снимете её с пульта «Кистью Открытия» (см. «Эффекты»).\n\n5) Кнопка «Редактор сцены» доступна и для картинки, и для видео — сетка, ловушки, неигровые токены и токены НПС поверх превью (см. «Редактор сцены», «Генератор сетки», «Ловушки», «Неигровые токены», «НПС»).\n\nДля видео включите «Автостарт», если ролик должен сам начаться на экране игроков, и при необходимости «Цикл».\n\n«Аудио сцены» — музыка и звуки этого эпизода. «Загрузить» добавляет файлы. У каждого трека: «Авто» (играть при входе в сцену) и «Цикл». Корзина удаляет трек.\n\nСвязи между сценами задаются на карте, а не здесь — см. «Граф сцен».', 'Выберите сцену в списке слева — справа откроются её свойства.\n\n«Название сцены» — для мастера. «Описание» — заметки мастера с форматированием: рядом с подписью нажмите карандаш, откроется редактор (жирный, курсив, заголовки, списки, ссылки). Под подписью видно фрагмент текста или «описание отсутствует», если поле пустое. Во время сессии описание открывается с пульта в отдельном окне (см. «Пульт управления»), а не в блоке «История».\n\nЕсли у сцены на карте есть карточка с синей меткой «ПОБОЧНАЯ», появится поле «Название побочной линии».\n\nКартинка или видео для игроков:\n\n1) В «Превью сцены» нажмите «Загрузить» или «Изменить».\n\n2) Выберите файл (PNG, JPG, WebP, GIF, видео и др.).\n\n3) Для картинки и видео можно «Повернуть» (шаг 90°). «Очистить» — убрать превью.\n\n4) Для картинки и видео можно включить «Затемнить сцену»: при показе игроки сначала увидят кадр в полной темноте, а вы снимете её с пульта «Кистью Открытия» (см. «Эффекты»).\n\n5) Кнопка «Редактор сцены» доступна и для картинки, и для видео — сетка, ловушки, неигровые токены и токены НПС поверх превью (см. «Редактор сцены», «Генератор сетки», «Ловушки», «Неигровые токены», «НПС»).\n\nДля видео включите «Автостарт», если ролик должен сам начаться на экране игроков, и при необходимости «Цикл».\n\n«Аудио сцены» — музыка и звуки этого эпизода. «Загрузить» добавляет файлы. У каждого трека: «Авто» (играть при входе в сцену) и «Цикл». Корзина удаляет трек.\n\nСвязи между сценами задаются на карте, а не здесь — см. «Граф сцен».',
'help.section.sceneEditor.title': 'Редактор сцены', 'help.section.sceneEditor.title': 'Редактор сцены',
'help.section.sceneEditor.body': 'help.section.sceneEditor.body':
@@ -192,6 +201,56 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.grid.body': 'help.section.grid.body':
'Генератор сетки накладывает на превью сцены (картинку или видео) боевую сетку — квадратную или гексагональную. Сетка помогает ориентироваться по клеткам во время боя и видна и вам на пульте, и игрокам на презентации.\n\nНастроить:\n\n1) Откройте «Редактор сцены» (см. «Редактор сцены»).\n\n2) Раскройте аккордеон «Сетка» слева.\n\n3) Включите «Наложить сетку» — линии появятся поверх карты.\n\n4) «Тип» — «Квадратная» или «Гексогональная».\n\n5) «Цвет» — оттенок линий (удобно подобрать контраст к карте).\n\n6) «Размер» — ползунок ячейки: чем больше значение, тем крупнее клетки.\n\nПока сетка выключена, тип, цвет и размер недоступны для изменения, но запомненные значения сохраняются и вернутся при повторном включении.\n\nШаблоны сетки кампании:\n\n1) Настройте сетку на сцене.\n\n2) Нажмите «Сохранить как шаблон» и задайте имя — стиль (тип, цвет, размер) запишется в файл проекта. Автоматически на другие сцены он не подставляется.\n\n3) На любой сцене в списке шаблонов нажмите «Применить» — на сцену запишутся параметры шаблона и сетка включится.\n\n4) Шаблон можно переименовать или удалить. В кампании можно хранить несколько шаблонов (до 20).\n\nНастройки сетки каждой сцены и список шаблонов хранятся в проекте. Сетка рисуется под маркерами ловушек и токенов и не мешает их расставлять.\n\nВо время сессии на пульте можно включить «Привязка токенов к сетке»: при перетаскивании неигровые токены, токены НПС и игроков «прилипают» к клеткам (квадрат или гекс — по типу сетки). Если сетка выключена, галочка не действует.', 'Генератор сетки накладывает на превью сцены (картинку или видео) боевую сетку — квадратную или гексагональную. Сетка помогает ориентироваться по клеткам во время боя и видна и вам на пульте, и игрокам на презентации.\n\nНастроить:\n\n1) Откройте «Редактор сцены» (см. «Редактор сцены»).\n\n2) Раскройте аккордеон «Сетка» слева.\n\n3) Включите «Наложить сетку» — линии появятся поверх карты.\n\n4) «Тип» — «Квадратная» или «Гексогональная».\n\n5) «Цвет» — оттенок линий (удобно подобрать контраст к карте).\n\n6) «Размер» — ползунок ячейки: чем больше значение, тем крупнее клетки.\n\nПока сетка выключена, тип, цвет и размер недоступны для изменения, но запомненные значения сохраняются и вернутся при повторном включении.\n\nШаблоны сетки кампании:\n\n1) Настройте сетку на сцене.\n\n2) Нажмите «Сохранить как шаблон» и задайте имя — стиль (тип, цвет, размер) запишется в файл проекта. Автоматически на другие сцены он не подставляется.\n\n3) На любой сцене в списке шаблонов нажмите «Применить» — на сцену запишутся параметры шаблона и сетка включится.\n\n4) Шаблон можно переименовать или удалить. В кампании можно хранить несколько шаблонов (до 20).\n\nНастройки сетки каждой сцены и список шаблонов хранятся в проекте. Сетка рисуется под маркерами ловушек и токенов и не мешает их расставлять.\n\nВо время сессии на пульте можно включить «Привязка токенов к сетке»: при перетаскивании неигровые токены, токены НПС и игроков «прилипают» к клеткам (квадрат или гекс — по типу сетки). Если сетка выключена, галочка не действует.',
'sceneEditor.open': 'Редактор сцены',
'sceneEditor.grid': 'Сетка',
'sceneEditor.enableGrid': 'Наложить сетку',
'sceneEditor.gridType': 'Тип',
'sceneEditor.gridTypeAria': 'Тип сетки',
'sceneEditor.gridSquare': 'Квадратная',
'sceneEditor.gridHex': 'Гексогональная',
'sceneEditor.gridColor': 'Цвет',
'sceneEditor.gridColorAria': 'Цвет сетки',
'sceneEditor.gridSize': 'Размер',
'sceneEditor.npcs': 'НПС',
'sceneEditor.npcSearch': 'Поиск НПС…',
'sceneEditor.noNpcs': 'Нет НПС',
'sceneEditor.tokens': 'Неигровые токены',
'sceneEditor.add': 'Добавить',
'sceneEditor.tokenSearch': 'Поиск…',
'sceneEditor.noTokens': 'Нет токенов',
'sceneEditor.traps': 'Ловушки',
'sceneEditor.dragToMap': 'Перетащите на карту',
'sceneEditor.clearScene': 'Очистить сцену',
'sceneEditor.needMedia': 'Нужно изображение или видео сцены',
'sceneEditor.mapHint': 'Колесо — зум. СКМ / Space+ЛКМ — пан. Delete — удалить выбранное.',
'sceneEditor.untitledScene': 'Сцена',
'sceneEditor.deleteTokenTitle': 'Удалить токен',
'sceneEditor.deleteTokenBody': 'Удалить токен «{name}» из пула? Он также будет убран с текущей сцены.',
'sceneEditor.setPath': 'Указать движение',
'sceneEditor.resetPath': 'Сбросить на старт пути',
'sceneEditor.trap.mimic': 'Мимик',
'sceneEditor.trap.explosion': 'Взрыв',
'sceneEditor.trap.poison': 'Яд',
'sceneEditor.trap.pit': 'Пропасть',
'sceneEditor.trap.arrow': 'Стрела',
'sceneEditor.trap.laser': 'Лазер',
'sceneEditor.trap.freeform': 'Метка',
'sceneEditor.editToken': 'Изменить токен',
'sceneEditor.addToken': 'Добавить токен',
'sceneEditor.tokenName': 'Название',
'sceneEditor.tokenNamePlaceholder': 'Название токена',
'sceneEditor.tokenNameRequired': 'Укажите название',
'sceneEditor.tokenNameDuplicate': 'Такое название уже есть',
'sceneEditor.image': 'Изображение',
'sceneEditor.dropImage': 'Отпустите, чтобы загрузить изображение',
'sceneEditor.dropOrPick': 'Перетащите изображение или выберите файл',
'sceneEditor.chooseImage': 'Выбрать изображение',
'sceneEditor.imageRequired': 'Нужно изображение',
'sceneEditor.saveFailed': 'Не удалось сохранить',
'sceneEditor.dragDelete': 'Перетащите · ПКМ — удалить',
'sceneEditor.rotate': 'Повернуть',
'sceneEditor.actions': 'Действия',
'sceneEditor.edit': 'Изменить',
'sceneGrid.templates': 'Шаблоны кампании', 'sceneGrid.templates': 'Шаблоны кампании',
'sceneGrid.saveTemplate': 'Сохранить как шаблон', 'sceneGrid.saveTemplate': 'Сохранить как шаблон',
'sceneGrid.applyTemplate': 'Применить', 'sceneGrid.applyTemplate': 'Применить',
@@ -227,15 +286,15 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.session.title': 'Запуск сессии', 'help.section.session.title': 'Запуск сессии',
'help.section.session.body': 'help.section.session.body':
'Когда кампания готова, можно начать игру.\n\nОбычный запуск:\n\n1) На карте связей щёлкните правой кнопкой по карточке старта → «Начальная сцена».\n\n2) Нажмите «Запустить» в шапке редактора (левая часть кнопки).\n\nЗапуск с игроками: стрелка справа на кнопке «Запустить» → «Запустить с игроками» — выберите игроков и/или команды. Выбор сохранится на время сессии; на пульте появится «Показать игроков» / «Скрыть игроков». При смене сцены токены игроков скрываются.\n\nБыстрый запуск с любой карточки: правый клик по нужной карточке на карте → «Запустить с этой сцены». Презентация и пульт откроются сразу с выбранного места.\n\nОткроются «Презентация» (для игроков) и «Пульт управления» (для вас). Редактор на время показа затемняется — так и должно быть.\n\nЭкран для игроков можно заранее выбрать в «Настройки» → «Экран презентации»: монитор и полноэкранный режим. При «Запустить» окно презентации откроется на выбранном экране автоматически.\n\nВернуться к подготовке:\n\n1) На пульте нажмите «Выключить» или «Завершить показ» (если дальше некуда переходить).\n\n2) Дождитесь закрытия обоих окон.', 'Когда кампания готова, можно начать игру.\n\nОбычный запуск:\n\n1) На карте связей щёлкните правой кнопкой по карточке старта → «Начальная сцена».\n\n2) Нажмите «Запустить» в шапке редактора (левая часть кнопки).\n\nЗапуск с игроками: стрелка справа на кнопке «Запустить» → «Запустить с игроками» — выберите игроков и/или команды. Выбор сохранится на время сессии; на пульте появится «Показать игроков» / «Скрыть игроков». При смене сцены токены игроков скрываются.\n\nБыстрый запуск с любой карточки: правый клик по нужной карточке на карте → «Запустить с этой сцены». Презентация и пульт откроются сразу с выбранного места.\n\nОткроются «Презентация» (для игроков) и «Пульт управления» (для вас). Редактор на время показа затемняется — так и должно быть.\n\nЭкран для игроков можно заранее выбрать в «Настройки» → «Экран презентации»: монитор и полноэкранный режим. При «Запустить» окно презентации откроется на выбранном экране автоматически.\n\nВернуться к подготовке:\n\n1) На пульте нажмите «Завершить» (у предпросмотра или в блоке ветвления, если переходов нет). Тот же вопрос задают Escape и закрытие окна презентации или пульта.\n\n2) В окне «Завершить показ?» нажмите «Завершить» и дождитесь закрытия обоих окон.',
'help.section.controlPanel.title': 'Пульт управления', 'help.section.controlPanel.title': 'Пульт управления',
'help.section.controlPanel.body': 'help.section.controlPanel.body':
'Пульт — ваш стол во время игры. Игроки смотрят на презентацию, вы управляете всем отсюда.\n\nСлева сверху — «Инструменты», затем эффекты и ход сюжета; справа — мини-копия экрана игроков, варианты переходов и музыка.\n\nВ «Инструменты» кнопка с книгой открывает отдельное окно с оформленным описанием текущей сцены. Если описания нет, кнопка неактивна — при наведении подсказка «Описание отсутствует». Кнопка материалов (иконка карты) открывает окно со списком материалов кампании — подробнее в разделе «Материалы». Кнопка ⏱ открывает таймер сессии (обратный отсчёт): «Показать игрокам» выводит виджет на предпросмотр и презентацию — его можно двигать, масштабировать и вращать на превью пульта. Кнопка НПС (цветная иконка человека) открывает окно персонажей: карточки описания, чекбокс «Показать» для аватара поверх сцены и постановка токена на карту на время сессии — см. раздел «НПС».\n\n«Предпросмотр экрана» показывает то же, что видят игроки. Здесь же рисуют эффекты — они сразу появляются на большом экране (и на картинке, и на видео). Если на сцене расставлены ловушки, они видны на предпросмотре: правый клик по маркеру — проявить, активировать или обезвредить (см. «Ловушки»). Неигровые токены, токены НПС и игроков тоже видны на предпросмотре: их можно двигать и менять размер за жёлтый уголок до конца сессии. Линии путей движения видны вам всегда; игрокам — только после «Показать путь» в меню токена. На превью можно перетащить плитку из окна НПС — появится сессионный токен. ПКМ по неигровому токену — показать / скрыть путь, остановить / продолжить движение, сброс на старт. ПКМ по токену НПС — активировать / открыть информацию / сменить тип / сделать неактивным / убрать с карты / путь (см. «НПС» и «Неигровые токены»). Аватары с чекбокса «Показать» лежат поверх сцены отдельно от токенов на карте: их двигают, масштабируют лупами из окна НПС и закрывают «Закрыть всех». На видео-сценах внизу превью остаются кнопки воспроизведения и полоса перемотки.\n\nНад превью:\n• «Привязка токенов к сетке» — если на сцене включена сетка, перетаскиваемые токены (неигровые, НПС, игроки) встают по клеткам;\n• «Размеры игр. токенов» — общий масштаб круглых токенов игроков и НПС на предпросмотре и презентации;\n• «Показать игроков» / «Скрыть игроков» — после запуска с игроками.\n\n«Сюжетная линия» — цепочка пройденных эпизодов. Текущая сцена помечена «ТЕКУЩАЯ СЦЕНА». Клик по прошлому шагу переносит партию к тому месту на карте и добавляет новый шаг в историю.\n\n«Выключить демонстрацию» — закончить показ и вернуться в редактор.', 'Пульт — ваш стол во время игры. Игроки смотрят на презентацию, вы управляете всем отсюда.\n\nСлева сверху — «Инструменты», затем эффекты, «История» и «Сцены»; справа — мини-копия экрана игроков, варианты переходов и музыка.\n\nВ «Инструменты»: маркер (рисунок на карте), книга (описание текущей сцены; если описания нет, кнопка неактивна и подсказка «Описание отсутствует»), материалы (см. «Материалы»), плейлисты, таймер (обратный отсчёт: «Показать игрокам» выводит виджет на предпросмотр и презентацию), инициатива (очередь хода), кубики, НПС (карточки, чекбокс «Показать» для аватара и постановка токена на карту — см. «НПС») и «?» («Справка»: открывает этот раздел, не закрывая показ).\n\n«Предпросмотр экрана» показывает то же, что видят игроки. Здесь же рисуют эффекты — они сразу появляются на большом экране (и на картинке, и на видео). Если на сцене расставлены ловушки, они видны на предпросмотре: правый клик по маркеру — проявить, активировать или обезвредить (см. «Ловушки»). Неигровые токены, токены НПС и игроков тоже видны на предпросмотре: их можно двигать и менять размер за жёлтый уголок до конца сессии. Линии путей движения видны вам всегда; игрокам — только после «Показать путь» в меню токена. На превью можно перетащить плитку из окна НПС — появится сессионный токен. ПКМ по неигровому токену — показать / скрыть путь, остановить / продолжить движение, сброс на старт. ПКМ по токену НПС — скрыть от игроков или показать игрокам (на пульте токен остаётся), активировать / открыть информацию / сменить тип / сделать неактивным / убрать с карты / путь (см. «НПС» и «Неигровые токены»). Аватары с чекбокса «Показать» лежат поверх сцены отдельно от токенов на карте: их двигают, масштабируют лупами из окна НПС и закрывают «Закрыть всех». На видео-сценах внизу превью остаются кнопки воспроизведения и полоса перемотки.\n\nНад превью:\n• «Привязка токенов к сетке» — если на сцене включена сетка, перетаскиваемые токены (неигровые, НПС, игроки) встают по клеткам;\n• «Размер токенов игроков» — общий масштаб круглых токенов игроков и НПС на предпросмотре и презентации;\n• «Показать игроков» / «Скрыть игроков» — после запуска с игроками.\n\n«История» — цепочка пройденных эпизодов. Текущая сцена помечена «ТЕКУЩАЯ СЦЕНА». Клик по прошлому шагу переносит партию к тому месту на карте и добавляет новый шаг в историю. «Сцены» — сцены текущей линии, чтобы перейти к любой из них.\n\n«Завершить» у предпросмотра и в конце ветки (когда переходов нет) спрашивает подтверждение и после него закрывает показ. Escape на пульте и на презентации, а также закрытие этих окон, задают тот же вопрос.',
'help.section.transitions.title': 'Переходы между сценами', 'help.section.transitions.title': 'Переходы между сценами',
'help.section.transitions.body': 'help.section.transitions.body':
'Куда можно пойти дальше — видно на пульте в «Варианты ветвления». Это исходящие стрелки с текущей карточки на карте.\n\n1) Посмотрите карточки «ОПЦИЯ 1», «ОПЦИЯ 2» — там названия целевых сцен.\n\n2) Нажмите «Переключить» у нужного варианта.\n\n3) На презентации сменятся картинка и музыка, в сюжетной линии появится новый шаг.\n\nЕсли вариантов нет (конец ветки) — «Нет вариантов перехода». Нажмите «Завершить показ», чтобы закрыть окна.\n\nВарианты появляются только там, где вы провели стрелки на карте в редакторе.', 'Куда можно пойти дальше — видно на пульте в «Варианты ветвления». Это исходящие стрелки с текущей карточки на карте.\n\n1) Посмотрите карточки «ОПЦИЯ 1», «ОПЦИЯ 2» — там названия целевых сцен.\n\n2) Нажмите «Переключить» у нужного варианта.\n\n3) На презентации сменятся картинка и музыка, в сюжетной линии появится новый шаг.\n\nЕсли вариантов нет (конец ветки) — «Нет вариантов перехода». Нажмите «Завершить», чтобы закрыть окна.\n\nВарианты появляются только там, где вы провели стрелки на карте в редакторе.',
'help.section.music.title': 'Музыка на пульте', 'help.section.music.title': 'Музыка на пульте',
'help.section.music.body': 'help.section.music.body':
@@ -255,7 +314,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.settings.title': 'Настройки, язык и обновления', 'help.section.settings.title': 'Настройки, язык и обновления',
'help.section.settings.body': 'help.section.settings.body':
'«Настройки» → «Указать ключ» — ввести или сменить лицензию. «О лицензии» — проверить статус и срок.\n\n«Экран презентации» — выбрать монитор для окна игроков и включить или выключить полноэкранный режим. Настройки сохраняются и применяются при каждом «Запустить».\n\n«Проверить обновления» (в установленной версии при активной лицензии) — если есть новая версия, можно скачать и перезапустить программу.\n\n«Язык» → «Русский» или «English» — меняет язык интерфейса. Выбор сохраняется между запусками.\n\nНомер версии — в шапке справа от меню. «О приложении» → «О программе» — о продукте и контактах поддержки.', '«Настройки» → «Указать ключ» — ввести или сменить лицензию. «О лицензии» — проверить статус и срок.\n\n«Экран презентации» — выбрать монитор для окна игроков и включить или выключить полноэкранный режим. Настройки сохраняются и применяются при каждом «Запустить».\n\n«Проверить обновления» (в установленной версии при активной лицензии) — если есть новая версия, можно скачать и перезапустить программу.\n\n«Язык» → «Русский» или «English» — меняет язык интерфейса. Выбор сохраняется между запусками.\n\nНомер версии — в шапке справа от меню, сразу за ним иконка «?» («Справка»). «О приложении» открывает окно «О программе» — о продукте и контактах поддержки.',
'settings.presentation.title': 'Экран презентации', 'settings.presentation.title': 'Экран презентации',
'settings.presentation.hint': 'settings.presentation.hint':
@@ -535,6 +594,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.makeFriendly': 'Сделать дружественным', 'npcs.makeFriendly': 'Сделать дружественным',
'npcs.inactive': 'Неактивен', 'npcs.inactive': 'Неактивен',
'npcs.makeActive': 'Сделать активным', 'npcs.makeActive': 'Сделать активным',
'npcs.hideFromPlayers': 'Скрыть от игроков',
'npcs.showToPlayers': 'Показать игрокам',
'npcs.openInfo': 'Открыть информацию', 'npcs.openInfo': 'Открыть информацию',
'npcs.ringColor': 'ЦВЕТ РАМКИ ТОКЕНА', 'npcs.ringColor': 'ЦВЕТ РАМКИ ТОКЕНА',
'npcs.description': 'ОПИСАНИЕ', 'npcs.description': 'ОПИСАНИЕ',
@@ -656,6 +717,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.remoteTitle': 'ПУЛЬТ УПРАВЛЕНИЯ', 'control.remoteTitle': 'ПУЛЬТ УПРАВЛЕНИЯ',
'control.instruments': 'ИНСТРУМЕНТЫ', 'control.instruments': 'ИНСТРУМЕНТЫ',
'control.helpTool': 'Справка',
'control.previewPanHint': 'Колесо — зум; перетаскивание СКМ/ПКМ или Space+ЛКМ — пан',
'control.descriptionTool': 'Описание', 'control.descriptionTool': 'Описание',
'control.markerTool': 'Маркер', 'control.markerTool': 'Маркер',
'control.markerColor': 'Цвет маркера', 'control.markerColor': 'Цвет маркера',
@@ -676,6 +739,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'dice.noResult': 'Пока нет броска', 'dice.noResult': 'Пока нет броска',
'dice.inc': 'Больше', 'dice.inc': 'Больше',
'dice.dec': 'Меньше', 'dice.dec': 'Меньше',
'dice.showAnimationToPlayers': 'Показать анимацию игрокам',
'sessionTimer.duration': 'Длительность (MM:SS)', 'sessionTimer.duration': 'Длительность (MM:SS)',
'sessionTimer.start': 'Старт', 'sessionTimer.start': 'Старт',
@@ -747,11 +811,14 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.passed': 'Пройдено', 'control.passed': 'Пройдено',
'control.noActiveScene': 'Нет активной сцены.', 'control.noActiveScene': 'Нет активной сцены.',
'control.screenPreview': 'Предпросмотр экрана', 'control.screenPreview': 'Предпросмотр экрана',
'control.npcTokenScale': 'Размеры игр. токенов', 'control.npcTokenScale': 'Размер токенов игроков',
'control.resizeToken': 'Изменить размер', 'control.resizeToken': 'Изменить размер',
'control.snapTokensToGrid': 'Привязка токенов к сетке', 'control.snapTokensToGrid': 'Привязка токенов к сетке',
'control.snapTokensToGridNoGrid': 'Сетка на текущей сцене выключена — привязка не применяется', 'control.snapTokensToGridNoGrid': 'Сетка на текущей сцене выключена — привязка не применяется',
'control.stopPresentation': 'Выключить', 'control.stopPresentation': 'Завершить',
'control.stopConfirm.title': 'Завершить показ?',
'control.stopConfirm.body': 'Закрыть пульт и презентацию и вернуться в редактор?',
'control.stopConfirm.confirm': 'Завершить',
'control.showPlayers': 'Показать игроков', 'control.showPlayers': 'Показать игроков',
'control.hidePlayers': 'Скрыть игроков', 'control.hidePlayers': 'Скрыть игроков',
'control.videoBrushHint': 'control.videoBrushHint':
@@ -763,7 +830,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.unnamed': 'Без названия', 'control.unnamed': 'Без названия',
'control.switchScene': 'Переключить', 'control.switchScene': 'Переключить',
'control.noBranches': 'Нет вариантов перехода.', 'control.noBranches': 'Нет вариантов перехода.',
'control.endPresentation': 'Завершить показ', 'control.endPresentation': 'Завершить',
'control.music': 'Музыка', 'control.music': 'Музыка',
'control.sceneMusic': 'МУЗЫКА СЦЕНЫ', 'control.sceneMusic': 'МУЗЫКА СЦЕНЫ',
'control.gameMusic': 'МУЗЫКА ИГРЫ', 'control.gameMusic': 'МУЗЫКА ИГРЫ',
@@ -846,7 +913,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'presentation.overlay': 'Presentation running', 'presentation.overlay': 'Presentation running',
'presentation.title': 'Presentation running', 'presentation.title': 'Presentation running',
'presentation.body': 'The editor is locked. Close the Presentation and Control windows to continue.', 'presentation.body': 'The editor is locked. On the control panel click End and confirm to continue.',
'zip.progress': 'Operation progress', 'zip.progress': 'Operation progress',
'zip.importTitle': 'Import project', 'zip.importTitle': 'Import project',
@@ -877,12 +944,20 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'menu.aboutLicense': 'About license', 'menu.aboutLicense': 'About license',
'menu.checkUpdates': 'Check for updates', 'menu.checkUpdates': 'Check for updates',
'menu.presentationSettings': 'Presentation display', 'menu.presentationSettings': 'Presentation display',
'menu.gameSystems': 'Game systems',
'menu.language': 'Language', 'menu.language': 'Language',
'menu.langRu': 'Русский', 'menu.langRu': 'Русский',
'menu.langEn': 'English', 'menu.langEn': 'English',
'menu.aboutProgram': 'About', 'menu.aboutProgram': 'About',
'menu.instructions': 'Instructions', 'menu.instructions': 'Instructions',
'gameSystems.title': 'Game systems',
'gameSystems.import': 'Import…',
'gameSystems.preview': 'Preview',
'gameSystems.remove': 'Remove',
'gameSystems.empty': 'No imported systems',
'gameSystems.close': 'Close',
'top.aboutApp': 'About', 'top.aboutApp': 'About',
'app.about.title': 'About', 'app.about.title': 'About',
@@ -897,13 +972,14 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'app.about.supportEmail': 'player.ttrpg@gmail.com', 'app.about.supportEmail': 'player.ttrpg@gmail.com',
'app.about.websiteLabel': 'WEBSITE & UPDATES', 'app.about.websiteLabel': 'WEBSITE & UPDATES',
'app.about.websiteUrl': 'https://ttrpgplayer.ru/', 'app.about.websiteUrl': 'https://ttrpgplayer.ru/',
'app.about.sponsorsLabel': 'SPONSORS',
'help.title': 'Instructions', 'help.title': 'Instructions',
'help.navAria': 'Instruction sections', 'help.navAria': 'Instruction sections',
'help.section.overview.title': 'App overview', 'help.section.overview.title': 'App overview',
'help.section.overview.body': 'help.section.overview.body':
'When you launch the app, you land in the Editor — where you prepare your campaign: scenes, images, music, and how episodes connect. When it is time to play, click Run. That opens Presentation for your players and the Control panel for you.\n\nIn the editor, the left column lists scenes, the center shows the story map, and the right column holds game and scene settings. Game properties include NPCs, materials, and campaign audio; the header has Players.\n\nWhile a session is running, the editor is temporarily locked — that is normal. Close presentation and the control panel to edit again.\n\nInternet is only needed for license activation and update checks. All projects and files stay on your computer.', 'When you launch the app, you land in the Editor — where you prepare your campaign: scenes, images, music, and how episodes connect. When it is time to play, click Run. That opens Presentation for your players and the Control panel for you.\n\nIn the editor, the left column lists scenes, the center shows the story map, and the right column holds game and scene settings. Game properties include NPCs, materials, and campaign audio; the header has Players.\n\nWhile a session is running, the editor is temporarily locked — that is normal. On the control panel click End and confirm to edit again.\n\nInternet is only needed for license activation and update checks. All projects and files stay on your computer.',
'help.section.license.title': 'License and first launch', 'help.section.license.title': 'License and first launch',
'help.section.license.body': 'help.section.license.body':
@@ -927,7 +1003,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.sceneProps.title': 'Scene properties', 'help.section.sceneProps.title': 'Scene properties',
'help.section.sceneProps.body': 'help.section.sceneProps.body':
'Select a scene in the left list — its properties open on the right.\n\nScene title is for the GM. Description is GM notes with formatting: click the pencil next to the label to open the editor (bold, italic, headings, lists, links). Below the label you see a text preview, or “no description” when empty. During a session, open the description from the control panel in a separate window (see Control panel) — it is not shown inside the Storyline list.\n\nIf the scene has a graph card with a blue SIDE badge, a Side storyline title field appears.\n\nImage or video for players:\n\n1) Under Scene preview, click Upload or Change.\n\n2) Pick a file (PNG, JPG, WebP, GIF, video, etc.).\n\n3) For images and video, use Rotate (90° steps). Clear removes the preview.\n\n4) For images and video, enable Darken scene so players start in full darkness and you reveal the frame with the Opening brush on the control panel (see Effects).\n\n5) Scene editor works for both images and video — battle grid, traps, non-player tokens, and NPC tokens over the preview (see Scene editor, Grid generator, Traps, Non-player tokens, and NPCs).\n\nFor video, enable Autostart if the clip should start on its own on the player screen, and Loop if needed.\n\nScene audio — music and sounds for this episode. Upload adds files. Per track: Auto (play when entering the scene) and Loop. The trash icon removes a track.\n\nLinks between scenes are drawn on the map, not here — see Scene graph.', 'Select a scene in the left list — its properties open on the right.\n\nScene title is for the GM. Description is GM notes with formatting: click the pencil next to the label to open the editor (bold, italic, headings, lists, links). Below the label you see a text preview, or “no description” when empty. During a session, open the description from the control panel in a separate window (see Control panel) — it is not shown inside the History list.\n\nIf the scene has a graph card with a blue SIDE badge, a Side storyline title field appears.\n\nImage or video for players:\n\n1) Under Scene preview, click Upload or Change.\n\n2) Pick a file (PNG, JPG, WebP, GIF, video, etc.).\n\n3) For images and video, use Rotate (90° steps). Clear removes the preview.\n\n4) For images and video, enable Darken scene so players start in full darkness and you reveal the frame with the Opening brush on the control panel (see Effects).\n\n5) Scene editor works for both images and video — battle grid, traps, non-player tokens, and NPC tokens over the preview (see Scene editor, Grid generator, Traps, Non-player tokens, and NPCs).\n\nFor video, enable Autostart if the clip should start on its own on the player screen, and Loop if needed.\n\nScene audio — music and sounds for this episode. Upload adds files. Per track: Auto (play when entering the scene) and Loop. The trash icon removes a track.\n\nLinks between scenes are drawn on the map, not here — see Scene graph.',
'help.section.sceneEditor.title': 'Scene editor', 'help.section.sceneEditor.title': 'Scene editor',
'help.section.sceneEditor.body': 'help.section.sceneEditor.body':
@@ -937,6 +1013,56 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.grid.body': 'help.section.grid.body':
'The grid generator overlays a battle grid on the scene preview (image or video) — square or hexagonal. It helps track cells in combat and is visible both on your control panel and on the players’ presentation.\n\nSet it up:\n\n1) Open Scene editor (see Scene editor).\n\n2) Expand the Grid accordion on the left.\n\n3) Enable Overlay grid — lines appear over the map.\n\n4) Type — Square or Hexagonal.\n\n5) Color — line tint (pick contrast that suits the map).\n\n6) Size — cell size slider: higher values mean larger cells.\n\nWhile the grid is off, type, color, and size stay disabled, but the saved values return when you turn it back on.\n\nCampaign grid templates:\n\n1) Configure the grid on a scene.\n\n2) Click Save as template and name it — type, color, and size are stored in the project file. Nothing is applied to other scenes automatically.\n\n3) On any scene, click Apply on a template — that scene gets the template style and the grid turns on.\n\n4) Rename or delete templates anytime. A campaign can keep several templates (up to 20).\n\nPer-scene grid settings and the template list live in the project. The grid draws under trap and token markers and does not block placing them.\n\nDuring a session you can enable Snap tokens to grid on the control panel: dragging non-player tokens, NPC tokens, and player tokens snaps them to cells (square or hex, matching the grid type). If the grid is off, the checkbox has no effect.', 'The grid generator overlays a battle grid on the scene preview (image or video) — square or hexagonal. It helps track cells in combat and is visible both on your control panel and on the players’ presentation.\n\nSet it up:\n\n1) Open Scene editor (see Scene editor).\n\n2) Expand the Grid accordion on the left.\n\n3) Enable Overlay grid — lines appear over the map.\n\n4) Type — Square or Hexagonal.\n\n5) Color — line tint (pick contrast that suits the map).\n\n6) Size — cell size slider: higher values mean larger cells.\n\nWhile the grid is off, type, color, and size stay disabled, but the saved values return when you turn it back on.\n\nCampaign grid templates:\n\n1) Configure the grid on a scene.\n\n2) Click Save as template and name it — type, color, and size are stored in the project file. Nothing is applied to other scenes automatically.\n\n3) On any scene, click Apply on a template — that scene gets the template style and the grid turns on.\n\n4) Rename or delete templates anytime. A campaign can keep several templates (up to 20).\n\nPer-scene grid settings and the template list live in the project. The grid draws under trap and token markers and does not block placing them.\n\nDuring a session you can enable Snap tokens to grid on the control panel: dragging non-player tokens, NPC tokens, and player tokens snaps them to cells (square or hex, matching the grid type). If the grid is off, the checkbox has no effect.',
'sceneEditor.open': 'Scene editor',
'sceneEditor.grid': 'Grid',
'sceneEditor.enableGrid': 'Show grid',
'sceneEditor.gridType': 'Type',
'sceneEditor.gridTypeAria': 'Grid type',
'sceneEditor.gridSquare': 'Square',
'sceneEditor.gridHex': 'Hexagonal',
'sceneEditor.gridColor': 'Color',
'sceneEditor.gridColorAria': 'Grid color',
'sceneEditor.gridSize': 'Size',
'sceneEditor.npcs': 'NPCs',
'sceneEditor.npcSearch': 'Search NPCs…',
'sceneEditor.noNpcs': 'No NPCs',
'sceneEditor.tokens': 'Non-player tokens',
'sceneEditor.add': 'Add',
'sceneEditor.tokenSearch': 'Search…',
'sceneEditor.noTokens': 'No tokens',
'sceneEditor.traps': 'Traps',
'sceneEditor.dragToMap': 'Drag onto the map',
'sceneEditor.clearScene': 'Clear scene',
'sceneEditor.needMedia': 'The scene needs an image or video',
'sceneEditor.mapHint': 'Wheel — zoom. Middle button / Space+left — pan. Delete — remove the selection.',
'sceneEditor.untitledScene': 'Scene',
'sceneEditor.deleteTokenTitle': 'Delete token',
'sceneEditor.deleteTokenBody': 'Delete token “{name}” from the library? It will also be removed from the current scene.',
'sceneEditor.setPath': 'Set movement',
'sceneEditor.resetPath': 'Reset to path start',
'sceneEditor.trap.mimic': 'Mimic',
'sceneEditor.trap.explosion': 'Explosion',
'sceneEditor.trap.poison': 'Poison',
'sceneEditor.trap.pit': 'Pit',
'sceneEditor.trap.arrow': 'Arrow',
'sceneEditor.trap.laser': 'Laser',
'sceneEditor.trap.freeform': 'Marker',
'sceneEditor.editToken': 'Edit token',
'sceneEditor.addToken': 'Add token',
'sceneEditor.tokenName': 'Name',
'sceneEditor.tokenNamePlaceholder': 'Token name',
'sceneEditor.tokenNameRequired': 'Enter a name',
'sceneEditor.tokenNameDuplicate': 'That name is already used',
'sceneEditor.image': 'Image',
'sceneEditor.dropImage': 'Drop to upload the image',
'sceneEditor.dropOrPick': 'Drop an image or choose a file',
'sceneEditor.chooseImage': 'Choose image',
'sceneEditor.imageRequired': 'An image is required',
'sceneEditor.saveFailed': 'Could not save',
'sceneEditor.dragDelete': 'Drag · right-click to delete',
'sceneEditor.rotate': 'Rotate',
'sceneEditor.actions': 'Actions',
'sceneEditor.edit': 'Edit',
'sceneGrid.templates': 'Campaign templates', 'sceneGrid.templates': 'Campaign templates',
'sceneGrid.saveTemplate': 'Save as template', 'sceneGrid.saveTemplate': 'Save as template',
'sceneGrid.applyTemplate': 'Apply', 'sceneGrid.applyTemplate': 'Apply',
@@ -972,15 +1098,15 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.session.title': 'Starting a session', 'help.section.session.title': 'Starting a session',
'help.section.session.body': 'help.section.session.body':
'When your campaign is ready, you can start playing.\n\nStandard start:\n\n1) On the story map, right-click the starting card → Start scene.\n\n2) Click Run in the editor header (left part of the button).\n\nRun with players: use the chevron on the right of Run → Run with players — pick players and/or teams. The selection lasts for the session; the control panel shows Show players / Hide players. Changing scenes hides player tokens.\n\nQuick start from any card: right-click the card on the map → Start from this scene. Presentation and the control panel open at that spot right away.\n\nPresentation (for players) and the Control panel (for you) open. The editor dims while the show runs — that is expected.\n\nChoose the player screen ahead of time in Settings → Presentation display: pick a monitor and fullscreen mode. When you click Run, the presentation window opens on that display automatically.\n\nReturn to prep:\n\n1) On the control panel, click Turn off or End presentation (when there is nowhere left to go).\n\n2) Wait until both windows close.', 'When your campaign is ready, you can start playing.\n\nStandard start:\n\n1) On the story map, right-click the starting card → Start scene.\n\n2) Click Run in the editor header (left part of the button).\n\nRun with players: use the chevron on the right of Run → Run with players — pick players and/or teams. The selection lasts for the session; the control panel shows Show players / Hide players. Changing scenes hides player tokens.\n\nQuick start from any card: right-click the card on the map → Start from this scene. Presentation and the control panel open at that spot right away.\n\nPresentation (for players) and the Control panel (for you) open. The editor dims while the show runs — that is expected.\n\nChoose the player screen ahead of time in Settings → Presentation display: pick a monitor and fullscreen mode. When you click Run, the presentation window opens on that display automatically.\n\nReturn to prep:\n\n1) On the control panel click End (by the preview, or in the branch block when there is nowhere left to go). Escape and closing the presentation or control window ask the same question.\n\n2) In the End presentation? dialog click End and wait until both windows close.',
'help.section.controlPanel.title': 'Control panel', 'help.section.controlPanel.title': 'Control panel',
'help.section.controlPanel.body': 'help.section.controlPanel.body':
'The control panel is your desk during play. Players watch presentation; you run everything from here.\n\nTop-left: Tools, then effects and storyline. On the right: a mini copy of the player screen, branch options, and music.\n\nUnder Tools, the book button opens a separate window with the current scene’s formatted description. If there is no description, the button is disabled — the tooltip reads “No description”. The materials button (map icon) opens a window with the campaign materials list — see the Materials section for details. The timer button (⏱) opens the session countdown: Show to players places the widget on the control preview and presentation — drag, resize, and rotate it on the control preview. The NPCs button (colored person icon) opens the characters window: description cards, the Show checkbox for an avatar over the scene, and placing a token on the map for this session — see NPCs.\n\nScreen preview shows what players see. Paint effects here — they appear on the big screen right away for both image and video scenes. If the scene has traps, they appear on the preview: right-click a marker to reveal, activate, or disarm (see Traps). Non-player, NPC, and player tokens are also visible on the preview: you can move them and resize them from the yellow corner until the session ends. Path lines are always visible to you; players see a line only after Show path in the token menu. You can drag a tile from the NPCs window onto the preview to spawn a session token. Right-click a non-player token to show / hide path, stop / resume movement, or reset to start. Right-click an NPC token to activate / open information / change type / make inactive / remove from map / set a path (see NPCs and Non-player tokens). Avatars from the Show checkbox sit over the scene separately from map tokens: drag them, zoom with the NPCs window magnifiers, and clear them with Close all. On video scenes, transport controls and the scrub bar stay at the bottom of the preview.\n\nAbove the preview:\n• Snap tokens to grid — when the scene grid is on, dragged tokens (non-player, NPC, players) snap to cells;\n• Play token size — shared scale for circular player and NPC tokens on the preview and presentation;\n• Show players / Hide players — after Run with players.\n\nStoryline lists episodes you have visited. The current scene is marked CURRENT SCENE. Click an earlier step to move the party back to that spot and add a new history entry.\n\nStop presentation ends the show and unlocks the editor.', 'The control panel is your desk during play. Players watch presentation; you run everything from here.\n\nTop-left: Tools, then effects, History, and Scenes. On the right: a mini copy of the player screen, branch options, and music.\n\nUnder Tools: marker (draw on the map), book (current scene description; if there is none the button is disabled and the tooltip reads “No description”), materials (see Materials), playlists, timer (countdown: Show to players places the widget on the preview and presentation), initiative (turn order), dice, NPCs (cards, the Show checkbox for an avatar, and placing a token on the map — see NPCs), and “?” (Help: opens this section without ending the show).\n\nScreen preview shows what players see. Paint effects here — they appear on the big screen right away for both image and video scenes. If the scene has traps, they appear on the preview: right-click a marker to reveal, activate, or disarm (see Traps). Non-player, NPC, and player tokens are also visible on the preview: you can move them and resize them from the yellow corner until the session ends. Path lines are always visible to you; players see a line only after Show path in the token menu. You can drag a tile from the NPCs window onto the preview to spawn a session token. Right-click a non-player token to show / hide path, stop / resume movement, or reset to start. Right-click an NPC token to hide it from players or show it to players (it stays on the control panel), activate / open information / change type / make inactive / remove from map / set a path (see NPCs and Non-player tokens). Avatars from the Show checkbox sit over the scene separately from map tokens: drag them, zoom with the NPCs window magnifiers, and clear them with Close all. On video scenes, transport controls and the scrub bar stay at the bottom of the preview.\n\nAbove the preview:\n• Snap tokens to grid — when the scene grid is on, dragged tokens (non-player, NPC, players) snap to cells;\n• Player token size — shared scale for circular player and NPC tokens on the preview and presentation;\n• Show players / Hide players — after Run with players.\n\nHistory lists episodes you have visited. The current scene is marked CURRENT SCENE. Click an earlier step to move the party back to that spot and add a new history entry. Scenes lists scenes on the current line so you can jump to any of them.\n\nEnd by the preview and at the end of a branch (when there is nowhere left to go) asks for confirmation and then closes the show. Escape on the control panel and on presentation, and closing those windows, ask the same question.',
'help.section.transitions.title': 'Scene transitions', 'help.section.transitions.title': 'Scene transitions',
'help.section.transitions.body': 'help.section.transitions.body':
'Where you can go next appears under Branch options on the control panel. These are outgoing arrows from the current card on the map.\n\n1) Read the OPTION 1, OPTION 2 cards — they show target scene names.\n\n2) Click Switch on the choice you want.\n\n3) Presentation updates image and music; storyline adds a new step.\n\nIf there are no options (end of a branch), you will see No transitions available. Click End presentation to close the show windows.\n\nOptions only exist where you drew arrows on the map in the editor.', 'Where you can go next appears under Branch options on the control panel. These are outgoing arrows from the current card on the map.\n\n1) Read the OPTION 1, OPTION 2 cards — they show target scene names.\n\n2) Click Switch on the choice you want.\n\n3) Presentation updates image and music; storyline adds a new step.\n\nIf there are no options (end of a branch), you will see No transitions available. Click End to close the show windows.\n\nOptions only exist where you drew arrows on the map in the editor.',
'help.section.music.title': 'Music on the control panel', 'help.section.music.title': 'Music on the control panel',
'help.section.music.body': 'help.section.music.body':
@@ -1000,7 +1126,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.settings.title': 'Settings, language and updates', 'help.section.settings.title': 'Settings, language and updates',
'help.section.settings.body': 'help.section.settings.body':
'Settings → Enter license key — add or change your license. About license — check status and expiry.\n\nPresentation display — choose the monitor for the player window and turn fullscreen on or off. Settings are saved and applied every time you click Run.\n\nCheck for updates (installed app with active license) — if a new version exists, you can download and restart.\n\nLanguage → Русский or English changes the interface. Your choice is saved between launches.\n\nThe version number is in the header, right of the menus. About → About opens product info and support contacts.', 'Settings → Enter license key — add or change your license. About license — check status and expiry.\n\nPresentation display — choose the monitor for the player window and turn fullscreen on or off. Settings are saved and applied every time you click Run.\n\nCheck for updates (installed app with active license) — if a new version exists, you can download and restart.\n\nLanguage → Русский or English changes the interface. Your choice is saved between launches.\n\nThe version number is in the header, right of the menus, with a “?” (Help) button immediately after it. About opens the product and support window.',
'settings.presentation.title': 'Presentation display', 'settings.presentation.title': 'Presentation display',
'settings.presentation.hint': 'settings.presentation.hint':
@@ -1282,6 +1408,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.makeFriendly': 'Make Friendly', 'npcs.makeFriendly': 'Make Friendly',
'npcs.inactive': 'Inactive', 'npcs.inactive': 'Inactive',
'npcs.makeActive': 'Make Active', 'npcs.makeActive': 'Make Active',
'npcs.hideFromPlayers': 'Hide from players',
'npcs.showToPlayers': 'Show to players',
'npcs.openInfo': 'Open information', 'npcs.openInfo': 'Open information',
'npcs.ringColor': 'TOKEN RING COLOR', 'npcs.ringColor': 'TOKEN RING COLOR',
'npcs.description': 'DESCRIPTION', 'npcs.description': 'DESCRIPTION',
@@ -1403,6 +1531,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.remoteTitle': 'CONTROL PANEL', 'control.remoteTitle': 'CONTROL PANEL',
'control.instruments': 'TOOLS', 'control.instruments': 'TOOLS',
'control.helpTool': 'Help',
'control.previewPanHint': 'Wheel — zoom; middle/right drag or Space+left drag — pan',
'control.descriptionTool': 'Description', 'control.descriptionTool': 'Description',
'control.markerTool': 'Marker', 'control.markerTool': 'Marker',
'control.markerColor': 'Marker color', 'control.markerColor': 'Marker color',
@@ -1423,6 +1553,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'dice.noResult': 'No roll yet', 'dice.noResult': 'No roll yet',
'dice.inc': 'More', 'dice.inc': 'More',
'dice.dec': 'Fewer', 'dice.dec': 'Fewer',
'dice.showAnimationToPlayers': 'Show animation to players',
'sessionTimer.duration': 'Duration (MM:SS)', 'sessionTimer.duration': 'Duration (MM:SS)',
'sessionTimer.start': 'Start', 'sessionTimer.start': 'Start',
@@ -1494,11 +1625,14 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.passed': 'Visited', 'control.passed': 'Visited',
'control.noActiveScene': 'No active scene.', 'control.noActiveScene': 'No active scene.',
'control.screenPreview': 'Screen preview', 'control.screenPreview': 'Screen preview',
'control.npcTokenScale': 'Play token size', 'control.npcTokenScale': 'Player token size',
'control.resizeToken': 'Resize', 'control.resizeToken': 'Resize',
'control.snapTokensToGrid': 'Snap tokens to grid', 'control.snapTokensToGrid': 'Snap tokens to grid',
'control.snapTokensToGridNoGrid': 'Grid is off on this scene — snap has no effect', 'control.snapTokensToGridNoGrid': 'Grid is off on this scene — snap has no effect',
'control.stopPresentation': 'Turn off', 'control.stopPresentation': 'End',
'control.stopConfirm.title': 'End presentation?',
'control.stopConfirm.body': 'Close the control panel and presentation and return to the editor?',
'control.stopConfirm.confirm': 'End',
'control.showPlayers': 'Show players', 'control.showPlayers': 'Show players',
'control.hidePlayers': 'Hide players', 'control.hidePlayers': 'Hide players',
'control.videoBrushHint': 'control.videoBrushHint':
@@ -1510,7 +1644,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.unnamed': 'Untitled', 'control.unnamed': 'Untitled',
'control.switchScene': 'Switch', 'control.switchScene': 'Switch',
'control.noBranches': 'No transitions available.', 'control.noBranches': 'No transitions available.',
'control.endPresentation': 'End presentation', 'control.endPresentation': 'End',
'control.music': 'Music', 'control.music': 'Music',
'control.sceneMusic': 'SCENE MUSIC', 'control.sceneMusic': 'SCENE MUSIC',
'control.gameMusic': 'GAME MUSIC', 'control.gameMusic': 'GAME MUSIC',
+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')) {
+35 -37
View File
@@ -38,13 +38,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';
@@ -422,8 +420,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;
@@ -638,17 +636,17 @@ export function SceneEditorApp() {
<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 +656,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 +677,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 +782,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}>
@@ -833,21 +831,21 @@ export function SceneEditorApp() {
{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 +854,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 +887,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 +900,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 +938,7 @@ export function SceneEditorApp() {
}); });
}} }}
> >
Очистить сцену {t('sceneEditor.clearScene')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -948,7 +946,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}
@@ -1278,13 +1276,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 +1292,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 +1305,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 +1343,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 +1367,7 @@ export function SceneEditorApp() {
setTokenCtxMenu(null); setTokenCtxMenu(null);
}} }}
> >
Сбросить на старт пути {t('sceneEditor.resetPath')}
</button> </button>
); );
})()} })()}
@@ -1423,7 +1421,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 +1439,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,
+7 -5
View File
@@ -107,7 +107,10 @@ export function PresentationView({
sceneNpcTokensSession.spawned, sceneNpcTokensSession.spawned,
session?.currentSceneId, session?.currentSceneId,
); );
const pathLivePoses = useTokenPathLivePoses({ const presentationNpcTokens = visibleNpcTokens.filter(
(token) => !sceneNpcTokensSession.byPlacementId[String(token.id)]?.hiddenFromPlayers,
);
useTokenPathLivePoses({
tokens: scene?.tokens ?? [], tokens: scene?.tokens ?? [],
npcTokens: visibleNpcTokens, npcTokens: visibleNpcTokens,
pathSession: tokenPathSession, pathSession: tokenPathSession,
@@ -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}
@@ -257,7 +260,6 @@ export function PresentationView({
library={appTokens} library={appTokens}
session={sceneTokensSession} session={sceneTokensSession}
viewport={contentRect} viewport={contentRect}
poseOverrides={pathLivePoses.tokenPoses}
/> />
) : null} ) : null}
{USERS_BRANCH_FEATURES_ENABLED && {USERS_BRANCH_FEATURES_ENABLED &&
@@ -269,7 +271,7 @@ export function PresentationView({
session={sceneNpcTokensSession} session={sceneNpcTokensSession}
viewport={contentRect} viewport={contentRect}
grid={scene.grid} grid={scene.grid}
poseOverrides={pathLivePoses.npcPoses} concealFromPlayers
/> />
) : null} ) : null}
{USERS_BRANCH_FEATURES_ENABLED && {USERS_BRANCH_FEATURES_ENABLED &&
@@ -340,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);
@@ -7,8 +7,9 @@
.token { .token {
position: absolute; position: absolute;
left: 0;
top: 0;
overflow: visible; overflow: visible;
transform: translate(-50%, -50%);
pointer-events: none; pointer-events: none;
touch-action: none; touch-action: none;
} }
@@ -34,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: '';
@@ -1,4 +1,4 @@
import React, { useRef, useState } from 'react'; import React, { useLayoutEffect, useRef, useState } from 'react';
import type { import type {
NpcDisposition, NpcDisposition,
@@ -16,6 +16,8 @@ import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid';
import { useAssetUrl } from '../useAssetImageUrl'; import { useAssetUrl } from '../useAssetImageUrl';
import { PlayerTokenView } from './PlayerTokenView'; import { PlayerTokenView } from './PlayerTokenView';
import { tokenPathKey } from '../../../shared/types/tokenPathSession';
import { registerPathTokenNode, writeTokenPathTransform } from '../tokens/tokenPathLiveDom';
import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast'; import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast';
import { import {
TokenInitiativeDecor, TokenInitiativeDecor,
@@ -49,6 +51,8 @@ function NpcSprite({
gridFit, gridFit,
disposition, disposition,
inactive, inactive,
hiddenFromPlayers,
concealed,
onMove, onMove,
onResize, onResize,
resizeTitle, resizeTitle,
@@ -69,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;
@@ -100,6 +106,8 @@ function NpcSprite({
onResize, onResize,
String(placement.id), String(placement.id),
); );
const elRef = useRef<HTMLDivElement>(null);
const pathKey = tokenPathKey('npcToken', String(placement.id));
const pos = localPos ?? { nx, ny }; const pos = localPos ?? { nx, ny };
const shownSizeN = localSize ?? sizeN; const shownSizeN = localSize ?? sizeN;
const minDim = Math.min(viewport.w, viewport.h); const minDim = Math.min(viewport.w, viewport.h);
@@ -107,6 +115,17 @@ function NpcSprite({
const canDrag = editable && dragEnabled && onMove !== undefined; const canDrag = editable && dragEnabled && onMove !== undefined;
const canResize = editable && dragEnabled && onResize !== undefined; const canResize = editable && dragEnabled && onResize !== undefined;
useLayoutEffect(() => {
registerPathTokenNode(pathKey, elRef.current, viewport, false);
return () => registerPathTokenNode(pathKey, null, viewport, false);
}, [pathKey, viewport]);
useLayoutEffect(() => {
const el = elRef.current;
if (!el || el.dataset.pathLive === '1') return;
writeTokenPathTransform(el, pos.nx, pos.ny, 0, viewport, false);
}, [pos.nx, pos.ny, viewport]);
const point = (e: React.PointerEvent, host?: HTMLElement | null) => { const point = (e: React.PointerEvent, host?: HTMLElement | null) => {
const rect = (host ?? e.currentTarget.parentElement)?.getBoundingClientRect(); const rect = (host ?? e.currentTarget.parentElement)?.getBoundingClientRect();
return { return {
@@ -133,18 +152,19 @@ function NpcSprite({
return ( return (
<div <div
ref={elRef}
className={[ className={[
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(' ')}
data-testid={`session-npc-token-${placement.id}`} data-testid={`session-npc-token-${placement.id}`}
data-initiative-active={initiativeActive ? '1' : '0'} data-initiative-active={initiativeActive ? '1' : '0'}
style={{ style={{
left: viewport.x + pos.nx * viewport.w,
top: viewport.y + pos.ny * viewport.h,
width: sizePx, width: sizePx,
height: sizePx, height: sizePx,
pointerEvents: canDrag || canResize || onContextMenu ? 'auto' : undefined, pointerEvents: canDrag || canResize || onContextMenu ? 'auto' : undefined,
@@ -260,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[];
@@ -275,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;
@@ -291,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
@@ -307,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>
@@ -8,6 +8,8 @@
.token { .token {
position: absolute; position: absolute;
left: 0;
top: 0;
border-radius: 8px; border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.25); border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.2);
@@ -1,9 +1,11 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { AppToken, SceneToken, SceneTokensSessionState } from '../../../shared/types'; import type { AppToken, SceneToken, SceneTokensSessionState } from '../../../shared/types';
import { clampSceneTokenSizeN } from '../../../shared/types/appTokens'; import { clampSceneTokenSizeN } from '../../../shared/types/appTokens';
import { tokenPathKey } from '../../../shared/types/tokenPathSession';
import { useLiveResizeBroadcast } from '../playerToken/useLiveDragBroadcast'; import { useLiveResizeBroadcast } from '../playerToken/useLiveDragBroadcast';
import { registerPathTokenNode, writeTokenPathTransform } from './tokenPathLiveDom';
import { useTokenImageUrl } from './useTokenImageUrl'; import { useTokenImageUrl } from './useTokenImageUrl';
import styles from './SceneTokensOverlay.module.css'; import styles from './SceneTokensOverlay.module.css';
@@ -106,18 +108,29 @@ function TokenSprite({
}; };
}, []); }, []);
const elRef = useRef<HTMLDivElement>(null);
const posNx = localPos?.nx ?? nx; const posNx = localPos?.nx ?? nx;
const posNy = localPos?.ny ?? ny; const posNy = localPos?.ny ?? ny;
const shownSizeN = localSize ?? sizeN; const shownSizeN = localSize ?? sizeN;
const pathKey = tokenPathKey('token', String(placement.id));
const minDim = Math.min(viewport.w, viewport.h); const minDim = Math.min(viewport.w, viewport.h);
const sizePx = Math.max(16, shownSizeN * minDim); const sizePx = Math.max(16, shownSizeN * minDim);
const left = viewport.x + posNx * viewport.w;
const top = viewport.y + posNy * viewport.h;
const canDrag = editable && dragEnabled && Boolean(onMove); const canDrag = editable && dragEnabled && Boolean(onMove);
const canResize = editable && dragEnabled && Boolean(onResize); const canResize = editable && dragEnabled && Boolean(onResize);
const interactive = canDrag || canResize || Boolean(onContextMenu); const interactive = canDrag || canResize || Boolean(onContextMenu);
useLayoutEffect(() => {
registerPathTokenNode(pathKey, elRef.current, viewport, true);
return () => registerPathTokenNode(pathKey, null, viewport, true);
}, [pathKey, viewport]);
useLayoutEffect(() => {
const el = elRef.current;
if (!el || el.dataset.pathLive === '1') return;
writeTokenPathTransform(el, posNx, posNy, rotationDeg, viewport, true);
}, [posNx, posNy, rotationDeg, viewport]);
const hostToNorm = (clientX: number, clientY: number, host: HTMLElement) => { const hostToNorm = (clientX: number, clientY: number, host: HTMLElement) => {
const r = host.getBoundingClientRect(); const r = host.getBoundingClientRect();
const w = Math.max(1e-6, viewport.w); const w = Math.max(1e-6, viewport.w);
@@ -160,6 +173,7 @@ function TokenSprite({
return ( return (
<div <div
ref={elRef}
className={[ className={[
styles.token, styles.token,
canDrag ? styles.tokenEditable : '', canDrag ? styles.tokenEditable : '',
@@ -168,11 +182,8 @@ function TokenSprite({
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
style={{ style={{
left,
top,
width: sizePx, width: sizePx,
height: sizePx, height: sizePx,
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
}} }}
onContextMenu={ onContextMenu={
onContextMenu onContextMenu
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { writeTokenPathTransform } from './tokenPathLiveDom';
void test('writeTokenPathTransform: GPU-friendly translate3d, not left/top', () => {
const el = { style: { transform: '' } } as unknown as HTMLElement;
writeTokenPathTransform(el, 0.25, 0.5, 45, { x: 10, y: 20, w: 200, h: 100 }, true);
assert.equal(el.style.transform, 'translate3d(60px, 70px, 0) translate(-50%, -50%) rotate(45deg)');
});
void test('writeTokenPathTransform: NPC without rotate keeps centering only', () => {
const el = { style: { transform: '' } } as unknown as HTMLElement;
writeTokenPathTransform(el, 0, 0, 90, { x: 0, y: 0, w: 100, h: 100 }, false);
assert.equal(el.style.transform, 'translate3d(0px, 0px, 0) translate(-50%, -50%)');
});
@@ -0,0 +1,65 @@
/** Direct DOM transforms for path playback — avoids React re-renders on every RAF tick. */
export type TokenPathViewport = { x: number; y: number; w: number; h: number };
type NodeReg = {
el: HTMLElement;
viewport: TokenPathViewport;
rotate: boolean;
};
const registry = new Map<string, NodeReg>();
export function writeTokenPathTransform(
el: HTMLElement,
nx: number,
ny: number,
rotationDeg: number,
viewport: TokenPathViewport,
rotate: boolean,
): void {
const x = viewport.x + nx * viewport.w;
const y = viewport.y + ny * viewport.h;
const rot = rotate ? ` rotate(${String(rotationDeg)}deg)` : '';
el.style.transform = `translate3d(${String(x)}px, ${String(y)}px, 0) translate(-50%, -50%)${rot}`;
}
export function registerPathTokenNode(
key: string,
el: HTMLElement | null,
viewport: TokenPathViewport,
rotate: boolean,
): void {
if (!el) {
registry.delete(key);
return;
}
registry.set(key, { el, viewport, rotate });
}
export function applyLivePathPose(
key: string,
nx: number,
ny: number,
rotationDeg: number,
animating: boolean,
): void {
const n = registry.get(key);
if (!n) return;
n.el.dataset.pathLive = '1';
n.el.style.willChange = animating ? 'transform' : '';
writeTokenPathTransform(n.el, nx, ny, rotationDeg, n.viewport, n.rotate);
}
export function endLivePathPose(key: string): void {
const n = registry.get(key);
if (!n) return;
delete n.el.dataset.pathLive;
n.el.style.willChange = '';
}
export function endAllLivePathPoses(): void {
for (const key of [...registry.keys()]) {
endLivePathPose(key);
}
}
@@ -1,58 +1,17 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef } from 'react';
import { computePathPlaybackSample } from '../../../shared/types/tokenPathPlayback'; import { computePathPlaybackSample } from '../../../shared/types/tokenPathPlayback';
import { import {
tokenPathKey, tokenPathKey,
type TokenPathPlaybackPhase,
type TokenPathSessionState, type TokenPathSessionState,
} from '../../../shared/types/tokenPathSession'; } from '../../../shared/types/tokenPathSession';
import type { SceneNpcToken, SceneToken } from '../../../shared/types'; import type { SceneNpcToken, SceneToken } from '../../../shared/types';
export type TokenPathLivePose = { import { applyLivePathPose, endAllLivePathPoses, endLivePathPose } from './tokenPathLiveDom';
nx: number;
ny: number;
rotationDeg: number;
phase: TokenPathPlaybackPhase;
dist: number;
};
type PoseMaps = {
tokenPoses: Record<string, TokenPathLivePose>;
npcPoses: Record<string, TokenPathLivePose>;
};
const EMPTY: PoseMaps = { tokenPoses: {}, npcPoses: {} };
function posesEqual(a: PoseMaps, b: PoseMaps): boolean {
const aT = a.tokenPoses;
const bT = b.tokenPoses;
const aN = a.npcPoses;
const bN = b.npcPoses;
const aTk = Object.keys(aT);
const bTk = Object.keys(bT);
const aNk = Object.keys(aN);
const bNk = Object.keys(bN);
if (aTk.length !== bTk.length || aNk.length !== bNk.length) return false;
for (const k of aTk) {
const x = aT[k];
const y = bT[k];
if (!y || x!.nx !== y.nx || x!.ny !== y.ny || x!.rotationDeg !== y.rotationDeg || x!.phase !== y.phase) {
return false;
}
}
for (const k of aNk) {
const x = aN[k];
const y = bN[k];
if (!y || x!.nx !== y.nx || x!.ny !== y.ny || x!.rotationDeg !== y.rotationDeg || x!.phase !== y.phase) {
return false;
}
}
return true;
}
/** /**
* RAF poses for control/presentation while path playback is active. * RAF poses for control/presentation while path playback is active.
* When phase === 'stopped', pose is omitted so session/placement (drag) wins. * Writes transforms on registered token DOM nodes — no React setState per frame.
* *
* Uses local Date.now() — segmentStartedAtMs is also wall-clock from main (same machine). * Uses local Date.now() — segmentStartedAtMs is also wall-clock from main (same machine).
* Do NOT clamp to stale serverNowMs: that froze motion after ~250ms until the next IPC bump. * Do NOT clamp to stale serverNowMs: that froze motion after ~250ms until the next IPC bump.
@@ -63,10 +22,10 @@ export function useTokenPathLivePoses(args: {
pathSession: TokenPathSessionState | null; pathSession: TokenPathSessionState | null;
enabled?: boolean; enabled?: boolean;
onMarkDone?: (kind: 'token' | 'npcToken', placementId: string, atDist: number) => void; onMarkDone?: (kind: 'token' | 'npcToken', placementId: string, atDist: number) => void;
}): PoseMaps { }): void {
const { tokens, npcTokens, pathSession, enabled = true, onMarkDone } = args; const { tokens, npcTokens, pathSession, enabled = true, onMarkDone } = args;
const [poses, setPoses] = useState<PoseMaps>(EMPTY);
const markedDoneRef = useRef<Set<string>>(new Set()); const markedDoneRef = useRef<Set<string>>(new Set());
const liveKeysRef = useRef<Set<string>>(new Set());
const onMarkDoneRef = useRef(onMarkDone); const onMarkDoneRef = useRef(onMarkDone);
onMarkDoneRef.current = onMarkDone; onMarkDoneRef.current = onMarkDone;
@@ -83,7 +42,8 @@ export function useTokenPathLivePoses(args: {
useEffect(() => { useEffect(() => {
if (!enabled) { if (!enabled) {
setPoses(EMPTY); endAllLivePathPoses();
liveKeysRef.current.clear();
return; return;
} }
let raf = 0; let raf = 0;
@@ -92,22 +52,14 @@ export function useTokenPathLivePoses(args: {
const tick = () => { const tick = () => {
if (!alive) return; if (!alive) return;
const session = pathSessionRef.current; const session = pathSessionRef.current;
if (!session) { const nextKeys = new Set<string>();
setPoses((prev) => (prev === EMPTY || Object.keys(prev.tokenPoses).length + Object.keys(prev.npcPoses).length === 0 ? prev : EMPTY)); if (session) {
raf = requestAnimationFrame(tick);
return;
}
// Wall clock: matches main's Date.now() for segmentStartedAtMs (Electron, one host).
const sampleNow = Date.now(); const sampleNow = Date.now();
const tokenPoses: Record<string, TokenPathLivePose> = {};
const npcPoses: Record<string, TokenPathLivePose> = {};
const sampleOne = ( const sampleOne = (
kind: 'token' | 'npcToken', kind: 'token' | 'npcToken',
placementId: string, placementId: string,
path: NonNullable<SceneToken['path']>, path: NonNullable<SceneToken['path']>,
out: Record<string, TokenPathLivePose>,
) => { ) => {
const key = tokenPathKey(kind, placementId); const key = tokenPathKey(kind, placementId);
const entry = session.playback[key]; const entry = session.playback[key];
@@ -115,13 +67,14 @@ export function useTokenPathLivePoses(args: {
if (entry.phase === 'stopped') return; if (entry.phase === 'stopped') return;
const result = computePathPlaybackSample({ path, entry, nowMs: sampleNow }); const result = computePathPlaybackSample({ path, entry, nowMs: sampleNow });
if (!result) return; if (!result) return;
out[placementId] = { nextKeys.add(key);
nx: result.sample.nx, applyLivePathPose(
ny: result.sample.ny, key,
rotationDeg: result.sample.rotationDeg, result.sample.nx,
phase: result.phase, result.sample.ny,
dist: result.sample.dist, result.sample.rotationDeg,
}; entry.phase === 'moving' || entry.phase === 'delay',
);
if (result.markDone && !markedDoneRef.current.has(key)) { if (result.markDone && !markedDoneRef.current.has(key)) {
markedDoneRef.current.add(key); markedDoneRef.current.add(key);
onMarkDoneRef.current?.(kind, placementId, result.sample.dist); onMarkDoneRef.current?.(kind, placementId, result.sample.dist);
@@ -130,17 +83,20 @@ export function useTokenPathLivePoses(args: {
for (const t of tokensRef.current) { for (const t of tokensRef.current) {
if (t.path && t.path.points.length >= 2) { if (t.path && t.path.points.length >= 2) {
sampleOne('token', String(t.id), t.path, tokenPoses); sampleOne('token', String(t.id), t.path);
} }
} }
for (const t of npcTokensRef.current) { for (const t of npcTokensRef.current) {
if (t.path && t.path.points.length >= 2) { if (t.path && t.path.points.length >= 2) {
sampleOne('npcToken', String(t.id), t.path, npcPoses); sampleOne('npcToken', String(t.id), t.path);
}
} }
} }
const next: PoseMaps = { tokenPoses, npcPoses }; for (const key of liveKeysRef.current) {
setPoses((prev) => (posesEqual(prev, next) ? prev : next)); if (!nextKeys.has(key)) endLivePathPose(key);
}
liveKeysRef.current = nextKeys;
raf = requestAnimationFrame(tick); raf = requestAnimationFrame(tick);
}; };
@@ -148,8 +104,10 @@ export function useTokenPathLivePoses(args: {
return () => { return () => {
alive = false; alive = false;
cancelAnimationFrame(raf); cancelAnimationFrame(raf);
for (const key of liveKeysRef.current) {
endLivePathPose(key);
}
liveKeysRef.current.clear();
}; };
}, [enabled]); }, [enabled]);
return poses;
} }
@@ -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;
}
@@ -11,6 +11,8 @@ import {
asProjectId, asProjectId,
asSceneId, asSceneId,
asSceneNpcTokenId, asSceneNpcTokenId,
asSceneTokenId,
asTokenId,
} from '../types/ids'; } from '../types/ids';
import { PROJECT_SCHEMA_VERSION } from '../types'; import { PROJECT_SCHEMA_VERSION } from '../types';
@@ -21,8 +23,10 @@ import {
findSceneTitleConflicts, findSceneTitleConflicts,
listExportedNpcsFromBundle, listExportedNpcsFromBundle,
listImportableStorylines, listImportableStorylines,
collectTokenIdsFromProject,
mergeStorylinesIntoProject, mergeStorylinesIntoProject,
newExportBundleProjectId, newExportBundleProjectId,
remapProjectSceneTokenIds,
selectNpcsByIds, selectNpcsByIds,
} from './storylineExportImport'; } from './storylineExportImport';
@@ -550,3 +554,48 @@ void test('mergeStorylinesIntoProject: reuse existing scene by resolution', () =
assert.equal(merged.sceneGraphNodes.length, 1); assert.equal(merged.sceneGraphNodes.length, 1);
assert.equal(merged.sceneGraphNodes[0]!.sceneId, targetSceneId); assert.equal(merged.sceneGraphNodes[0]!.sceneId, targetSceneId);
}); });
void test('collectTokenIdsFromProject: собирает id неигровых токенов со сцен', () => {
const s1 = scene('s1', 'Start');
s1.tokens = [
{
id: asSceneTokenId('st1'),
tokenId: asTokenId('tok_a'),
nx: 0.2,
ny: 0.3,
sizeN: 0.08,
rotationDeg: 0,
},
{
id: asSceneTokenId('st2'),
tokenId: asTokenId('tok_b'),
nx: 0.5,
ny: 0.5,
sizeN: 0.08,
rotationDeg: 0,
},
];
const project = minimalProject({
scenes: { [asSceneId('s1')]: s1 },
sceneGraphNodes: [node('main', 's1', { isStartScene: true })],
});
const ids = collectTokenIdsFromProject(project).map(String).sort();
assert.deepEqual(ids, ['tok_a', 'tok_b']);
});
void test('remapProjectSceneTokenIds: переписывает tokenId на сценах', () => {
const s1 = scene('s1', 'Start');
s1.tokens = [
{
id: asSceneTokenId('st1'),
tokenId: asTokenId('old'),
nx: 0.2,
ny: 0.3,
sizeN: 0.08,
rotationDeg: 0,
},
];
const project = minimalProject({ scenes: { [asSceneId('s1')]: s1 } });
const remapped = remapProjectSceneTokenIds(project, new Map([['old', 'new']]));
assert.equal(String(remapped.scenes[asSceneId('s1')]!.tokens[0]!.tokenId), 'new');
});
+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\[\]/);
+66
View File
@@ -76,11 +76,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',
@@ -158,6 +169,8 @@ export const ipcChannels = {
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 +178,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 +300,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 +432,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 +453,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;
@@ -764,6 +794,10 @@ export type IpcInvokeMap = {
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 +826,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 +1094,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;
+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»). */
@@ -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 / крестик) скрывает виджет с презентации и предпросмотра и ставит отсчёт на паузу, если он шёл.
--- ---
Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

+32
View File
@@ -0,0 +1,32 @@
# ВК: успешный краудфандинг НРИ Плеер
Обложка: `docs/vk-covers/vk-cover-crowdfunding-success.png` (16:9, без текста).
---
## Черновик поста
Мастера, коротко и по делу.
Кампания НРИ Плеера на CrowdRepublic закрыта — успешно. Цель набрана. Спасибо каждому, кто поддержал сбор: взносом, репостом, советом за столом или просто тем, что оставались рядом, пока мы собирали редактор, пульт и экран для игроков в один инструмент.
Для нас это не «галочка в отчёте». Это знак, что приложение нужно живому сообществу — тем, кто ведёт партии по выходным, готовит сцены ночами и устал жонглировать десятком вкладок вместо одного стола.
Дальше — спокойная работа: доводим обещанное, слушаем вас и выпускаем обновления без лишнего шума. О следующих шагах расскажем здесь отдельно.
Ещё раз спасибо. Без вас этого бы не было.
Скачать бесплатно: https://ttrpgplayer.ru
Windows · macOS · Linux
#НРИплеер #TTRPG #DnD #настолки #мастер #краудфандинг #CrowdRepublic
---
## Короткая версия
Мастера, крауд НРИ Плеера на CrowdRepublic закрыт успешно — цель набрана. Спасибо всем, кто поддержал. Дальше работаем и выпускаем обновления.
https://ttrpgplayer.ru
#НРИплеер #TTRPG #мастер #краудфандинг
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "TTRPGPlayer", "name": "TTRPGPlayer",
"version": "1.0.29", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "TTRPGPlayer", "name": "TTRPGPlayer",
"version": "1.0.29", "version": "1.1.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "TTRPGPlayer", "name": "TTRPGPlayer",
"version": "1.0.29", "version": "1.1.0",
"description": "TTRPG Player — редактор и проигрыватель НРИ", "description": "TTRPG Player — редактор и проигрыватель НРИ",
"main": "dist/main/index.cjs", "main": "dist/main/index.cjs",
"scripts": { "scripts": {
@@ -10,7 +10,7 @@
"build:obfuscate": "node scripts/build.mjs --production --obfuscate", "build:obfuscate": "node scripts/build.mjs --production --obfuscate",
"lint": "eslint . --max-warnings 0", "lint": "eslint . --max-warnings 0",
"typecheck": "tsc -p tsconfig.eslint.json --noEmit", "typecheck": "tsc -p tsconfig.eslint.json --noEmit",
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/diceRoll/diceRollSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/sceneAudioRef.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs", "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/shared/fieldEffectStampPoints.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/presentation/PresentationApp.escapeConfirm.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/diceRoll/diceRollSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/ensureParentDir.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/sceneAudioRef.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/gameSystems/gameSystemsStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts app/renderer/shared/tokens/tokenPathLiveDom.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs",
"format": "prettier . --check", "format": "prettier . --check",
"format:write": "prettier . --write", "format:write": "prettier . --write",
"postinstall": "patch-package", "postinstall": "patch-package",
+1
View File
@@ -63,6 +63,7 @@ export default defineConfig(({ mode }) => {
tokenPathEditor: path.resolve(__dirname, 'app/renderer/tokenPathEditor.html'), tokenPathEditor: path.resolve(__dirname, 'app/renderer/tokenPathEditor.html'),
npcsEditor: path.resolve(__dirname, 'app/renderer/npcsEditor.html'), npcsEditor: path.resolve(__dirname, 'app/renderer/npcsEditor.html'),
npcs: path.resolve(__dirname, 'app/renderer/npcs.html'), npcs: path.resolve(__dirname, 'app/renderer/npcs.html'),
instructions: path.resolve(__dirname, 'app/renderer/instructions.html'),
}, },
}, },
}, },