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