Compare commits
6 Commits
4456eb0277
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ab6ffd593 | |||
| 46bec1a86a | |||
| 7362a36fe5 | |||
| 1d94c95cc8 | |||
| 1fbaaa6e77 | |||
| 8d5a68c71e |
+107
-1
@@ -20,6 +20,10 @@ import {
|
||||
} from '../shared/project/projectZipExtension';
|
||||
import type { Project } from '../shared/types';
|
||||
import { asNpcId } from '../shared/types/ids';
|
||||
import {
|
||||
asPreviewRotationDeg,
|
||||
previewRotationStepsCw,
|
||||
} from '../shared/types/scenePreviewRotation';
|
||||
|
||||
import { EffectsStore, effectsDefaultTool } from './effects/effectsStore';
|
||||
import { SceneDarknessStore } from './effects/sceneDarknessStore';
|
||||
@@ -55,17 +59,20 @@ import {
|
||||
focusEditorWindow,
|
||||
getPresentationContentSize,
|
||||
getSceneDescriptionContent,
|
||||
getTokenPathEditorTarget,
|
||||
isMultiWindowOpen,
|
||||
markAppQuitting,
|
||||
openMaterialsWindow,
|
||||
openMultiWindow,
|
||||
openNpcsEditorWindow,
|
||||
openSceneEditorWindow,
|
||||
openTokenPathEditorWindow,
|
||||
openNpcsWindow,
|
||||
openSceneDescriptionWindow,
|
||||
closeMaterialsWindow,
|
||||
closeNpcsEditorWindow,
|
||||
closeSceneEditorWindow,
|
||||
closeTokenPathEditorWindow,
|
||||
closeNpcsWindow,
|
||||
sendToAppWindows,
|
||||
syncAllWindowChromeTitles,
|
||||
@@ -73,6 +80,9 @@ import {
|
||||
waitForEditorWindowReady,
|
||||
warmNpcsEditorWindow,
|
||||
} from './windows/createWindows';
|
||||
import { TokenPathSessionStore } from './tokens/tokenPathSessionStore';
|
||||
import { tokenPathTotalLength } from '../shared/types/tokenPath';
|
||||
import type { TokenPathTargetKind } from '../shared/types/tokenPathSession';
|
||||
|
||||
function emitZipProgress(evt: {
|
||||
kind: 'import' | 'export';
|
||||
@@ -156,12 +166,59 @@ const videoStore = new VideoPlaybackStore();
|
||||
const materialsOverlayStore = new MaterialsOverlayStore();
|
||||
const npcsOverlayStore = new NpcsOverlayStore();
|
||||
const sceneTokensSessionStore = new SceneTokensSessionStore();
|
||||
const tokenPathSessionStore = new TokenPathSessionStore();
|
||||
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
|
||||
const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore();
|
||||
const tokenGridSnapSessionStore = new TokenGridSnapSessionStore();
|
||||
let tokensStore: TokensStore | null = null;
|
||||
let playersStore: PlayersStore | null = null;
|
||||
|
||||
function emitTokenPathSessionState(): void {
|
||||
const state = tokenPathSessionStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(ipcChannels.tokenPathSession.stateChanged, { state });
|
||||
}
|
||||
}
|
||||
|
||||
function seedTokenPathPlaybackForProject(project: Project | null): void {
|
||||
tokenPathSessionStore.reset();
|
||||
if (!project?.currentSceneId) {
|
||||
emitTokenPathSessionState();
|
||||
return;
|
||||
}
|
||||
const scene = project.scenes[project.currentSceneId];
|
||||
if (!scene) {
|
||||
emitTokenPathSessionState();
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const seedOne = (kind: TokenPathTargetKind, placementId: string, path: NonNullable<(typeof scene.tokens)[number]['path']>) => {
|
||||
const pathLength = tokenPathTotalLength(path);
|
||||
if (pathLength <= 1e-9) return;
|
||||
tokenPathSessionStore.dispatch({
|
||||
kind: 'seedPlayback',
|
||||
entry: {
|
||||
kind,
|
||||
placementId,
|
||||
phase: path.startMode === 'delayed' ? 'delay' : 'moving',
|
||||
baseDist: 0,
|
||||
direction: 1,
|
||||
rejoinDist: null,
|
||||
durationSec: path.durationSec,
|
||||
pathLength,
|
||||
segmentStartedAtMs: now,
|
||||
},
|
||||
});
|
||||
};
|
||||
for (const t of scene.tokens ?? []) {
|
||||
if (t.path && t.path.points.length >= 2) seedOne('token', String(t.id), t.path);
|
||||
}
|
||||
for (const t of scene.npcTokens ?? []) {
|
||||
if (t.path && t.path.points.length >= 2) seedOne('npcToken', String(t.id), t.path);
|
||||
}
|
||||
emitTokenPathSessionState();
|
||||
}
|
||||
|
||||
function emitEffectsState(): void {
|
||||
const state = effectsStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
@@ -267,7 +324,9 @@ function emitScenePlayerTokensSessionState(): void {
|
||||
function syncSceneDarknessForProject(project: Project): void {
|
||||
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
|
||||
const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined;
|
||||
const enabled = Boolean(scene?.darkenScene) && scene?.previewAssetType === 'image';
|
||||
const enabled =
|
||||
Boolean(scene?.darkenScene) &&
|
||||
(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video');
|
||||
sceneDarknessStore.switchScene(cacheKey, enabled);
|
||||
}
|
||||
|
||||
@@ -440,6 +499,7 @@ async function main() {
|
||||
sceneDarknessStore.resetSession();
|
||||
sceneTrapsStore.resetSession();
|
||||
sceneTokensSessionStore.reset();
|
||||
tokenPathSessionStore.reset();
|
||||
sceneNpcTokensSessionStore.reset();
|
||||
scenePlayerTokensSessionStore.reset();
|
||||
tokenGridSnapSessionStore.reset();
|
||||
@@ -455,6 +515,9 @@ async function main() {
|
||||
if (project) {
|
||||
syncSceneDarknessForProject(project);
|
||||
syncSceneTrapsForProject(project);
|
||||
seedTokenPathPlaybackForProject(project);
|
||||
} else {
|
||||
emitTokenPathSessionState();
|
||||
}
|
||||
emitSceneDarknessState();
|
||||
emitSceneTrapsState();
|
||||
@@ -521,6 +584,15 @@ async function main() {
|
||||
closeSceneEditorWindow();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.openTokenPathEditor, ({ kind, placementId }) => {
|
||||
openTokenPathEditorWindow(kind, placementId);
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.closeTokenPathEditor, () => {
|
||||
closeTokenPathEditorWindow();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.getTokenPathEditorTarget, () => getTokenPathEditorTarget());
|
||||
registerHandler(ipcChannels.windows.openNpcs, (req) => {
|
||||
openNpcsWindow();
|
||||
const npcId = req?.npcId ? asNpcId(String(req.npcId)) : null;
|
||||
@@ -637,6 +709,7 @@ async function main() {
|
||||
emitSceneViewState();
|
||||
emitSceneTokensSessionState();
|
||||
emitScenePlayerTokensSessionState();
|
||||
seedTokenPathPlaybackForProject(project);
|
||||
emitSessionState();
|
||||
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
|
||||
});
|
||||
@@ -668,6 +741,7 @@ async function main() {
|
||||
emitSceneViewState();
|
||||
emitSceneTokensSessionState();
|
||||
emitScenePlayerTokensSessionState();
|
||||
seedTokenPathPlaybackForProject(project);
|
||||
emitSessionState();
|
||||
const p = projectStore.getOpenProject();
|
||||
return {
|
||||
@@ -676,6 +750,7 @@ async function main() {
|
||||
};
|
||||
});
|
||||
registerHandler(ipcChannels.project.updateScene, async ({ sceneId, patch }) => {
|
||||
const before = projectStore.getOpenProject()?.scenes[sceneId];
|
||||
const next = await projectStore.updateScene(sceneId, patch);
|
||||
const project = projectStore.getOpenProject();
|
||||
if (project?.currentSceneId === sceneId && patch.darkenScene !== undefined) {
|
||||
@@ -686,6 +761,29 @@ async function main() {
|
||||
syncSceneTrapsForProject(project);
|
||||
emitSceneTrapsState();
|
||||
}
|
||||
if (
|
||||
project?.currentSceneId === sceneId &&
|
||||
patch.previewRotationDeg !== undefined &&
|
||||
before
|
||||
) {
|
||||
const steps = previewRotationStepsCw(
|
||||
asPreviewRotationDeg(before.previewRotationDeg),
|
||||
asPreviewRotationDeg(patch.previewRotationDeg),
|
||||
);
|
||||
if (steps !== 0) {
|
||||
sceneTokensSessionStore.rotateMapCwSteps(steps);
|
||||
sceneNpcTokensSessionStore.rotateMapCwSteps(steps);
|
||||
scenePlayerTokensSessionStore.rotateMapCwSteps(steps);
|
||||
emitSceneTokensSessionState();
|
||||
emitSceneNpcTokensSessionState();
|
||||
emitScenePlayerTokensSessionState();
|
||||
}
|
||||
}
|
||||
if (project && project.currentSceneId === sceneId && patch.previewRotationDeg !== undefined) {
|
||||
// Trap ids stay the same; runtime statuses remain valid after coordinate remap in project.
|
||||
syncSceneTrapsForProject(project);
|
||||
emitSceneTrapsState();
|
||||
}
|
||||
emitSessionState();
|
||||
return { scene: next };
|
||||
});
|
||||
@@ -1381,6 +1479,14 @@ async function main() {
|
||||
emitSceneTokensSessionState();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.tokenPathSession.getState, () => {
|
||||
return { state: tokenPathSessionStore.getState() };
|
||||
});
|
||||
registerHandler(ipcChannels.tokenPathSession.dispatch, ({ event }) => {
|
||||
tokenPathSessionStore.dispatch(event);
|
||||
emitTokenPathSessionState();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
registerHandler(ipcChannels.tokenGridSnap.getState, () => tokenGridSnapSessionStore.getState());
|
||||
registerHandler(ipcChannels.tokenGridSnap.setEnabled, ({ enabled }) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type SceneNpcTokensSessionState,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
|
||||
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
|
||||
|
||||
function emptyState(revision = 1): SceneNpcTokensSessionState {
|
||||
return {
|
||||
@@ -63,6 +64,34 @@ export class SceneNpcTokensSessionStore {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Rotate session position overrides with the scene preview (CSS rotate steps). */
|
||||
rotateMapCwSteps(steps: number): SceneNpcTokensSessionState {
|
||||
const n = ((steps % 4) + 4) % 4;
|
||||
if (n === 0) return this.state;
|
||||
const ids = Object.keys(this.state.byPlacementId);
|
||||
if (ids.length === 0) return this.state;
|
||||
let changed = false;
|
||||
const byPlacementId: SceneNpcTokensSessionState['byPlacementId'] = {};
|
||||
for (const id of ids) {
|
||||
const prev = this.state.byPlacementId[id];
|
||||
if (!prev) continue;
|
||||
if (typeof prev.nx === 'number' && typeof prev.ny === 'number') {
|
||||
const p = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
|
||||
byPlacementId[id] = { ...prev, nx: p.nx, ny: p.ny };
|
||||
changed = true;
|
||||
} else {
|
||||
byPlacementId[id] = prev;
|
||||
}
|
||||
}
|
||||
if (!changed) return this.state;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId,
|
||||
scale: this.state.scale,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: SceneNpcTokensSessionEvent): SceneNpcTokensSessionState {
|
||||
switch (event.kind) {
|
||||
case 'clear':
|
||||
|
||||
@@ -37,3 +37,13 @@ void test('ScenePlayerTokensSessionStore move and reset', () => {
|
||||
assert.equal(s2.visible, false);
|
||||
assert.deepEqual(s2.byPlayerId, {});
|
||||
});
|
||||
|
||||
void test('ScenePlayerTokensSessionStore rotateMapCwSteps', () => {
|
||||
const store = new ScenePlayerTokensSessionStore();
|
||||
store.dispatch({ kind: 'setSelection', playerIds: ['p1'] });
|
||||
store.dispatch({ kind: 'show', sizeN: 0.1 });
|
||||
store.dispatch({ kind: 'move', playerId: 'p1', nx: 0.2, ny: 0.1 });
|
||||
const s = store.rotateMapCwSteps(1);
|
||||
assert.equal(s.byPlayerId.p1?.nx, 0.9);
|
||||
assert.equal(s.byPlayerId.p1?.ny, 0.2);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type ScenePlayerTokensSessionEvent,
|
||||
type ScenePlayerTokensSessionState,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
|
||||
|
||||
function emptyState(revision = 1): ScenePlayerTokensSessionState {
|
||||
return {
|
||||
@@ -60,6 +61,27 @@ export class ScenePlayerTokensSessionStore {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Rotate live player tokens with the scene preview (CSS rotate steps). */
|
||||
rotateMapCwSteps(steps: number): ScenePlayerTokensSessionState {
|
||||
const n = ((steps % 4) + 4) % 4;
|
||||
if (n === 0) return this.state;
|
||||
const ids = Object.keys(this.state.byPlayerId);
|
||||
if (ids.length === 0) return this.state;
|
||||
const byPlayerId: ScenePlayerTokensSessionState['byPlayerId'] = {};
|
||||
for (const id of ids) {
|
||||
const prev = this.state.byPlayerId[id];
|
||||
if (!prev) continue;
|
||||
const p = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
|
||||
byPlayerId[id] = { ...prev, nx: p.nx, ny: p.ny };
|
||||
}
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
byPlayerId,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: ScenePlayerTokensSessionEvent): ScenePlayerTokensSessionState {
|
||||
switch (event.kind) {
|
||||
case 'clear':
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Visually lossless re-encode for imported raster images (same pixel dimensions).
|
||||
* Node-only; shared by the main app and ../project-converter (monorepo sibling).
|
||||
*/
|
||||
import sharp from 'sharp';
|
||||
import { getSharp } from './sharpRuntime.mjs';
|
||||
|
||||
/** @typedef {import('node:buffer').Buffer} Buffer */
|
||||
|
||||
@@ -102,6 +102,7 @@ function makePassthrough(buf, meta) {
|
||||
* @param {number} h0
|
||||
*/
|
||||
async function sameDimensionsOrThrow(outBuf, w0, h0) {
|
||||
const sharp = getSharp();
|
||||
const m = await sharp(outBuf).metadata();
|
||||
if ((m.width ?? 0) !== w0 || (m.height ?? 0) !== h0) {
|
||||
const err = new Error('encode changed dimensions');
|
||||
@@ -120,6 +121,13 @@ export async function optimizeImageBufferVisuallyLossless(src) {
|
||||
return makePassthrough(input, { width: 0, height: 0, format: 'png' });
|
||||
}
|
||||
|
||||
let sharp;
|
||||
try {
|
||||
sharp = getSharp();
|
||||
} catch {
|
||||
return makePassthrough(input, null);
|
||||
}
|
||||
|
||||
let meta0;
|
||||
try {
|
||||
meta0 = await sharp(input, { failOn: 'error', unlimited: true }).metadata();
|
||||
|
||||
@@ -5,7 +5,8 @@ import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import ffmpegStatic from 'ffmpeg-static';
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { getSharp } from './sharpRuntime.mjs';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -21,6 +22,7 @@ export async function generateScenePreviewThumbnailBytes(
|
||||
kind: 'image' | 'video',
|
||||
): Promise<Buffer | null> {
|
||||
try {
|
||||
const sharp = getSharp();
|
||||
if (kind === 'image') {
|
||||
return await sharp(source)
|
||||
.rotate()
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Lazy `sharp` load so a corrupt/missing native install does not crash Electron at import time.
|
||||
* Call only from image-processing paths; errors are recoverable for the rest of the app.
|
||||
*
|
||||
* Note: main is bundled to CJS (esbuild). `import.meta.url` is empty there — prefer `__filename`.
|
||||
*/
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
function requireBaseFilename() {
|
||||
// CJS bundle / Electron main
|
||||
if (typeof __filename === 'string' && __filename.length > 0) {
|
||||
return __filename;
|
||||
}
|
||||
// Direct ESM (unit tests)
|
||||
const metaUrl = import.meta.url;
|
||||
if (typeof metaUrl === 'string' && metaUrl.startsWith('file:')) {
|
||||
return fileURLToPath(metaUrl);
|
||||
}
|
||||
return path.join(process.cwd(), 'package.json');
|
||||
}
|
||||
|
||||
const require = createRequire(requireBaseFilename());
|
||||
|
||||
/** @type {typeof import('sharp') | null} */
|
||||
let cached = null;
|
||||
/** @type {Error | null} */
|
||||
let loadError = null;
|
||||
|
||||
/**
|
||||
* @param {unknown} err
|
||||
* @returns {Error}
|
||||
*/
|
||||
export function sharpLoadFailure(err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
return new Error(
|
||||
[
|
||||
'Не удалось загрузить модуль обработки изображений (sharp).',
|
||||
'Переустановите приложение полностью (удалите и поставьте заново)',
|
||||
'или исключите папку установки из проверки антивируса.',
|
||||
detail ? `Детали: ${detail}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {typeof import('sharp')}
|
||||
*/
|
||||
export function getSharp() {
|
||||
if (cached) return cached;
|
||||
if (loadError) throw loadError;
|
||||
try {
|
||||
cached = require('sharp');
|
||||
return cached;
|
||||
} catch (err) {
|
||||
loadError = sharpLoadFailure(err);
|
||||
throw loadError;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset cache (tests only). */
|
||||
export function __resetSharpRuntimeForTests() {
|
||||
cached = null;
|
||||
loadError = null;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
__resetSharpRuntimeForTests,
|
||||
getSharp,
|
||||
sharpLoadFailure,
|
||||
} from './sharpRuntime.mjs';
|
||||
|
||||
void test('getSharp: loads sharp when install is healthy', () => {
|
||||
__resetSharpRuntimeForTests();
|
||||
const sharp = getSharp();
|
||||
assert.equal(typeof sharp, 'function');
|
||||
});
|
||||
|
||||
void test('sharpLoadFailure: includes reinstall hint', () => {
|
||||
const err = sharpLoadFailure(new Error('SyntaxError: Unexpected end of input'));
|
||||
assert.match(err.message, /переустановите/i);
|
||||
assert.match(err.message, /SyntaxError/);
|
||||
});
|
||||
@@ -67,6 +67,12 @@ import {
|
||||
type NpcDisposition,
|
||||
} from '../../shared/types/npcDisposition';
|
||||
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
|
||||
import {
|
||||
asPreviewRotationDeg,
|
||||
previewRotationStepsCw,
|
||||
rotateMapMarkersByCwSteps,
|
||||
rotateSceneTokensByCwSteps,
|
||||
} from '../../shared/types/scenePreviewRotation';
|
||||
import { normalizeSceneTrap } from '../../shared/types/sceneTraps';
|
||||
import type {
|
||||
AssetId,
|
||||
@@ -787,6 +793,24 @@ export class ZipProjectStore {
|
||||
...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null),
|
||||
};
|
||||
|
||||
// Keep map markers glued to the art when previewRotationDeg changes (image/video).
|
||||
if (patch.previewRotationDeg !== undefined) {
|
||||
const fromRot = asPreviewRotationDeg(base.previewRotationDeg);
|
||||
const toRot = asPreviewRotationDeg(patch.previewRotationDeg);
|
||||
const steps = previewRotationStepsCw(fromRot, toRot);
|
||||
if (steps !== 0) {
|
||||
if (patch.tokens === undefined) {
|
||||
next.tokens = rotateSceneTokensByCwSteps(base.tokens ?? [], steps);
|
||||
}
|
||||
if (patch.npcTokens === undefined) {
|
||||
next.npcTokens = rotateMapMarkersByCwSteps(base.npcTokens ?? [], steps);
|
||||
}
|
||||
if (patch.traps === undefined) {
|
||||
next.traps = rotateMapMarkersByCwSteps(base.traps ?? [], steps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.updateProject((p) => {
|
||||
const scenes = { ...p.scenes, [sceneId]: next };
|
||||
const sceneListOrder =
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SceneTokensSessionEvent, SceneTokensSessionState } from '../../shared/types';
|
||||
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
|
||||
|
||||
function emptyState(): SceneTokensSessionState {
|
||||
return {
|
||||
@@ -23,6 +24,26 @@ export class SceneTokensSessionStore {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Rotate session position overrides with the scene preview (CSS rotate steps). */
|
||||
rotateMapCwSteps(steps: number): SceneTokensSessionState {
|
||||
const n = ((steps % 4) + 4) % 4;
|
||||
if (n === 0) return this.state;
|
||||
const ids = Object.keys(this.state.byPlacementId);
|
||||
if (ids.length === 0) return this.state;
|
||||
const byPlacementId: SceneTokensSessionState['byPlacementId'] = {};
|
||||
for (const id of ids) {
|
||||
const prev = this.state.byPlacementId[id];
|
||||
if (!prev) continue;
|
||||
const next = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
|
||||
byPlacementId[id] = { nx: next.nx, ny: next.ny };
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: SceneTokensSessionEvent): SceneTokensSessionState {
|
||||
switch (event.kind) {
|
||||
case 'clear':
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
emptyTokenPathSessionState,
|
||||
tokenPathKey,
|
||||
type TokenPathPlaybackEntry,
|
||||
type TokenPathSessionEvent,
|
||||
type TokenPathSessionState,
|
||||
type TokenPathTargetRef,
|
||||
} from '../../shared/types/tokenPathSession';
|
||||
|
||||
function bump(
|
||||
state: TokenPathSessionState,
|
||||
patch: Partial<Omit<TokenPathSessionState, 'revision' | 'serverNowMs'>>,
|
||||
): TokenPathSessionState {
|
||||
return {
|
||||
...state,
|
||||
...patch,
|
||||
revision: state.revision + 1,
|
||||
serverNowMs: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export class TokenPathSessionStore {
|
||||
private state: TokenPathSessionState = emptyTokenPathSessionState();
|
||||
|
||||
getState(): TokenPathSessionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Refresh clock without logical change (optional heartbeat). */
|
||||
touchClock(): TokenPathSessionState {
|
||||
this.state = { ...this.state, serverNowMs: Date.now() };
|
||||
return this.state;
|
||||
}
|
||||
|
||||
reset(): TokenPathSessionState {
|
||||
if (
|
||||
Object.keys(this.state.playback).length === 0 &&
|
||||
Object.keys(this.state.presentationVisible).length === 0
|
||||
) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = emptyTokenPathSessionState(this.state.revision + 1);
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: TokenPathSessionEvent): TokenPathSessionState {
|
||||
switch (event.kind) {
|
||||
case 'clear':
|
||||
return this.reset();
|
||||
case 'showPresentation': {
|
||||
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
||||
if (this.state.presentationVisible[key]) return this.state;
|
||||
this.state = bump(this.state, {
|
||||
presentationVisible: { ...this.state.presentationVisible, [key]: true },
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
case 'hidePresentation': {
|
||||
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
||||
if (!this.state.presentationVisible[key]) return this.state;
|
||||
const { [key]: _removed, ...rest } = this.state.presentationVisible;
|
||||
this.state = bump(this.state, { presentationVisible: rest });
|
||||
return this.state;
|
||||
}
|
||||
case 'stop': {
|
||||
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
||||
const prev = this.state.playback[key];
|
||||
if (!prev || prev.phase === 'stopped') return this.state;
|
||||
const atDist =
|
||||
typeof event.atDist === 'number' && Number.isFinite(event.atDist)
|
||||
? Math.max(0, event.atDist)
|
||||
: prev.baseDist;
|
||||
this.state = bump(this.state, {
|
||||
playback: {
|
||||
...this.state.playback,
|
||||
[key]: {
|
||||
...prev,
|
||||
phase: 'stopped',
|
||||
baseDist: atDist,
|
||||
rejoinDist: null,
|
||||
segmentStartedAtMs: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
case 'resume': {
|
||||
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
||||
const prev = this.state.playback[key];
|
||||
if (!prev) return this.state;
|
||||
if (prev.phase !== 'stopped' && prev.phase !== 'done') return this.state;
|
||||
const entry: TokenPathPlaybackEntry = {
|
||||
...prev,
|
||||
phase: 'moving',
|
||||
segmentStartedAtMs: Date.now(),
|
||||
// Jump to nearest-ahead on path, then continue (v1: no off-path lerp).
|
||||
baseDist: Math.max(0, event.fromDist),
|
||||
rejoinDist: null,
|
||||
direction: 1,
|
||||
};
|
||||
this.state = bump(this.state, {
|
||||
playback: { ...this.state.playback, [key]: entry },
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
case 'resetToStart': {
|
||||
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
||||
const prev = this.state.playback[key];
|
||||
const entry: TokenPathPlaybackEntry = {
|
||||
kind: event.target.kind,
|
||||
placementId: event.target.placementId,
|
||||
phase: 'moving',
|
||||
segmentStartedAtMs: Date.now(),
|
||||
baseDist: 0,
|
||||
direction: 1,
|
||||
rejoinDist: null,
|
||||
durationSec: prev?.durationSec ?? 8,
|
||||
pathLength: prev?.pathLength ?? 1,
|
||||
};
|
||||
this.state = bump(this.state, {
|
||||
playback: { ...this.state.playback, [key]: entry },
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
case 'seedPlayback': {
|
||||
const e = event.entry;
|
||||
const key = tokenPathKey(e.kind, e.placementId);
|
||||
const entry: TokenPathPlaybackEntry = {
|
||||
kind: e.kind,
|
||||
placementId: e.placementId,
|
||||
phase: e.phase,
|
||||
segmentStartedAtMs: e.segmentStartedAtMs ?? Date.now(),
|
||||
baseDist: e.baseDist,
|
||||
direction: e.direction,
|
||||
rejoinDist: e.rejoinDist,
|
||||
durationSec: e.durationSec,
|
||||
pathLength: e.pathLength,
|
||||
};
|
||||
this.state = bump(this.state, {
|
||||
playback: { ...this.state.playback, [key]: entry },
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
case 'markDone': {
|
||||
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
||||
const prev = this.state.playback[key];
|
||||
if (!prev || prev.phase === 'done') return this.state;
|
||||
this.state = bump(this.state, {
|
||||
playback: {
|
||||
...this.state.playback,
|
||||
[key]: {
|
||||
...prev,
|
||||
phase: 'done',
|
||||
baseDist: prev.pathLength,
|
||||
rejoinDist: null,
|
||||
segmentStartedAtMs: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = event;
|
||||
void _exhaustive;
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type { TokenPathTargetRef };
|
||||
@@ -18,6 +18,7 @@ export type WindowKind =
|
||||
| 'materials'
|
||||
| 'npcsEditor'
|
||||
| 'sceneEditor'
|
||||
| 'tokenPathEditor'
|
||||
| 'npcs';
|
||||
|
||||
/** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */
|
||||
@@ -28,6 +29,7 @@ export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [
|
||||
'npcs',
|
||||
'npcsEditor',
|
||||
'sceneEditor',
|
||||
'tokenPathEditor',
|
||||
] as const;
|
||||
|
||||
const windows = new Map<WindowKind, BrowserWindow>();
|
||||
@@ -209,6 +211,8 @@ function pageNameForKind(kind: WindowKind): string {
|
||||
return 'npcsEditor.html';
|
||||
case 'sceneEditor':
|
||||
return 'sceneEditor.html';
|
||||
case 'tokenPathEditor':
|
||||
return 'tokenPathEditor.html';
|
||||
case 'npcs':
|
||||
return 'npcs.html';
|
||||
}
|
||||
@@ -280,6 +284,7 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number }
|
||||
if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT };
|
||||
if (kind === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT };
|
||||
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 };
|
||||
return { width: 1280, height: 800 };
|
||||
}
|
||||
@@ -323,6 +328,14 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
minHeight: 600,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'tokenPathEditor'
|
||||
? {
|
||||
width: 1100,
|
||||
height: 760,
|
||||
minWidth: 900,
|
||||
minHeight: 560,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'npcs'
|
||||
? {
|
||||
width: NPCS_WINDOW_WIDTH,
|
||||
@@ -361,6 +374,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
kind === 'materials' ||
|
||||
kind === 'npcsEditor' ||
|
||||
kind === 'sceneEditor' ||
|
||||
kind === 'tokenPathEditor' ||
|
||||
kind === 'npcs'
|
||||
) {
|
||||
win.setMenuBarVisibility(false);
|
||||
@@ -408,6 +422,11 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
});
|
||||
}
|
||||
win.on('closed', () => windows.delete(kind));
|
||||
if (kind === 'sceneEditor') {
|
||||
win.on('closed', () => {
|
||||
closeTokenPathEditorWindow();
|
||||
});
|
||||
}
|
||||
win.on('closed', () => {
|
||||
if (kind !== 'presentation' && kind !== 'control') return;
|
||||
const open = windows.has('presentation') || windows.has('control');
|
||||
@@ -535,12 +554,73 @@ export function closeNpcsEditorWindow(): void {
|
||||
}
|
||||
|
||||
export function closeSceneEditorWindow(): void {
|
||||
closeTokenPathEditorWindow();
|
||||
const win = windows.get('sceneEditor');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeTokenPathEditorWindow(): void {
|
||||
const win = windows.get('tokenPathEditor');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
let pendingTokenPathEditorTarget: { kind: 'token' | 'npcToken'; placementId: string } | null = null;
|
||||
|
||||
export function getTokenPathEditorTarget(): { kind: 'token' | 'npcToken'; placementId: string } | null {
|
||||
return pendingTokenPathEditorTarget;
|
||||
}
|
||||
|
||||
function broadcastTokenPathEditorTarget(): void {
|
||||
const win = windows.get('tokenPathEditor');
|
||||
if (!win || win.isDestroyed() || win.webContents.isDestroyed()) return;
|
||||
try {
|
||||
win.webContents.send(ipcChannels.windows.tokenPathEditorTargetChanged, pendingTokenPathEditorTarget);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Одно окно пути: смена токена = фокус + targetChanged, без второго окна. */
|
||||
export function openTokenPathEditorWindow(kind: 'token' | 'npcToken', placementId: string): void {
|
||||
pendingTokenPathEditorTarget = { kind, placementId };
|
||||
const existing = windows.get('tokenPathEditor');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
broadcastTokenPathEditorTarget();
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('sceneEditor') ?? windows.get('editor');
|
||||
const win = createWindow('tokenPathEditor', 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', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
broadcastTokenPathEditorTarget();
|
||||
}
|
||||
});
|
||||
win.on('closed', () => {
|
||||
pendingTokenPathEditorTarget = null;
|
||||
});
|
||||
}
|
||||
|
||||
export function closeNpcsWindow(): void {
|
||||
const win = windows.get('npcs');
|
||||
if (win && !win.isDestroyed()) {
|
||||
|
||||
@@ -56,9 +56,12 @@ import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost';
|
||||
import { useSceneViewState } from '../shared/sceneView/useSceneViewState';
|
||||
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
|
||||
import { SceneTokensOverlay } from '../shared/tokens/SceneTokensOverlay';
|
||||
import { TokenPathsOverlay } from '../shared/tokens/TokenPathsOverlay';
|
||||
import { useAppTokens } from '../shared/tokens/useAppTokens';
|
||||
import { useSceneTokensSession } from '../shared/tokens/useSceneTokensSession';
|
||||
import { useTokenGridSnapSession } from '../shared/tokens/useTokenGridSnapSession';
|
||||
import { useTokenPathLivePoses } from '../shared/tokens/useTokenPathLivePoses';
|
||||
import { useTokenPathSession } from '../shared/tokens/useTokenPathSession';
|
||||
import { SceneTrapsOverlay } from '../shared/traps/SceneTrapsOverlay';
|
||||
import { useSceneTrapsState } from '../shared/traps/useSceneTrapsState';
|
||||
import { Button } from '../shared/ui/controls';
|
||||
@@ -66,6 +69,9 @@ import { EllipsisText } from '../shared/ui/EllipsisText';
|
||||
import ellipsisStyles from '../shared/ui/ellipsisText.module.css';
|
||||
import { Surface } from '../shared/ui/Surface';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
import { resumeFromPose, computePathPlaybackSample } from '../../shared/types/tokenPathPlayback';
|
||||
import { tokenPathKey } from '../../shared/types/tokenPathSession';
|
||||
import { sampleTokenPathAtDistance, tokenPathTotalLength } from '../../shared/types/tokenPath';
|
||||
|
||||
import styles from './ControlApp.module.css';
|
||||
import { ControlAudioCard } from './ControlAudioCard';
|
||||
@@ -156,6 +162,7 @@ export function ControlApp() {
|
||||
const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession();
|
||||
const [sceneNpcTokensSession, sceneNpcTokensApi] = useSceneNpcTokensSession();
|
||||
const [scenePlayerTokensSession, scenePlayerTokensApi] = useScenePlayerTokensSession();
|
||||
const [tokenPathSession, tokenPathApi] = useTokenPathSession();
|
||||
const [tokenGridSnap, tokenGridSnapApi] = useTokenGridSnapSession();
|
||||
const { players: appPlayers } = useAppPlayers();
|
||||
const [npcSessionCtxMenu, setNpcSessionCtxMenu] = useState<{
|
||||
@@ -163,6 +170,11 @@ export function ControlApp() {
|
||||
y: number;
|
||||
placementId: string;
|
||||
} | null>(null);
|
||||
const [tokenSessionCtxMenu, setTokenSessionCtxMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
placementId: string;
|
||||
} | null>(null);
|
||||
const [sceneView, sceneViewApi] = useSceneViewState();
|
||||
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
|
||||
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
|
||||
@@ -373,8 +385,7 @@ export function ControlApp() {
|
||||
const currentHistoryIdx = currentGraphNodeId != null ? history.lastIndexOf(currentGraphNodeId) : -1;
|
||||
const currentScene =
|
||||
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
|
||||
const isVideoPreviewScene = currentScene?.previewAssetType === 'video';
|
||||
const isDarkenScene = Boolean(currentScene?.darkenScene) && !isVideoPreviewScene;
|
||||
const isDarkenScene = Boolean(currentScene?.darkenScene);
|
||||
|
||||
const snapNormActive = useCallback(
|
||||
(nx: number, ny: number) => {
|
||||
@@ -868,7 +879,7 @@ export function ControlApp() {
|
||||
|
||||
useEffect(() => {
|
||||
const frame = previewFrameRef.current;
|
||||
if (!frame || isVideoPreviewScene) return;
|
||||
if (!frame) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const host = previewHostRef.current;
|
||||
@@ -892,7 +903,7 @@ export function ControlApp() {
|
||||
};
|
||||
frame.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => frame.removeEventListener('wheel', onWheel);
|
||||
}, [isVideoPreviewScene]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -992,8 +1003,63 @@ export function ControlApp() {
|
||||
/** Действия с токенами/ловушками только без активной кисти эффектов. */
|
||||
const markersInteractive = tool.tool === 'none';
|
||||
|
||||
const pathLivePoses = useTokenPathLivePoses({
|
||||
tokens: currentScene?.tokens ?? [],
|
||||
npcTokens: currentScene?.npcTokens ?? [],
|
||||
pathSession: tokenPathSession,
|
||||
enabled: Boolean(currentScene),
|
||||
onMarkDone: (kind, placementId) => {
|
||||
void tokenPathApi.dispatch({ kind: 'markDone', target: { kind, placementId } });
|
||||
const placement =
|
||||
kind === 'token'
|
||||
? (currentScene?.tokens ?? []).find((t) => String(t.id) === placementId)
|
||||
: (currentScene?.npcTokens ?? []).find((t) => String(t.id) === placementId);
|
||||
if (!placement?.path) return;
|
||||
const end = sampleTokenPathAtDistance(placement.path, tokenPathTotalLength(placement.path));
|
||||
if (!end) return;
|
||||
if (kind === 'token') {
|
||||
void sceneTokensApi.dispatch({
|
||||
kind: 'move',
|
||||
placementId,
|
||||
nx: end.nx,
|
||||
ny: end.ny,
|
||||
});
|
||||
} else {
|
||||
sceneNpcTokensApi.dispatch({
|
||||
kind: 'move',
|
||||
placementId,
|
||||
nx: end.nx,
|
||||
ny: end.ny,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const tokenPathDragEnabled = useMemo(() => {
|
||||
const out: Record<string, boolean> = {};
|
||||
for (const t of currentScene?.tokens ?? []) {
|
||||
const key = String(t.id);
|
||||
const entry = tokenPathSession?.playback[tokenPathKey('token', key)];
|
||||
out[key] = !entry || entry.phase === 'stopped';
|
||||
}
|
||||
return out;
|
||||
}, [currentScene?.tokens, tokenPathSession?.playback]);
|
||||
|
||||
const npcPathDragEnabled = useMemo(() => {
|
||||
const out: Record<string, boolean> = {};
|
||||
for (const t of currentScene?.npcTokens ?? []) {
|
||||
const key = String(t.id);
|
||||
const entry = tokenPathSession?.playback[tokenPathKey('npcToken', key)];
|
||||
out[key] = !entry || entry.phase === 'stopped';
|
||||
}
|
||||
return out;
|
||||
}, [currentScene?.npcTokens, tokenPathSession?.playback]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!markersInteractive) setNpcSessionCtxMenu(null);
|
||||
if (!markersInteractive) {
|
||||
setNpcSessionCtxMenu(null);
|
||||
setTokenSessionCtxMenu(null);
|
||||
}
|
||||
}, [markersInteractive]);
|
||||
toolRef.current = tool;
|
||||
|
||||
@@ -1256,11 +1322,6 @@ export function ControlApp() {
|
||||
}
|
||||
|
||||
async function commitStroke(): Promise<void> {
|
||||
if (isVideoPreviewScene) {
|
||||
brushRef.current = null;
|
||||
clearDraftFromPixi();
|
||||
return;
|
||||
}
|
||||
if (!fxState) return;
|
||||
const b = brushRef.current;
|
||||
if (!b) return;
|
||||
@@ -1608,7 +1669,6 @@ export function ControlApp() {
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.spacer12} />
|
||||
{!isVideoPreviewScene ? (
|
||||
<>
|
||||
<div className={styles.sectionLabel}>{t('control.effects')}</div>
|
||||
<div className={styles.spacer8} />
|
||||
@@ -1815,7 +1875,6 @@ export function ControlApp() {
|
||||
</div>
|
||||
<div className={styles.spacer12} />
|
||||
</>
|
||||
) : null}
|
||||
<div className={styles.storyWrap}>
|
||||
<div className={styles.sectionLabel}>{t('control.storyLine')}</div>
|
||||
<div className={styles.spacer10} />
|
||||
@@ -1960,14 +2019,11 @@ export function ControlApp() {
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.spacer10} />
|
||||
{isVideoPreviewScene ? <div className={styles.videoHint}>{t('control.videoBrushHint')}</div> : null}
|
||||
<div className={styles.spacer10} />
|
||||
<div
|
||||
ref={previewFrameRef}
|
||||
className={styles.previewFrame}
|
||||
title={
|
||||
isVideoPreviewScene ? undefined : 'Колесо — зум; перетаскивание СКМ/ПКМ или Space+ЛКМ — пан'
|
||||
}
|
||||
title="Колесо — зум; перетаскивание СКМ/ПКМ или Space+ЛКМ — пан"
|
||||
>
|
||||
<div ref={previewHostRef} className={styles.previewHost}>
|
||||
<ControlScenePreview
|
||||
@@ -1977,7 +2033,6 @@ export function ControlApp() {
|
||||
onContentRectChange={setPreviewContentRect}
|
||||
/>
|
||||
</div>
|
||||
{!isVideoPreviewScene ? (
|
||||
<>
|
||||
<SceneGridOverlay grid={currentScene?.grid} viewport={previewContentRect} />
|
||||
<PixiEffectsOverlay
|
||||
@@ -2151,6 +2206,15 @@ export function ControlApp() {
|
||||
clearDraftFromPixi();
|
||||
}}
|
||||
/>
|
||||
{previewContentRect ? (
|
||||
<TokenPathsOverlay
|
||||
tokens={currentScene?.tokens ?? []}
|
||||
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? (currentScene?.npcTokens ?? []) : []}
|
||||
viewport={previewContentRect}
|
||||
mode="always"
|
||||
pathSession={tokenPathSession}
|
||||
/>
|
||||
) : null}
|
||||
{previewContentRect ? (
|
||||
<SceneTokensOverlay
|
||||
placements={currentScene?.tokens ?? []}
|
||||
@@ -2159,6 +2223,8 @@ export function ControlApp() {
|
||||
viewport={previewContentRect}
|
||||
editable={markersInteractive}
|
||||
snapNorm={snapNormActive}
|
||||
poseOverrides={pathLivePoses.tokenPoses}
|
||||
dragEnabledById={tokenPathDragEnabled}
|
||||
onMove={(placementId, nx, ny) => {
|
||||
const snapped = snapNormActive(nx, ny);
|
||||
void sceneTokensApi.dispatch({
|
||||
@@ -2168,6 +2234,20 @@ export function ControlApp() {
|
||||
ny: snapped.ny,
|
||||
});
|
||||
}}
|
||||
{...(markersInteractive
|
||||
? {
|
||||
onContextMenu: (
|
||||
e: React.MouseEvent,
|
||||
placement: { id: string },
|
||||
) => {
|
||||
setTokenSessionCtxMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
placementId: String(placement.id),
|
||||
});
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
) : null}
|
||||
{USERS_BRANCH_FEATURES_ENABLED && previewContentRect ? (
|
||||
@@ -2179,6 +2259,8 @@ 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);
|
||||
sceneNpcTokensApi.dispatch({
|
||||
@@ -2188,17 +2270,20 @@ export function ControlApp() {
|
||||
ny: snapped.ny,
|
||||
});
|
||||
}}
|
||||
onContextMenu={
|
||||
markersInteractive
|
||||
? (e, placement) => {
|
||||
{...(markersInteractive
|
||||
? {
|
||||
onContextMenu: (
|
||||
e: React.MouseEvent,
|
||||
placement: { id: string },
|
||||
) => {
|
||||
setNpcSessionCtxMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
placementId: String(placement.id),
|
||||
});
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
) : null}
|
||||
{USERS_BRANCH_FEATURES_ENABLED && previewContentRect ? (
|
||||
@@ -2280,6 +2365,13 @@ export function ControlApp() {
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
{previewContentRect && currentScene?.darkenScene ? (
|
||||
<SceneDarknessOverlay
|
||||
state={sdState}
|
||||
overlayAlpha={0.5}
|
||||
viewport={previewContentRect}
|
||||
style={{ zIndex: 30 }}
|
||||
/>
|
||||
) : null}
|
||||
{(() => {
|
||||
const project = session?.project;
|
||||
@@ -2322,7 +2414,7 @@ export function ControlApp() {
|
||||
const showMaterial = materialItems.length > 0;
|
||||
const showNpcs = npcItems.length > 0;
|
||||
const screenRect = presentationScreenRect;
|
||||
const showGuide = Boolean(screenRect) && !isVideoPreviewScene;
|
||||
const showGuide = Boolean(screenRect);
|
||||
if (!showMaterial && !showNpcs && !showGuide) return null;
|
||||
const closes = [
|
||||
...(showMaterial
|
||||
@@ -2357,14 +2449,6 @@ export function ControlApp() {
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{previewContentRect && currentScene?.darkenScene ? (
|
||||
<SceneDarknessOverlay
|
||||
state={sdState}
|
||||
overlayAlpha={0.5}
|
||||
viewport={previewContentRect}
|
||||
style={{ zIndex: 30 }}
|
||||
/>
|
||||
) : null}
|
||||
<SceneOverlayHost
|
||||
active={showMaterial || showNpcs}
|
||||
viewport={screenRect}
|
||||
@@ -2670,6 +2754,151 @@ export function ControlApp() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{tokenSessionCtxMenu
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxMenuBackdrop}
|
||||
aria-label={t('common.close')}
|
||||
onClick={() => setTokenSessionCtxMenu(null)}
|
||||
/>
|
||||
<div
|
||||
className={styles.ctxMenu}
|
||||
style={{ left: tokenSessionCtxMenu.x, top: tokenSessionCtxMenu.y }}
|
||||
role="menu"
|
||||
>
|
||||
{(() => {
|
||||
const placement = (currentScene?.tokens ?? []).find(
|
||||
(item) => String(item.id) === tokenSessionCtxMenu.placementId,
|
||||
);
|
||||
if (!placement?.path || placement.path.points.length < 2) {
|
||||
return (
|
||||
<div className={styles.ctxItem} style={{ opacity: 0.55, cursor: 'default' }}>
|
||||
Нет пути движения
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const path = placement.path;
|
||||
const target = {
|
||||
kind: 'token' as const,
|
||||
placementId: tokenSessionCtxMenu.placementId,
|
||||
};
|
||||
const key = tokenPathKey(target.kind, target.placementId);
|
||||
const entry = tokenPathSession?.playback[key];
|
||||
const visible = Boolean(tokenPathSession?.presentationVisible[key]);
|
||||
const override =
|
||||
sceneTokensSession?.byPlacementId[tokenSessionCtxMenu.placementId];
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
void tokenPathApi.dispatch({
|
||||
kind: visible ? 'hidePresentation' : 'showPresentation',
|
||||
target,
|
||||
});
|
||||
setTokenSessionCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
{visible ? 'Скрыть путь' : 'Показать путь'}
|
||||
</button>
|
||||
{entry && entry.phase !== 'stopped' ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const sample = computePathPlaybackSample({
|
||||
path,
|
||||
entry,
|
||||
nowMs: Date.now(),
|
||||
});
|
||||
if (sample) {
|
||||
void sceneTokensApi.dispatch({
|
||||
kind: 'move',
|
||||
placementId: target.placementId,
|
||||
nx: sample.sample.nx,
|
||||
ny: sample.sample.ny,
|
||||
});
|
||||
}
|
||||
void tokenPathApi.dispatch({
|
||||
kind: 'stop',
|
||||
target,
|
||||
atDist: sample?.sample.dist ?? entry.baseDist,
|
||||
});
|
||||
setTokenSessionCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
Остановить движение
|
||||
</button>
|
||||
) : null}
|
||||
{entry && entry.phase === 'stopped' ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const nx = override?.nx ?? placement.nx;
|
||||
const ny = override?.ny ?? placement.ny;
|
||||
const fromDist = resumeFromPose(path, nx, ny, entry.baseDist);
|
||||
void tokenPathApi.dispatch({
|
||||
kind: 'resume',
|
||||
target,
|
||||
nx,
|
||||
ny,
|
||||
fromDist,
|
||||
});
|
||||
setTokenSessionCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
Продолжить движение
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const len = tokenPathTotalLength(path);
|
||||
void tokenPathApi.dispatch({
|
||||
kind: 'seedPlayback',
|
||||
entry: {
|
||||
kind: 'token',
|
||||
placementId: target.placementId,
|
||||
phase: path.startMode === 'delayed' ? 'delay' : 'moving',
|
||||
baseDist: 0,
|
||||
direction: 1,
|
||||
rejoinDist: null,
|
||||
durationSec: path.durationSec,
|
||||
pathLength: len,
|
||||
},
|
||||
});
|
||||
const start = sampleTokenPathAtDistance(path, 0);
|
||||
if (start) {
|
||||
void sceneTokensApi.dispatch({
|
||||
kind: 'move',
|
||||
placementId: target.placementId,
|
||||
nx: start.nx,
|
||||
ny: start.ny,
|
||||
});
|
||||
}
|
||||
setTokenSessionCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
Сбросить на старт пути
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
|
||||
{USERS_BRANCH_FEATURES_ENABLED && npcSessionCtxMenu
|
||||
? createPortal(
|
||||
<>
|
||||
@@ -2700,6 +2929,14 @@ export function ControlApp() {
|
||||
override?.disposition,
|
||||
);
|
||||
const inactive = Boolean(override?.inactive);
|
||||
const path = placement.path;
|
||||
const pathTarget = {
|
||||
kind: 'npcToken' as const,
|
||||
placementId: npcSessionCtxMenu.placementId,
|
||||
};
|
||||
const pathKey = tokenPathKey(pathTarget.kind, pathTarget.placementId);
|
||||
const pathEntry = tokenPathSession?.playback[pathKey];
|
||||
const pathVisible = Boolean(tokenPathSession?.presentationVisible[pathKey]);
|
||||
if (inactive) {
|
||||
return (
|
||||
<button
|
||||
@@ -2721,6 +2958,109 @@ export function ControlApp() {
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{path && path.points.length >= 2 ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
void tokenPathApi.dispatch({
|
||||
kind: pathVisible ? 'hidePresentation' : 'showPresentation',
|
||||
target: pathTarget,
|
||||
});
|
||||
setNpcSessionCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
{pathVisible ? 'Скрыть путь' : 'Показать путь'}
|
||||
</button>
|
||||
{pathEntry && pathEntry.phase !== 'stopped' ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const sample = computePathPlaybackSample({
|
||||
path,
|
||||
entry: pathEntry,
|
||||
nowMs: Date.now(),
|
||||
});
|
||||
if (sample) {
|
||||
sceneNpcTokensApi.dispatch({
|
||||
kind: 'move',
|
||||
placementId: pathTarget.placementId,
|
||||
nx: sample.sample.nx,
|
||||
ny: sample.sample.ny,
|
||||
});
|
||||
}
|
||||
void tokenPathApi.dispatch({
|
||||
kind: 'stop',
|
||||
target: pathTarget,
|
||||
atDist: sample?.sample.dist ?? pathEntry.baseDist,
|
||||
});
|
||||
setNpcSessionCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
Остановить движение
|
||||
</button>
|
||||
) : null}
|
||||
{pathEntry && pathEntry.phase === 'stopped' ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const nx = override?.nx ?? placement.nx;
|
||||
const ny = override?.ny ?? placement.ny;
|
||||
const fromDist = resumeFromPose(path, nx, ny, pathEntry.baseDist);
|
||||
void tokenPathApi.dispatch({
|
||||
kind: 'resume',
|
||||
target: pathTarget,
|
||||
nx,
|
||||
ny,
|
||||
fromDist,
|
||||
});
|
||||
setNpcSessionCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
Продолжить движение
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const len = tokenPathTotalLength(path);
|
||||
void tokenPathApi.dispatch({
|
||||
kind: 'seedPlayback',
|
||||
entry: {
|
||||
kind: 'npcToken',
|
||||
placementId: pathTarget.placementId,
|
||||
phase: path.startMode === 'delayed' ? 'delay' : 'moving',
|
||||
baseDist: 0,
|
||||
direction: 1,
|
||||
rejoinDist: null,
|
||||
durationSec: path.durationSec,
|
||||
pathLength: len,
|
||||
},
|
||||
});
|
||||
const start = sampleTokenPathAtDistance(path, 0);
|
||||
if (start) {
|
||||
sceneNpcTokensApi.dispatch({
|
||||
kind: 'move',
|
||||
placementId: pathTarget.placementId,
|
||||
nx: start.nx,
|
||||
ny: start.ny,
|
||||
});
|
||||
}
|
||||
setNpcSessionCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
Сбросить на старт пути
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
pointer-events: auto;
|
||||
/* Above brush / traps layers so transport stays usable on video scenes. */
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.scrub {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computeTimeSec } from '../../main/video/videoPlaybackStore';
|
||||
import type { SessionState } from '../../shared/ipc/contracts';
|
||||
import type { SceneViewCamera } from '../../shared/types';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { ContainedVideo } from '../shared/ContainedVideo';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
import { useVideoPlaybackState } from '../shared/video/useVideoPlaybackState';
|
||||
@@ -106,20 +107,20 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
||||
onContentRectChange={onContentRectChange}
|
||||
/>
|
||||
) : url && isVideo ? (
|
||||
<video
|
||||
ref={(el) => {
|
||||
(videoRef as unknown as { current: HTMLVideoElement | null }).current = el;
|
||||
}}
|
||||
className={styles.video}
|
||||
src={url}
|
||||
<ContainedVideo
|
||||
url={url}
|
||||
rotationDeg={rot}
|
||||
videoRef={videoRef}
|
||||
playsInline
|
||||
loop={Boolean(scene?.settings?.loopVideo)}
|
||||
preload="auto"
|
||||
viewCamera={viewCamera}
|
||||
onContentRectChange={onContentRectChange}
|
||||
onTimeUpdate={() => setTick((x) => x + 1)}
|
||||
onLoadedMetadata={() => setTick((x) => x + 1)}
|
||||
>
|
||||
<track kind="captions" srcLang="ru" label={t('control.previewTrackLabel')} />
|
||||
</video>
|
||||
</ContainedVideo>
|
||||
) : (
|
||||
<div className={styles.placeholder} />
|
||||
)}
|
||||
|
||||
@@ -58,7 +58,7 @@ void test('ControlApp: эффект «взрыв» + ловушка исполь
|
||||
const appSrc = readControlApp();
|
||||
const sfxSrc = fs.readFileSync(path.join(here, 'explosionSfx.ts'), 'utf8');
|
||||
assert.ok(appSrc.includes("title={t('control.explosion')}"));
|
||||
assert.ok(appSrc.includes("tool: 'explosion'"));
|
||||
assert.ok(appSrc.includes("selectEffectTool('explosion')"));
|
||||
assert.ok(appSrc.includes("type: 'explosion'"));
|
||||
assert.ok(appSrc.includes('getExplosionEffectLifeMs'));
|
||||
assert.ok(appSrc.includes('playExplosionEffectSound'));
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
SceneId,
|
||||
} from '../../shared/types';
|
||||
import { AppLogo } from '../shared/branding/AppLogo';
|
||||
import { ContainedVideo } from '../shared/ContainedVideo';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
@@ -2696,14 +2697,16 @@ function SceneInspector({
|
||||
</div>
|
||||
) : previewUrl && previewAssetType === 'video' ? (
|
||||
<div className={styles.previewFill}>
|
||||
<video
|
||||
src={previewUrl}
|
||||
<ContainedVideo
|
||||
url={previewUrl}
|
||||
rotationDeg={previewRotationDeg}
|
||||
mode="cover"
|
||||
muted
|
||||
playsInline
|
||||
autoPlay={previewVideoAutostart}
|
||||
loop={previewVideoLoop}
|
||||
preload="metadata"
|
||||
className={styles.videoCover}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -2744,7 +2747,7 @@ function SceneInspector({
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
{previewAssetId && previewAssetType === 'image' ? (
|
||||
{previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? (
|
||||
<>
|
||||
<div className={styles.spacer6} />
|
||||
<Button
|
||||
@@ -2755,6 +2758,10 @@ function SceneInspector({
|
||||
>
|
||||
{t('scene.rotate')}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? (
|
||||
<>
|
||||
<div className={styles.spacer6} />
|
||||
<label className={styles.checkboxLabel}>
|
||||
<input
|
||||
@@ -2985,6 +2992,7 @@ function SceneListCard({
|
||||
</div>
|
||||
) : previewUrl && scene.previewAssetType === 'video' ? (
|
||||
<div className={styles.sceneThumbInner}>
|
||||
{scene.previewRotationDeg === 0 ? (
|
||||
<video
|
||||
src={previewUrl}
|
||||
muted
|
||||
@@ -3002,6 +3010,26 @@ function SceneListCard({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ContainedVideo
|
||||
url={previewUrl}
|
||||
rotationDeg={scene.previewRotationDeg}
|
||||
mode="cover"
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
onLoadedData={(e) => {
|
||||
const v = e.currentTarget;
|
||||
try {
|
||||
v.currentTime = 0;
|
||||
v.pause();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.sceneThumbEmptyInner} aria-hidden />
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
isSideStoryEdge,
|
||||
} from '../../../shared/graph/sceneGraphLineage';
|
||||
import type { AssetId, GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../../../shared/types';
|
||||
import { ContainedVideo } from '../../shared/ContainedVideo';
|
||||
import { RotatedImage } from '../../shared/RotatedImage';
|
||||
import { EllipsisText } from '../../shared/ui/EllipsisText';
|
||||
import ellipsisStyles from '../../shared/ui/ellipsisText.module.css';
|
||||
@@ -260,6 +261,8 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
)}
|
||||
</div>
|
||||
) : previewUrl && data.previewAssetType === 'video' ? (
|
||||
<div className={styles.previewFill}>
|
||||
{data.previewRotationDeg === 0 ? (
|
||||
<video
|
||||
src={previewUrl}
|
||||
muted
|
||||
@@ -276,6 +279,27 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ContainedVideo
|
||||
url={previewUrl}
|
||||
rotationDeg={data.previewRotationDeg}
|
||||
mode="cover"
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
onLoadedData={(e) => {
|
||||
const v = e.currentTarget;
|
||||
try {
|
||||
v.currentTime = 0;
|
||||
v.pause();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.previewPlaceholder} aria-hidden />
|
||||
)}
|
||||
|
||||
@@ -180,23 +180,23 @@ 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':
|
||||
'«Редактор сцены» — отдельное окно для подготовки карты: сетка боя, ловушки, неигровые токены и круглые токены НПС. Доступен только для сцен с изображением (не с видео).\n\nОткрыть:\n\n1) Выберите сцену в списке слева.\n\n2) В «Свойствах сцены» загрузите картинку, если её ещё нет.\n\n3) Нажмите «Редактор сцены».\n\nСлева — аккордеоны «Сетка», «Неигровые токены», «НПС» и «Ловушки»; справа — карта сцены. Перетащите НПС на карту, чтобы добавить его круглый токен.\n\nНавигация по карте: колесо мыши — зум; средняя кнопка мыши или Space+ЛКМ — сдвиг вида. Delete / Backspace убирает выделенный маркер на карте.\n\nПод аккордеонами кнопка «Очистить сцену» убирает с текущей карты все ловушки и токены (пул токенов приложения не трогает).\n\nПодробнее: разделы «Генератор сетки», «Ловушки» и «Неигровые токены».',
|
||||
'«Редактор сцены» — отдельное окно для подготовки карты: сетка боя, ловушки, неигровые токены и круглые токены НПС. Работает для сцен с изображением и с видео (оверлеи поверх ролика).\n\nОткрыть:\n\n1) Выберите сцену в списке слева.\n\n2) В «Свойствах сцены» загрузите картинку или видео, если превью ещё нет.\n\n3) Нажмите «Редактор сцены».\n\nСлева — аккордеоны «Сетка», «Неигровые токены», «НПС» и «Ловушки»; справа — карта сцены. Перетащите НПС на карту, чтобы добавить его круглый токен.\n\nНавигация по карте: колесо мыши — зум; средняя кнопка мыши или Space+ЛКМ — сдвиг вида. Delete / Backspace убирает выделенный маркер на карте.\n\nПод аккордеонами кнопка «Очистить сцену» убирает с текущей карты все ловушки и токены (пул токенов приложения не трогает).\n\nПодробнее: разделы «Генератор сетки», «Ловушки» и «Неигровые токены».',
|
||||
|
||||
'help.section.grid.title': 'Генератор сетки',
|
||||
'help.section.grid.body':
|
||||
'Генератор сетки накладывает на картинку сцены боевую сетку — квадратную или гексагональную. Сетка помогает ориентироваться по клеткам во время боя и видна и вам на пульте, и игрокам на презентации.\n\nНастроить:\n\n1) Откройте «Редактор сцены» для сцены с изображением (см. «Редактор сцены»).\n\n2) Раскройте аккордеон «Сетка» слева.\n\n3) Включите «Наложить сетку» — линии появятся поверх карты.\n\n4) «Тип» — «Квадратная» или «Гексогональная».\n\n5) «Цвет» — оттенок линий (удобно подобрать контраст к карте).\n\n6) «Размер» — ползунок ячейки: чем больше значение, тем крупнее клетки.\n\nПока сетка выключена, тип, цвет и размер недоступны для изменения, но запомненные значения сохраняются и вернутся при повторном включении.\n\nНастройки сетки хранятся в проекте вместе со сценой. На видео-сценах генератор недоступен — только на картинках. Сетка рисуется под маркерами ловушек и токенов и не мешает их расставлять.',
|
||||
'Генератор сетки накладывает на превью сцены (картинку или видео) боевую сетку — квадратную или гексагональную. Сетка помогает ориентироваться по клеткам во время боя и видна и вам на пульте, и игрокам на презентации.\n\nНастроить:\n\n1) Откройте «Редактор сцены» (см. «Редактор сцены»).\n\n2) Раскройте аккордеон «Сетка» слева.\n\n3) Включите «Наложить сетку» — линии появятся поверх карты.\n\n4) «Тип» — «Квадратная» или «Гексогональная».\n\n5) «Цвет» — оттенок линий (удобно подобрать контраст к карте).\n\n6) «Размер» — ползунок ячейки: чем больше значение, тем крупнее клетки.\n\nПока сетка выключена, тип, цвет и размер недоступны для изменения, но запомненные значения сохраняются и вернутся при повторном включении.\n\nНастройки сетки хранятся в проекте вместе со сценой. Сетка рисуется под маркерами ловушек и токенов и не мешает их расставлять.\n\nВо время сессии на пульте можно включить «Привязка токенов к сетке»: при перетаскивании неигровые токены, токены НПС и игроков «прилипают» к клеткам (квадрат или гекс — по типу сетки). Если сетка выключена, галочка не действует.',
|
||||
|
||||
'help.section.traps.title': 'Ловушки',
|
||||
'help.section.traps.body':
|
||||
'Ловушки — маркеры на карте сцены для скрытых угроз и сюрпризов. Расстановка хранится в проекте вместе со сценой; во время игры вы решаете, когда их показать игрокам.\n\nВ редакторе сцены:\n\n1) Откройте «Редактор сцены» для сцены с картинкой (см. «Редактор сцены»).\n\n2) Раскройте аккордеон «Ловушки» слева.\n\n3) Перетащите тип из палитры на нужное место карты.\n\n4) Перетащите маркер, чтобы сдвинуть его; потяните за уголок выделенного маркера — изменить размер.\n\n5) Delete / Backspace — убрать выделенную ловушку. «Очистить сцену» снимает все маркеры сразу.\n\nТипы: Мимик, Взрыв, Яд, Пропасть, Стрела, Лазер и Метка (универсальный маркер без особого эффекта).\n\nВо время сессии:\n\n1) На пульте в «Предпросмотр экрана» видны все ловушки текущей сцены. Пока они скрыты от игроков, маркеры у вас слегка приглушены.\n\n2) На презентации маркеры появляются только после проявления или срабатывания.\n\n3) Правый клик по маркеру на пульте:\n• «Проявить» — показать игрокам без срабатывания.\n• «Активировать» — проявить и запустить эффект: у Мимика, Пропасти, Стрелы и Лазера — анимация и звук; у Яда и Взрыва — как соответствующие эффекты с пульта (облако яда / взрыв); у Метки — короткая вспышка.\n• «Обезвредить» — показать как обезвреженную.\n\nСостояние ловушек (проявлены / сработали / обезврежены) сбрасывается при новом запуске сессии. Сами маркеры на карте остаются.',
|
||||
'Ловушки — маркеры на карте сцены для скрытых угроз и сюрпризов. Расстановка хранится в проекте вместе со сценой; во время игры вы решаете, когда их показать игрокам.\n\nВ редакторе сцены:\n\n1) Откройте «Редактор сцены» для сцены с картинкой или видео (см. «Редактор сцены»).\n\n2) Раскройте аккордеон «Ловушки» слева.\n\n3) Перетащите тип из палитры на нужное место карты.\n\n4) Перетащите маркер, чтобы сдвинуть его; потяните за уголок выделенного маркера — изменить размер.\n\n5) Delete / Backspace — убрать выделенную ловушку. «Очистить сцену» снимает все маркеры сразу.\n\nТипы: Мимик, Взрыв, Яд, Пропасть, Стрела, Лазер и Метка (универсальный маркер без особого эффекта).\n\nВо время сессии:\n\n1) На пульте в «Предпросмотр экрана» видны все ловушки текущей сцены. Пока они скрыты от игроков, у вас маркеры остаются хорошо читаемыми (пунктирная рамка), чтобы их было удобно найти на карте.\n\n2) На презентации маркеры появляются только после проявления или срабатывания.\n\n3) Правый клик по маркеру на пульте:\n• «Проявить» — показать игрокам без срабатывания.\n• «Активировать» — проявить и запустить эффект: у Мимика, Пропасти, Стрелы и Лазера — анимация и звук; у Яда и Взрыва — как соответствующие эффекты с пульта (облако яда / взрыв); у Метки — короткая вспышка.\n• «Обезвредить» — показать как обезвреженную.\n\nСостояние ловушек (проявлены / сработали / обезврежены) сбрасывается при новом запуске сессии. Сами маркеры на карте остаются.',
|
||||
|
||||
'help.section.tokens.title': 'Неигровые токены',
|
||||
'help.section.tokens.body':
|
||||
'Неигровые токены — картинки существ, предметов и маркеров, которые вы ставите на карту сцены. Библиотека токенов хранится в приложении на этом компьютере (не внутри файла проекта). На сцене сохраняется только расстановка: какой токен, где стоит, размер и поворот.\n\nВ редакторе сцены:\n\n1) Откройте «Редактор сцены» для сцены с картинкой (см. «Редактор сцены»).\n\n2) Раскройте «Неигровые токены».\n\n3) «Добавить» — задайте уникальное название и изображение (кнопка выбора или перетаскивание файла).\n\n4) В поиске можно быстро найти токен по имени.\n\n5) Меню «⋮» у плитки — «Изменить» или «Удалить» (с подтверждением). Удаление из пула также убирает этот токен с текущей сцены.\n\n6) Перетащите плитку на карту, чтобы поставить токен. Выделите маркер: перетаскивание — сдвиг, уголок — размер, ручка поворота — угол. Delete / Backspace или ПКМ по маркеру — убрать с карты. «Очистить сцену» снимает все токены и ловушки со сцены.\n\nВо время сессии:\n\n1) На пульте в «Предпросмотр экрана» токены видны сразу (в отличие от ловушек их не нужно проявлять).\n\n2) Перетаскивайте токены левой кнопкой — новая позиция запоминается до конца текущей сессии, в том числе если вы возвращаетесь к сцене через «Сюжетную линию». При новом «Запустить» позиции снова берутся из редактора.\n\n3) На экране презентации токены только отображаются: клики и перетаскивание для игроков недоступны.\n\nПри экспорте и импорте сюжетных линий нужные файлы токенов упаковываются вместе с линией, чтобы на другом компьютере расстановка не «теряла» картинки.',
|
||||
'Неигровые токены — картинки существ, предметов и маркеров, которые вы ставите на карту сцены. Библиотека токенов хранится в приложении на этом компьютере (не внутри файла проекта). На сцене сохраняется только расстановка: какой токен, где стоит, размер и поворот.\n\nВ редакторе сцены:\n\n1) Откройте «Редактор сцены» для сцены с картинкой или видео (см. «Редактор сцены»).\n\n2) Раскройте «Неигровые токены».\n\n3) «Добавить» — задайте уникальное название и изображение (кнопка выбора или перетаскивание файла).\n\n4) В поиске можно быстро найти токен по имени.\n\n5) Меню «⋮» у плитки — «Изменить» или «Удалить» (с подтверждением). Удаление из пула также убирает этот токен с текущей сцены.\n\n6) Перетащите плитку на карту, чтобы поставить токен. Выделите маркер: перетаскивание — сдвиг, уголок — размер, ручка поворота — угол. Delete / Backspace — убрать с карты. ПКМ по маркеру — меню: «Указать движение» / «Удалить» / «Сбросить на старт пути». «Очистить сцену» снимает все токены и ловушки со сцены.\n\nВо время сессии:\n\n1) На пульте в «Предпросмотр экрана» токены видны сразу (в отличие от ловушек их не нужно проявлять).\n\n2) Перетаскивайте токены левой кнопкой — новая позиция запоминается до конца текущей сессии, в том числе если вы возвращаетесь к сцене через «Сюжетную линию». При новом «Запустить» позиции снова берутся из редактора.\n\n3) Если на сцене включена сетка, на пульте можно отметить «Привязка токенов к сетке» — при перетаскивании токены встают по клеткам (см. «Генератор сетки»).\n\n4) На экране презентации токены только отображаются: клики и перетаскивание для игроков недоступны.\n\nПри экспорте и импорте сюжетных линий нужные файлы токенов упаковываются вместе с линией, чтобы на другом компьютере расстановка не «теряла» картинки.',
|
||||
|
||||
'help.section.campaignAudio.title': 'Аудио игры',
|
||||
'help.section.campaignAudio.body':
|
||||
@@ -204,14 +204,14 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.materials.title': 'Материалы',
|
||||
'help.section.materials.body':
|
||||
'Материалы — изображения кампании (карты, записки, чертежи), которые можно показать игрокам поверх сцены во время игры. Они общие для проекта и не привязаны к одной сцене.\n\nВ редакторе:\n\n1) В блоке «Свойства игры» нажмите «Материалы».\n\n2) «Добавить» — укажите уникальное название и изображение (PNG, JPG или WebP): кнопка выбора или перетаскивание файла.\n\n3) В списке можно искать, менять порядок перетаскиванием, править или удалять через меню «⋮» (перед удалением будет подтверждение).\n\n4) Под большим превью — «Повернуть»: поворот на 90° (учитывается и в плитке, и при показе на экране).\n\nВо время сессии:\n\n1) На пульте в «Инструменты» нажмите кнопку материалов (иконка карты сокровищ) — откроется отдельное окно со списком.\n\n2) Клик по плитке показывает материал поверх сцены на пульте и на презентации; повторный клик по той же плитке скрывает его.\n\n3) На предпросмотре пульта материал можно перетаскивать и менять размер за углы; крестик закрывает показ.\n\n4) В окне материалов лупы «+» / «−» — инструменты масштаба: выберите лупу, затем кликните по материалу в предпросмотре пульта.\n\nПри смене сцены показ материала сбрасывается. Описание сцены и эффекты поля с материалами не связаны.',
|
||||
'Материалы — изображения кампании (карты, записки, чертежи), которые можно показать игрокам поверх сцены во время игры. Они общие для проекта и не привязаны к одной сцене.\n\nВ редакторе:\n\n1) В блоке «Свойства игры» нажмите «Материалы».\n\n2) «Добавить» — укажите уникальное название и изображение (PNG, JPG или WebP): кнопка выбора или перетаскивание файла.\n\n3) В списке можно искать, менять порядок перетаскиванием, править или удалять через меню «⋮» (перед удалением будет подтверждение).\n\n4) Под большим превью — «Повернуть»: поворот на 90° (учитывается и в плитке, и при показе на экране).\n\n5) Блок «Легенда» у выбранного материала: можно включить легенду, разместить нумерованные маркеры на картинке и подписать пункты списка. При показе на пульте и презентации легенда идёт вместе с материалом.\n\nВо время сессии:\n\n1) На пульте в «Инструменты» нажмите кнопку материалов (иконка карты сокровищ) — откроется отдельное окно со списком.\n\n2) Клик по плитке показывает материал поверх сцены на пульте и на презентации; повторный клик по той же плитке скрывает его. Можно открыть несколько материалов сразу — каждый кликом по своей плитке.\n\n3) На предпросмотре пульта материал можно перетаскивать, менять размер за углы и поворачивать; крестик закрывает показ этого материала (остальные остаются).\n\n4) В окне материалов лупы «+» / «−» — инструменты масштаба: выберите лупу, затем кликните по материалу в предпросмотре пульта.\n\nПри смене сцены показ материалов сбрасывается. Описание сцены и эффекты поля с материалами не связаны.',
|
||||
'help.section.players.title': 'Игроки',
|
||||
'help.section.players.body':
|
||||
'Раздел «Игроки» в шапке хранит локальную библиотеку живых игроков на этом компьютере (не внутри файла проекта).\n\n1) Откройте «Игроки» в шапке.\n\n2) «Добавить игрока» — имя и изображение обязательны. Пока идёт сохранение, видно окно с прогрессом.\n\n3) Справа — превью игрового токена: круг с цветной рамкой, аватар внутри (перетащите мышью, чтобы отцентровать; колесо мыши — увеличить/уменьшить), имя на тёмной подложке и выбор цвета рамки.\n\n4) Команды — плоские группы без вложенности: создайте команду, перетащите игрока в неё или в «Без команды».\n\nКампанийных НПС на сцену ставят отдельно: в «Редактор сцены» аккордеон «НПС» — плитки персонажей проекта, на карте они выглядят как такой же круглый токен.',
|
||||
'Раздел «Игроки» в шапке хранит локальную библиотеку живых игроков на этом компьютере (не внутри файла проекта).\n\n1) Откройте «Игроки» в шапке.\n\n2) «Добавить игрока» — имя и изображение обязательны. Пока идёт сохранение, видно окно с прогрессом.\n\n3) Справа — превью игрового токена: круг с цветной рамкой, аватар внутри (перетащите мышью, чтобы отцентровать; колесо мыши — увеличить/уменьшить), имя на тёмной подложке и выбор цвета рамки.\n\n4) Команды — плоские группы без вложенности: создайте команду, перетащите игрока в неё или в «Без команды».\n\nКампанийных НПС на сцену ставят отдельно: в «Редактор сцены» аккордеон «НПС» — плитки персонажей проекта, на карте они выглядят как такой же круглый токен.\n\nВо время сессии размер круглых токенов игроков и НПС на пульте можно менять ползунком «Размеры игр. токенов» (см. «Пульт управления»).',
|
||||
|
||||
'help.section.npcs.title': 'НПС',
|
||||
'help.section.npcs.body':
|
||||
'НПС — персонажи кампании с аватаром, описанием и однонаправленными связями между собой. Они общие для проекта.\n\nВ редакторе:\n\n1) В блоке «Свойства игры» нажмите «НПС» — откроется отдельное окно редактора персонажей.\n\n2) «Добавить» — укажите уникальное имя и обязательный аватар (PNG, JPG или WebP): кнопка выбора или перетаскивание файла. При необходимости сразу заполните описание.\n\n3) Слева — список персонажей: поиск, порядок перетаскиванием; меню «⋮» — только удаление (с подтверждением; все связи с этим персонажем тоже удаляются).\n\n4) В центре — граф связей: протяните стрелку от одного персонажа к другому и укажите обязательное название связи.\n\n5) Справа — карточка выбранного персонажа: настройте круглый токен, цвет рамки и положение аватара (перетаскивание и колесо мыши для масштаба), а также имя, описание и отношения.\n\n6) В «Редакторе сцены» раскройте «НПС» и перетащите персонажа на карту. Круглый токен можно двигать и менять его размер; во время сессии он доступен на пульте и только отображается в презентации.',
|
||||
'НПС — персонажи кампании с аватаром, описанием и однонаправленными связями между собой. Они общие для проекта.\n\nВ редакторе:\n\n1) В блоке «Свойства игры» нажмите «НПС» — откроется отдельное окно редактора персонажей.\n\n2) «Добавить» — укажите уникальное имя и обязательный аватар (PNG, JPG или WebP): кнопка выбора или перетаскивание файла. При необходимости сразу заполните описание.\n\n3) Слева — список персонажей: поиск, порядок перетаскиванием; меню «⋮» — только удаление (с подтверждением; все связи с этим персонажем тоже удаляются). Персонажей можно объединять в группы (и подгруппы): создайте группу и перетащите НПС в неё или оставьте «Без группы». На графе связей доступен фильтр по группе.\n\n4) В центре — граф связей: протяните стрелку от одного персонажа к другому и укажите обязательное название связи.\n\n5) Справа — карточка выбранного персонажа: настройте круглый токен, цвет рамки, положение аватара (перетаскивание и колесо мыши для масштаба), имя, описание, группу и отношения. Поле «Тип» задаёт отношение: враждебный, нейтральный или дружественный — от этого зависит цвет кольца токена на карте.\n\n6) В «Редакторе сцены» раскройте «НПС» и перетащите персонажа на карту. Круглый токен можно двигать и менять его размер.\n\nВо время сессии на пульте токен НПС можно двигать; правый клик по маркеру:\n• если токен неактивен — «Сделать активным»;\n• если активен — «Открыть информацию» (карточка НПС), смена типа (враждебный / нейтральный / дружественный) и «Сделать неактивным».\nНа презентации токены только отображаются. Подробнее о пульте — в разделе «Пульт управления».',
|
||||
|
||||
'help.section.session.title': 'Запуск сессии',
|
||||
'help.section.session.body':
|
||||
@@ -219,7 +219,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'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«Выключить демонстрацию» — закончить показ и вернуться в редактор.',
|
||||
|
||||
'help.section.transitions.title': 'Переходы между сценами',
|
||||
'help.section.transitions.body':
|
||||
@@ -231,11 +231,11 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.effects.title': 'Эффекты поля и действий',
|
||||
'help.section.effects.body':
|
||||
'Эффекты работают на сценах с картинкой, не на видео. Рисуйте в «Предпросмотр экрана» — игроки увидят то же на презентации.\n\nВыберите инструмент слева:\n• Эффекты поля (туман, дождь, огонь, вода) — зажмите левую кнопку и ведите по карте.\n• Эффекты действий (молния, луч света, заморозка, тьма, облако яда, взрыв) — короткий клик или штрих; у некоторых есть звук.\n\nЕсли у сцены в свойствах включено «Затемнить сцену», появится блок «Управление затемнением» с «Кистью Открытия» 🔦 и «Кистью Закрытия» ⬛. «Кисть Открытия» снимает тьму на обоих экранах сразу; «Кисть Закрытия» снова накрывает уже открытые участки. У игроков нераскрытое остаётся чёрным, у вас на пульте — полузатемнённым. Состояние сохраняется, пока идёт показ и вы снова попадаете на ту же карточку сцены на карте. Это не то же самое, что эффект «Тьма» 🌑 в блоке действий.\n\nЛастик 🧹 — для эффектов поля (туман, дождь, огонь, вода) водите кистью, как «Кистью Открытия» для затемнения: стирается только пройденный участок. Эффекты действий (молния, луч и т.д.) убираются целиком при клике или проведении по ним. «Очистить эффекты» — снять всё сразу.\n\n«Радиус кисти» под панелью — чем больше число, тем шире мазок.',
|
||||
'Эффекты работают на сценах с картинкой и с видео. Рисуйте в «Предпросмотр экрана» — игроки увидят то же на презентации.\n\nВыберите инструмент слева:\n• Эффекты поля (туман, дождь, огонь, вода) — зажмите левую кнопку и ведите по карте.\n• Эффекты действий (молния, луч света, заморозка, тьма, облако яда, взрыв) — короткий клик или штрих; у некоторых есть звук.\n\nЕсли у сцены в свойствах включено «Затемнить сцену», появится блок «Управление затемнением» с «Кистью Открытия» 🔦 и «Кистью Закрытия» ⬛. «Кисть Открытия» снимает тьму на обоих экранах сразу; «Кисть Закрытия» снова накрывает уже открытые участки. У игроков нераскрытое остаётся чёрным, у вас на пульте — полузатемнённым. Состояние сохраняется, пока идёт показ и вы снова попадаете на ту же карточку сцены на карте. Это не то же самое, что эффект «Тьма» 🌑 в блоке действий.\n\nЛастик 🧹 — для эффектов поля (туман, дождь, огонь, вода) водите кистью, как «Кистью Открытия» для затемнения: стирается только пройденный участок. Эффекты действий (молния, луч и т.д.) убираются целиком при клике или проведении по ним. «Очистить эффекты» — снять всё сразу.\n\n«Радиус кисти» под панелью — чем больше число, тем шире мазок.',
|
||||
|
||||
'help.section.presentation.title': 'Экран презентации',
|
||||
'help.section.presentation.body':
|
||||
'«Презентация» — то, что видят игроки: картинка сцены (с учётом поворота из редактора) или видео по вашим настройкам.\n\nЭффекты с пульта накладываются поверх. Меню и кнопки мастера здесь не показываются — клики по карте, ловушкам и токенам для игроков недоступны.\n\nЕсли у сцены включено «Затемнить сцену», игроки сначала видят полностью чёрный экран. Мастер открывает карту «Кистью Открытия» и при необходимости снова закрывает участки «Кистью Закрытия» на пульте.\n\nПри смене сцены с пульта картинка обновляется сама. Перенесите окно на экран для игроков и при необходимости спрячьте панель задач.\n\nПустой или тёмный экран — скорее всего, у сцены нет превью. Добавьте картинку в свойствах сцены в редакторе.',
|
||||
'«Презентация» — то, что видят игроки: картинка или видео сцены (с учётом поворота из редактора) по вашим настройкам.\n\nЭффекты с пульта накладываются поверх. Меню и кнопки мастера здесь не показываются — клики по карте, ловушкам и токенам для игроков недоступны.\n\nЕсли у сцены включено «Затемнить сцену», игроки сначала видят полностью чёрный экран. Мастер открывает карту «Кистью Открытия» и при необходимости снова закрывает участки «Кистью Закрытия» на пульте.\n\nПри смене сцены с пульта картинка обновляется сама. Перенесите окно на экран для игроков и при необходимости спрячьте панель задач.\n\nПустой или тёмный экран — скорее всего, у сцены нет превью. Добавьте картинку в свойствах сцены в редакторе.',
|
||||
|
||||
'help.section.importExport.title': 'Импорт и экспорт',
|
||||
'help.section.importExport.body':
|
||||
@@ -800,23 +800,23 @@ 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, etc.).\n\n3) For images, use Rotate (90° steps). Clear removes the preview.\n\n4) For images, enable Darken scene so players start in full darkness and you reveal the map with the Opening brush on the control panel (see Effects).\n\n5) For images, Scene editor opens the battle grid, traps, and non-player tokens on the map (see Scene editor, Grid generator, Traps, and Non-player tokens).\n\nFor video, enable Autostart if the clip should start on its own on the player screen. Brush effects and the scene editor are not available on video scenes.\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 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, and non-player tokens over the preview (see Scene editor, Grid generator, Traps, and Non-player tokens).\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':
|
||||
'Scene editor is a separate window for preparing the map: battle grid, traps, non-player tokens, and circular NPC tokens. It is available only for image scenes (not video).\n\nOpen it:\n\n1) Select a scene in the left list.\n\n2) In Scene properties, upload an image if the scene has none yet.\n\n3) Click Scene editor.\n\nOn the left are the Grid, Non-player tokens, NPCs, and Traps accordions; on the right is the scene map. Drag an NPC onto the map to add its circular token.\n\nMap navigation: mouse wheel zooms; middle mouse button or Space+left-drag pans the view. Delete / Backspace removes the selected marker on the map.\n\nUnder the accordions, Clear scene removes every trap and token from the current map (it does not delete tokens from the app library).\n\nFor details, see Grid generator, Traps, and Non-player tokens.',
|
||||
'Scene editor is a separate window for preparing the map: battle grid, traps, non-player tokens, and circular NPC tokens. It works for image and video scenes (overlays sit on top of the clip).\n\nOpen it:\n\n1) Select a scene in the left list.\n\n2) In Scene properties, upload an image or video if the scene has none yet.\n\n3) Click Scene editor.\n\nOn the left are the Grid, Non-player tokens, NPCs, and Traps accordions; on the right is the scene map. Drag an NPC onto the map to add its circular token.\n\nMap navigation: mouse wheel zooms; middle mouse button or Space+left-drag pans the view. Delete / Backspace removes the selected marker on the map.\n\nUnder the accordions, Clear scene removes every trap and token from the current map (it does not delete tokens from the app library).\n\nFor details, see Grid generator, Traps, and Non-player tokens.',
|
||||
|
||||
'help.section.grid.title': 'Grid generator',
|
||||
'help.section.grid.body':
|
||||
'The grid generator overlays a battle grid on the scene image — 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 for an image scene (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\nGrid settings are stored with the scene in the project. The generator is not available on video scenes — only on images. The grid draws under trap and token markers and does not block placing them.',
|
||||
'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\nGrid settings are stored with the scene 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.',
|
||||
|
||||
'help.section.traps.title': 'Traps',
|
||||
'help.section.traps.body':
|
||||
'Traps are markers on the scene map for hidden threats and surprises. Placement is stored with the scene in the project; during play you decide when players see them.\n\nIn the scene editor:\n\n1) Open Scene editor for an image scene (see Scene editor).\n\n2) Expand the Traps accordion on the left.\n\n3) Drag a type from the palette onto the map.\n\n4) Drag a marker to move it; drag the corner handle of the selected marker to resize.\n\n5) Delete / Backspace removes the selected trap. Clear scene removes all markers at once.\n\nTypes: Mimic, Explosion, Poison, Pit, Arrow, Laser, and Marker (a generic marker without a special effect).\n\nDuring a session:\n\n1) On the control panel Screen preview you see every trap on the current scene. While still hidden from players, markers look slightly muted on your side.\n\n2) On presentation, markers appear only after reveal or activation.\n\n3) Right-click a marker on the control panel:\n• Reveal — show it to players without triggering.\n• Activate — reveal and play the effect: Mimic, Pit, Arrow, and Laser play animation and sound; Poison and Explosion use the matching control-panel effects (poison cloud / explosion); Marker shows a short flash.\n• Disarm — show it as disarmed.\n\nTrap runtime state (revealed / triggered / disarmed) resets when you start a new session. Markers placed on the map remain.',
|
||||
'Traps are markers on the scene map for hidden threats and surprises. Placement is stored with the scene in the project; during play you decide when players see them.\n\nIn the scene editor:\n\n1) Open Scene editor for an image or video scene (see Scene editor).\n\n2) Expand the Traps accordion on the left.\n\n3) Drag a type from the palette onto the map.\n\n4) Drag a marker to move it; drag the corner handle of the selected marker to resize.\n\n5) Delete / Backspace removes the selected trap. Clear scene removes all markers at once.\n\nTypes: Mimic, Explosion, Poison, Pit, Arrow, Laser, and Marker (a generic marker without a special effect).\n\nDuring a session:\n\n1) On the control panel Screen preview you see every trap on the current scene. While still hidden from players, markers stay clearly readable on your side (dashed outline) so you can find them on the map.\n\n2) On presentation, markers appear only after reveal or activation.\n\n3) Right-click a marker on the control panel:\n• Reveal — show it to players without triggering.\n• Activate — reveal and play the effect: Mimic, Pit, Arrow, and Laser play animation and sound; Poison and Explosion use the matching control-panel effects (poison cloud / explosion); Marker shows a short flash.\n• Disarm — show it as disarmed.\n\nTrap runtime state (revealed / triggered / disarmed) resets when you start a new session. Markers placed on the map remain.',
|
||||
|
||||
'help.section.tokens.title': 'Non-player tokens',
|
||||
'help.section.tokens.body':
|
||||
'Non-player tokens are images of creatures, props, and markers you place on the scene map. The token library lives in the app on this computer (not inside the project file). The scene only stores placements: which token, where it stands, size, and rotation.\n\nIn the scene editor:\n\n1) Open Scene editor for an image scene (see Scene editor).\n\n2) Expand Non-player tokens.\n\n3) Add — enter a unique name and an image (choose file or drop one).\n\n4) Use search to find a token by name.\n\n5) The ⋮ menu on a tile opens Edit or Delete (with confirmation). Deleting from the library also removes that token from the current scene.\n\n6) Drag a tile onto the map to place it. Select a marker: drag to move, corner handle to resize, rotate handle to turn. Delete / Backspace or right-click the marker removes it from the map. Clear scene removes all tokens and traps from the scene.\n\nDuring a session:\n\n1) On the control panel Screen preview, tokens are visible right away (unlike traps, they do not need revealing).\n\n2) Drag tokens with the left button — the new position is kept until the current session ends, including when you return to the scene via Storyline. A new Run resets positions to what you set in the editor.\n\n3) On the presentation screen tokens are display-only: players cannot click or drag them.\n\nWhen you export or import storylines, the needed token files are packed with the line so placements keep their images on another computer.',
|
||||
'Non-player tokens are images of creatures, props, and markers you place on the scene map. The token library lives in the app on this computer (not inside the project file). The scene only stores placements: which token, where it stands, size, and rotation.\n\nIn the scene editor:\n\n1) Open Scene editor for an image or video scene (see Scene editor).\n\n2) Expand Non-player tokens.\n\n3) Add — enter a unique name and an image (choose file or drop one).\n\n4) Use search to find a token by name.\n\n5) The ⋮ menu on a tile opens Edit or Delete (with confirmation). Deleting from the library also removes that token from the current scene.\n\n6) Drag a tile onto the map to place it. Select a marker: drag to move, corner handle to resize, rotate handle to turn. Delete / Backspace removes it from the map. Right-click opens a menu: Set movement / Delete / Reset to path start. Clear scene removes all tokens and traps from the scene.\n\nDuring a session:\n\n1) On the control panel Screen preview, tokens are visible right away (unlike traps, they do not need revealing).\n\n2) Drag tokens with the left button — the new position is kept until the current session ends, including when you return to the scene via Storyline. A new Run resets positions to what you set in the editor.\n\n3) If the scene has a grid, enable Snap tokens to grid on the control panel so dragged tokens snap to cells (see Grid generator).\n\n4) On the presentation screen tokens are display-only: players cannot click or drag them.\n\nWhen you export or import storylines, the needed token files are packed with the line so placements keep their images on another computer.',
|
||||
|
||||
'help.section.campaignAudio.title': 'Game audio',
|
||||
'help.section.campaignAudio.body':
|
||||
@@ -824,14 +824,14 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.materials.title': 'Materials',
|
||||
'help.section.materials.body':
|
||||
'Materials are campaign images (maps, notes, sketches) you can show players on top of the scene during play. They belong to the project and are not tied to one scene.\n\nIn the editor:\n\n1) Under Game properties, click Materials.\n\n2) Add — enter a unique name and an image (PNG, JPG, or WebP) via Choose image or by dropping a file.\n\n3) In the list you can search, reorder by drag-and-drop, and edit or delete via the ⋮ menu (delete asks for confirmation).\n\n4) Under the large preview, Rotate turns the image by 90° (applied in the tile and when shown on screen).\n\nDuring a session:\n\n1) On the control panel under Tools, click the materials button (treasure-map icon) to open a separate window with the list.\n\n2) Click a tile to show the material over the scene on the control preview and presentation; click the same tile again to hide it.\n\n3) On the control preview you can drag the material and resize it from the corners; the × button closes the overlay.\n\n4) In the materials window, the + / − magnifiers are zoom tools: pick one, then click the material on the control preview.\n\nChanging scenes clears the material overlay. Scene description and field effects are separate from materials.',
|
||||
'Materials are campaign images (maps, notes, sketches) you can show players on top of the scene during play. They belong to the project and are not tied to one scene.\n\nIn the editor:\n\n1) Under Game properties, click Materials.\n\n2) Add — enter a unique name and an image (PNG, JPG, or WebP) via Choose image or by dropping a file.\n\n3) In the list you can search, reorder by drag-and-drop, and edit or delete via the ⋮ menu (delete asks for confirmation).\n\n4) Under the large preview, Rotate turns the image by 90° (applied in the tile and when shown on screen).\n\n5) The Legend block for the selected material: enable the legend, place numbered markers on the image, and label the list items. When shown on the control panel and presentation, the legend travels with the material.\n\nDuring a session:\n\n1) On the control panel under Tools, click the materials button (treasure-map icon) to open a separate window with the list.\n\n2) Click a tile to show the material over the scene on the control preview and presentation; click the same tile again to hide it. You can show several materials at once — one click per tile.\n\n3) On the control preview you can drag the material, resize it from the corners, and rotate it; the × button closes that material (others stay open).\n\n4) In the materials window, the + / − magnifiers are zoom tools: pick one, then click the material on the control preview.\n\nChanging scenes clears material overlays. Scene description and field effects are separate from materials.',
|
||||
'help.section.players.title': 'Players',
|
||||
'help.section.players.body':
|
||||
'The Players item in the header stores a local library of live players on this computer (not inside the project file).\n\n1) Open Players in the header.\n\n2) Add player — name and image are required. A progress dialog appears while saving.\n\n3) On the right — player token preview: a circle with a colored ring, avatar inside (drag to recenter; mouse wheel to zoom), name on a dark plate, and a ring color picker.\n\n4) Teams are flat groups with no nesting: create a team and drag a player into it or into No team.\n\nCampaign NPCs are placed separately: in Scene editor open the NPCs accordion and drag project characters onto the map — they appear as the same circular token.',
|
||||
'The Players item in the header stores a local library of live players on this computer (not inside the project file).\n\n1) Open Players in the header.\n\n2) Add player — name and image are required. A progress dialog appears while saving.\n\n3) On the right — player token preview: a circle with a colored ring, avatar inside (drag to recenter; mouse wheel to zoom), name on a dark plate, and a ring color picker.\n\n4) Teams are flat groups with no nesting: create a team and drag a player into it or into No team.\n\nCampaign NPCs are placed separately: in Scene editor open the NPCs accordion and drag project characters onto the map — they appear as the same circular token.\n\nDuring a session you can change the size of circular player and NPC tokens with the Play token size slider on the control panel (see Control panel).',
|
||||
|
||||
'help.section.npcs.title': 'NPCs',
|
||||
'help.section.npcs.body':
|
||||
'NPCs are campaign characters with an avatar, description, and one-way relations between them. They belong to the project.\n\nIn the NPC editor, select a character and use the right inspector to configure their circular token: ring color, avatar position (drag), and zoom (mouse wheel) can be adjusted directly in the preview. Name, description, group, and relations remain available there as well.\n\nIn Scene editor, expand NPCs and drag a character onto the map. The circular marker can be moved and resized. During a session it remains movable on the control preview and is read-only on presentation.',
|
||||
'NPCs are campaign characters with an avatar, description, and one-way relations between them. They belong to the project.\n\nIn the editor:\n\n1) Under Game properties, click NPCs to open the character editor window.\n\n2) Add — enter a unique name and a required avatar (PNG, JPG, or WebP) via Choose image or by dropping a file. Fill in the description if needed.\n\n3) On the left — character list: search, reorder by drag-and-drop; the ⋮ menu only deletes (with confirmation; relations to that character are removed too). Characters can be organized into groups (and subgroups): create a group and drag NPCs into it, or leave them Ungrouped. The relation graph has a group filter.\n\n4) In the center — relation graph: drag an arrow from one character to another and enter a required relation title.\n\n5) On the right — selected character card: circular token, ring color, avatar position (drag and mouse wheel to zoom), name, description, group, and relations. Type sets disposition: hostile, neutral, or friendly — it controls the ring color on the map.\n\n6) In Scene editor, expand NPCs and drag a character onto the map. The circular marker can be moved and resized.\n\nDuring a session on the control panel you can move NPC tokens; right-click a marker:\n• if inactive — Make active;\n• if active — Open information (NPC card), change type (hostile / neutral / friendly), and Make inactive.\nOn presentation tokens are display-only. See Control panel for more.',
|
||||
|
||||
'help.section.session.title': 'Starting a session',
|
||||
'help.section.session.body':
|
||||
@@ -839,7 +839,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'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 NPCs button (colored person icon) opens the characters window — see the NPCs section.\n\nScreen preview shows what players see. On image scenes, paint effects here — they appear on the big screen right away. If the scene has traps, they appear on the preview: right-click a marker to reveal, activate, or disarm (see Traps). Non-player tokens are also visible on the preview and can be moved until the session ends (see Non-player tokens).\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 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 NPCs button (colored person icon) opens the characters window — see the NPCs section.\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 tokens and NPC tokens are also visible on the preview and can be moved until the session ends; right-click an NPC token to activate / open information / change type / make inactive (see NPCs). 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.',
|
||||
|
||||
'help.section.transitions.title': 'Scene transitions',
|
||||
'help.section.transitions.body':
|
||||
@@ -851,11 +851,11 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.effects.title': 'Field and action effects',
|
||||
'help.section.effects.body':
|
||||
'Effects work on image scenes, not video. Paint in Screen preview — players see the same on presentation.\n\nPick a tool on the left:\n• Field effects (fog, rain, fire, water) — hold the left button and brush on the map.\n• Action effects (lightning, sunbeam, freeze, darkness, poison cloud, explosion) — click or short stroke; some include sound.\n\nIf Darken scene is enabled in scene properties, a Darkness control section appears with the Opening brush 🔦 and Closing brush ⬛. Opening brush clears darkness on both screens at once; Closing brush covers revealed areas again. Unrevealed areas stay fully black for players and half-dark on your preview. The state is remembered while the show runs and you return to the same graph card. This is not the same as the Darkness 🌑 action effect.\n\nEraser 🧹 — for field effects (fog, rain, fire, water), brush like the Opening brush for darkness: only the stroke area is erased. Action effects (lightning, sunbeam, etc.) are removed whole when you click or drag over them. Clear effects removes everything at once.\n\nBrush radius under the panel — higher values mean a wider stroke.',
|
||||
'Effects work on image and video scenes. Paint in Screen preview — players see the same on presentation.\n\nPick a tool on the left:\n• Field effects (fog, rain, fire, water) — hold the left button and brush on the map.\n• Action effects (lightning, sunbeam, freeze, darkness, poison cloud, explosion) — click or short stroke; some include sound.\n\nIf Darken scene is enabled in scene properties, a Darkness control section appears with the Opening brush 🔦 and Closing brush ⬛. Opening brush clears darkness on both screens at once; Closing brush covers revealed areas again. Unrevealed areas stay fully black for players and half-dark on your preview. The state is remembered while the show runs and you return to the same graph card. This is not the same as the Darkness 🌑 action effect.\n\nEraser 🧹 — for field effects (fog, rain, fire, water), brush like the Opening brush for darkness: only the stroke area is erased. Action effects (lightning, sunbeam, etc.) are removed whole when you click or drag over them. Clear effects removes everything at once.\n\nBrush radius under the panel — higher values mean a wider stroke.',
|
||||
|
||||
'help.section.presentation.title': 'Presentation screen',
|
||||
'help.section.presentation.body':
|
||||
'Presentation is what players see: the scene image (with rotation from the editor) or video according to your settings.\n\nEffects from the control panel draw on top. There are no GM menus or buttons here — players cannot click the map, traps, or tokens.\n\nIf Darken scene is enabled, players first see a fully black screen. The GM reveals the map with the Opening brush and can cover areas again with the Closing brush on the control panel.\n\nWhen you switch scenes from the control panel, the image updates automatically. Move the window to the display players watch and hide the taskbar if needed.\n\nA blank or dark screen usually means the scene has no preview — add one in scene properties in the editor.',
|
||||
'Presentation is what players see: the scene image or video (with rotation from the editor) according to your settings.\n\nEffects from the control panel draw on top. There are no GM menus or buttons here — players cannot click the map, traps, or tokens.\n\nIf Darken scene is enabled, players first see a fully black screen. The GM reveals the map with the Opening brush and can cover areas again with the Closing brush on the control panel.\n\nWhen you switch scenes from the control panel, the image updates automatically. Move the window to the display players watch and hide the taskbar if needed.\n\nA blank or dark screen usually means the scene has no preview — add one in scene properties in the editor.',
|
||||
|
||||
'help.section.importExport.title': 'Import and export',
|
||||
'help.section.importExport.body':
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
} from '../../../shared/types';
|
||||
import { getDndApi } from '../../shared/dndApi';
|
||||
import { invalidateAssetUrlCache } from '../../shared/useAssetImageUrl';
|
||||
import { applyPreviewRotationToSceneMarkers } from '../../../shared/types/scenePreviewRotation';
|
||||
|
||||
type ProjectSummary = { id: ProjectId; name: string; updatedAt: string; fileName: string };
|
||||
|
||||
@@ -600,6 +601,25 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),
|
||||
layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout,
|
||||
};
|
||||
if (patch.previewRotationDeg !== undefined) {
|
||||
const remapped = applyPreviewRotationToSceneMarkers(
|
||||
{
|
||||
previewRotationDeg: scene.previewRotationDeg ?? 0,
|
||||
tokens: scene.tokens ?? [],
|
||||
npcTokens: scene.npcTokens ?? [],
|
||||
traps: scene.traps ?? [],
|
||||
},
|
||||
patch.previewRotationDeg,
|
||||
{
|
||||
tokensProvided: patch.tokens !== undefined,
|
||||
npcTokensProvided: patch.npcTokens !== undefined,
|
||||
trapsProvided: patch.traps !== undefined,
|
||||
},
|
||||
);
|
||||
if (patch.tokens === undefined) next.tokens = remapped.tokens;
|
||||
if (patch.npcTokens === undefined) next.npcTokens = remapped.npcTokens;
|
||||
if (patch.traps === undefined) next.traps = remapped.traps;
|
||||
}
|
||||
const scenes = { ...p.scenes, [sceneId]: next };
|
||||
const project: Project = { ...p, scenes };
|
||||
return { ...s, project };
|
||||
|
||||
@@ -50,13 +50,16 @@ import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { EllipsisText } from '../shared/ui/EllipsisText';
|
||||
import ellipsisStyles from '../shared/ui/ellipsisText.module.css';
|
||||
import { ContainedVideo } from '../shared/ContainedVideo';
|
||||
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
|
||||
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { TokenPathsOverlay } from '../shared/tokens/TokenPathsOverlay';
|
||||
import { useAppTokens } from '../shared/tokens/useAppTokens';
|
||||
import { TrapGlyph } from '../shared/traps/TrapGlyph';
|
||||
import { Button, Input, Select } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
import { sampleTokenPathAtDistance } from '../../shared/types/tokenPath';
|
||||
|
||||
import styles from './SceneEditorApp.module.css';
|
||||
import { SceneTokenMarker } from './SceneTokenMarker';
|
||||
@@ -234,6 +237,11 @@ export function SceneEditorApp() {
|
||||
y: number;
|
||||
placementId: string;
|
||||
} | null>(null);
|
||||
const [tokenCtxMenu, setTokenCtxMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
placementId: string;
|
||||
} | null>(null);
|
||||
const [selected, setSelected] = useState<Selection>(null);
|
||||
const [view, setView] = useState<LocalView>({ scale: 1, ox: 0.5, oy: 0.5 });
|
||||
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||
@@ -521,6 +529,8 @@ export function SceneEditorApp() {
|
||||
const editingToken =
|
||||
tokenModal?.mode === 'edit' ? (appTokens.find((t) => t.id === tokenModal.tokenId) ?? null) : null;
|
||||
const isImage = scene?.previewAssetType === 'image' && Boolean(url);
|
||||
const isVideo = scene?.previewAssetType === 'video' && Boolean(url);
|
||||
const hasMapMedia = isImage || isVideo;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
@@ -703,8 +713,8 @@ export function SceneEditorApp() {
|
||||
</aside>
|
||||
|
||||
<div className={styles.stage}>
|
||||
{!isImage ? (
|
||||
<div className={styles.empty}>Нужно изображение сцены</div>
|
||||
{!hasMapMedia ? (
|
||||
<div className={styles.empty}>Нужно изображение или видео сцены</div>
|
||||
) : (
|
||||
<div
|
||||
ref={hostRef}
|
||||
@@ -808,6 +818,7 @@ export function SceneEditorApp() {
|
||||
dragRef.current = null;
|
||||
}}
|
||||
>
|
||||
{isImage ? (
|
||||
<RotatedImage
|
||||
url={url!}
|
||||
rotationDeg={rot}
|
||||
@@ -815,7 +826,25 @@ export function SceneEditorApp() {
|
||||
viewCamera={viewCamera}
|
||||
onContentRectChange={setContentRect}
|
||||
/>
|
||||
) : (
|
||||
<ContainedVideo
|
||||
url={url!}
|
||||
rotationDeg={rot}
|
||||
muted
|
||||
playsInline
|
||||
loop
|
||||
preload="metadata"
|
||||
viewCamera={viewCamera}
|
||||
onContentRectChange={setContentRect}
|
||||
/>
|
||||
)}
|
||||
<SceneGridOverlay grid={localGrid} viewport={contentRect} />
|
||||
<TokenPathsOverlay
|
||||
tokens={localTokens}
|
||||
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? localNpcTokens : []}
|
||||
viewport={contentRect}
|
||||
mode="always"
|
||||
/>
|
||||
{contentRect
|
||||
? localTokens.map((tok) => {
|
||||
const minDim = Math.min(contentRect.w, contentRect.h);
|
||||
@@ -834,8 +863,8 @@ export function SceneEditorApp() {
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
persistTokens(tokensRef.current.filter((t) => t.id !== tok.id));
|
||||
setSelected((cur) => (cur?.kind === 'token' && cur.id === tok.id ? null : cur));
|
||||
setSelected({ kind: 'token', id: tok.id });
|
||||
setTokenCtxMenu({ x: e.clientX, y: e.clientY, placementId: tok.id });
|
||||
}}
|
||||
onMovePointerDown={(e) => {
|
||||
if (spaceDownRef.current) return;
|
||||
@@ -1053,6 +1082,84 @@ export function SceneEditorApp() {
|
||||
)
|
||||
: null}
|
||||
|
||||
{tokenCtxMenu
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxMenuBackdrop}
|
||||
aria-label={t('common.close')}
|
||||
onClick={() => setTokenCtxMenu(null)}
|
||||
/>
|
||||
<div
|
||||
className={styles.ctxMenu}
|
||||
style={{ left: tokenCtxMenu.x, top: tokenCtxMenu.y }}
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const id = tokenCtxMenu.placementId;
|
||||
setTokenCtxMenu(null);
|
||||
void api
|
||||
.invoke(ipcChannels.windows.openTokenPathEditor, {
|
||||
kind: 'token',
|
||||
placementId: id,
|
||||
})
|
||||
.catch((err) => console.error('[sceneEditor] openTokenPathEditor', err));
|
||||
}}
|
||||
>
|
||||
Указать движение
|
||||
</button>
|
||||
{(() => {
|
||||
const tok = localTokens.find((item) => item.id === tokenCtxMenu.placementId);
|
||||
if (!tok?.path || tok.path.points.length < 2) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const sample = sampleTokenPathAtDistance(tok.path!, 0);
|
||||
if (sample) {
|
||||
updateToken(tok.id, {
|
||||
nx: sample.nx,
|
||||
ny: sample.ny,
|
||||
...(tok.path?.facingMode === 'fixed'
|
||||
? { rotationDeg: tok.path.fixedRotationDeg }
|
||||
: { rotationDeg: sample.rotationDeg }),
|
||||
});
|
||||
}
|
||||
setTokenCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
Сбросить на старт пути
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItemDanger}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const id = tokenCtxMenu.placementId;
|
||||
setTokenCtxMenu(null);
|
||||
persistTokens(tokensRef.current.filter((item) => item.id !== id));
|
||||
setSelected((current) =>
|
||||
current?.kind === 'token' && current.id === id ? null : current,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
|
||||
{USERS_BRANCH_FEATURES_ENABLED && npcCtxMenu
|
||||
? createPortal(
|
||||
<>
|
||||
@@ -1067,6 +1174,43 @@ export function SceneEditorApp() {
|
||||
style={{ left: npcCtxMenu.x, top: npcCtxMenu.y }}
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const id = npcCtxMenu.placementId;
|
||||
setNpcCtxMenu(null);
|
||||
void api
|
||||
.invoke(ipcChannels.windows.openTokenPathEditor, {
|
||||
kind: 'npcToken',
|
||||
placementId: id,
|
||||
})
|
||||
.catch((err) => console.error('[sceneEditor] openTokenPathEditor', err));
|
||||
}}
|
||||
>
|
||||
Указать движение
|
||||
</button>
|
||||
{(() => {
|
||||
const tok = localNpcTokens.find((item) => item.id === npcCtxMenu.placementId);
|
||||
if (!tok?.path || tok.path.points.length < 2) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const sample = sampleTokenPathAtDistance(tok.path!, 0);
|
||||
if (sample) {
|
||||
updateNpcToken(tok.id, { nx: sample.nx, ny: sample.ny });
|
||||
}
|
||||
setNpcCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
Сбросить на старт пути
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItemDanger}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transform-origin: center;
|
||||
display: block;
|
||||
background: #000;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
containMediaLayout,
|
||||
type MediaRotationDeg,
|
||||
} from '../../shared/types/containMediaRect';
|
||||
import { DEFAULT_SCENE_VIEW_CAMERA, type SceneViewCamera } from '../../shared/types/sceneView';
|
||||
|
||||
import styles from './ContainedVideo.module.css';
|
||||
|
||||
export type ContainedVideoProps = {
|
||||
url: string;
|
||||
/** Same 90° steps as scene image previewRotationDeg. */
|
||||
rotationDeg?: MediaRotationDeg;
|
||||
/** Default contain (map overlays). Cover for editor/graph thumbnails. */
|
||||
mode?: 'contain' | 'cover';
|
||||
/** Зум/пан как у RotatedImage в mode=contain. */
|
||||
viewCamera?: SceneViewCamera | null;
|
||||
onContentRectChange?: ((rect: { x: number; y: number; w: number; h: number }) => void) | undefined;
|
||||
videoRef?: React.Ref<HTMLVideoElement | null>;
|
||||
loop?: boolean;
|
||||
muted?: boolean;
|
||||
playsInline?: boolean;
|
||||
autoPlay?: boolean;
|
||||
preload?: React.VideoHTMLAttributes<HTMLVideoElement>['preload'];
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
onTimeUpdate?: React.VideoHTMLAttributes<HTMLVideoElement>['onTimeUpdate'];
|
||||
onLoadedMetadata?: React.VideoHTMLAttributes<HTMLVideoElement>['onLoadedMetadata'];
|
||||
onLoadedData?: React.VideoHTMLAttributes<HTMLVideoElement>['onLoadedData'];
|
||||
onError?: React.VideoHTMLAttributes<HTMLVideoElement>['onError'];
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
function useElementSize<T extends HTMLElement>() {
|
||||
const ref = useRef<T | null>(null);
|
||||
const [size, setSize] = useState<{ w: number; h: number }>({ w: 0, h: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const readLayoutSize = () => {
|
||||
setSize({ w: el.clientWidth, h: el.clientHeight });
|
||||
};
|
||||
const ro = new ResizeObserver(() => {
|
||||
readLayoutSize();
|
||||
});
|
||||
ro.observe(el);
|
||||
readLayoutSize();
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
return [ref, size] as const;
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: React.Ref<T> | undefined, value: T): void {
|
||||
if (!ref) return;
|
||||
if (typeof ref === 'function') {
|
||||
ref(value);
|
||||
return;
|
||||
}
|
||||
(ref as React.MutableRefObject<T>).current = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Video laid out like RotatedImage: reports the visible content rect
|
||||
* so grid / traps / tokens / effects align with the letterboxed (or cover) frame.
|
||||
*/
|
||||
export function ContainedVideo({
|
||||
url,
|
||||
rotationDeg = 0,
|
||||
mode = 'contain',
|
||||
viewCamera = null,
|
||||
onContentRectChange,
|
||||
videoRef,
|
||||
loop = false,
|
||||
muted = false,
|
||||
playsInline = true,
|
||||
autoPlay = false,
|
||||
preload = 'auto',
|
||||
className,
|
||||
style,
|
||||
onTimeUpdate,
|
||||
onLoadedMetadata,
|
||||
onLoadedData,
|
||||
onError,
|
||||
children,
|
||||
}: ContainedVideoProps) {
|
||||
const [hostRef, size] = useElementSize<HTMLDivElement>();
|
||||
const [mediaSize, setMediaSize] = useState<{ w: number; h: number } | null>(null);
|
||||
const elRef = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
const cam = viewCamera ?? DEFAULT_SCENE_VIEW_CAMERA;
|
||||
const viewScale = mode === 'contain' ? Math.max(1, cam.scale) : 1;
|
||||
const viewOx = mode === 'contain' ? cam.ox : 0.5;
|
||||
const viewOy = mode === 'contain' ? cam.oy : 0.5;
|
||||
|
||||
const syncMediaSize = (el: HTMLVideoElement) => {
|
||||
const w0 = el.videoWidth || 0;
|
||||
const h0 = el.videoHeight || 0;
|
||||
if (w0 <= 0 || h0 <= 0) return;
|
||||
setMediaSize((prev) => (prev && prev.w === w0 && prev.h === h0 ? prev : { w: w0, h: h0 }));
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = elRef.current;
|
||||
if (!el) return;
|
||||
if (el.readyState >= 1) syncMediaSize(el);
|
||||
}, [url]);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
if (!mediaSize) return null;
|
||||
return containMediaLayout({
|
||||
hostW: size.w,
|
||||
hostH: size.h,
|
||||
mediaW: mediaSize.w,
|
||||
mediaH: mediaSize.h,
|
||||
scale: viewScale,
|
||||
ox: viewOx,
|
||||
oy: viewOy,
|
||||
rotationDeg,
|
||||
mode,
|
||||
});
|
||||
}, [mediaSize, mode, rotationDeg, size.h, size.w, viewOx, viewOy, viewScale]);
|
||||
|
||||
const contentRect = layout?.contentRect ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!onContentRectChange || !contentRect) return;
|
||||
onContentRectChange(contentRect);
|
||||
}, [contentRect, onContentRectChange]);
|
||||
|
||||
const leftPx = contentRect ? contentRect.x + contentRect.w / 2 : undefined;
|
||||
const topPx = contentRect ? contentRect.y + contentRect.h / 2 : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
className={[styles.root, className].filter(Boolean).join(' ')}
|
||||
style={style}
|
||||
>
|
||||
<video
|
||||
ref={(el) => {
|
||||
elRef.current = el;
|
||||
assignRef(videoRef, el);
|
||||
}}
|
||||
className={styles.video}
|
||||
src={url}
|
||||
loop={loop}
|
||||
muted={muted}
|
||||
playsInline={playsInline}
|
||||
autoPlay={autoPlay}
|
||||
preload={preload}
|
||||
draggable={false}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onLoadedMetadata={(e) => {
|
||||
syncMediaSize(e.currentTarget);
|
||||
onLoadedMetadata?.(e);
|
||||
}}
|
||||
onLoadedData={onLoadedData}
|
||||
onError={onError}
|
||||
style={{
|
||||
width: layout ? layout.elementW : '100%',
|
||||
height: layout ? layout.elementH : '100%',
|
||||
left: leftPx !== undefined ? `${String(leftPx)}px` : '50%',
|
||||
top: topPx !== undefined ? `${String(topPx)}px` : '50%',
|
||||
objectFit: mediaSize ? undefined : mode,
|
||||
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,11 +24,15 @@ import { useScenePlayerTokensSession } from './playerToken/useScenePlayerTokensS
|
||||
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
|
||||
import { useSceneViewState } from './sceneView/useSceneViewState';
|
||||
import { SceneTokensOverlay } from './tokens/SceneTokensOverlay';
|
||||
import { TokenPathsOverlay } from './tokens/TokenPathsOverlay';
|
||||
import { useAppTokens } from './tokens/useAppTokens';
|
||||
import { useSceneTokensSession } from './tokens/useSceneTokensSession';
|
||||
import { useTokenPathLivePoses } from './tokens/useTokenPathLivePoses';
|
||||
import { useTokenPathSession } from './tokens/useTokenPathSession';
|
||||
import { SceneTrapsOverlay } from './traps/SceneTrapsOverlay';
|
||||
import { useSceneTrapsState } from './traps/useSceneTrapsState';
|
||||
import styles from './PresentationView.module.css';
|
||||
import { ContainedVideo } from './ContainedVideo';
|
||||
import { RotatedImage } from './RotatedImage';
|
||||
import { useAssetUrl } from './useAssetImageUrl';
|
||||
import { useVideoPlaybackState } from './video/useVideoPlaybackState';
|
||||
@@ -60,6 +64,7 @@ export function PresentationView({
|
||||
const [sceneTokensSession] = useSceneTokensSession();
|
||||
const [sceneNpcTokensSession] = useSceneNpcTokensSession();
|
||||
const [scenePlayerTokensSession] = useScenePlayerTokensSession();
|
||||
const [tokenPathSession, tokenPathApi] = useTokenPathSession();
|
||||
const [vp] = useVideoPlaybackState();
|
||||
const videoElRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(
|
||||
@@ -68,6 +73,15 @@ export function PresentationView({
|
||||
const scene =
|
||||
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
|
||||
const project = session?.project;
|
||||
const pathLivePoses = useTokenPathLivePoses({
|
||||
tokens: scene?.tokens ?? [],
|
||||
npcTokens: scene?.npcTokens ?? [],
|
||||
pathSession: tokenPathSession,
|
||||
enabled: Boolean(scene),
|
||||
onMarkDone: (kind, placementId) => {
|
||||
void tokenPathApi.dispatch({ kind: 'markDone', target: { kind, placementId } });
|
||||
},
|
||||
});
|
||||
const activeMaterialItems =
|
||||
project && (materialsOverlay?.activeMaterialIds?.length ?? 0) > 0
|
||||
? (materialsOverlay?.activeMaterialIds ?? [])
|
||||
@@ -171,43 +185,62 @@ export function PresentationView({
|
||||
/>
|
||||
</div>
|
||||
) : originalUrl && scene?.previewAssetType === 'video' ? (
|
||||
<video
|
||||
ref={videoElRef}
|
||||
className={styles.video}
|
||||
src={originalUrl}
|
||||
<div className={styles.fill}>
|
||||
<ContainedVideo
|
||||
url={originalUrl}
|
||||
rotationDeg={rot}
|
||||
videoRef={videoElRef}
|
||||
muted
|
||||
playsInline
|
||||
loop={Boolean(scene?.settings?.loopVideo)}
|
||||
preload="auto"
|
||||
viewCamera={sceneView}
|
||||
onContentRectChange={setContentRect}
|
||||
onError={() => {
|
||||
// noop: status surfaced in control app; keep presentation clean
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.placeholderBg} />
|
||||
)}
|
||||
{scene?.previewAssetType === 'image' ? (
|
||||
{scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video' ? (
|
||||
<SceneGridOverlay grid={scene.grid} viewport={contentRect} />
|
||||
) : null}
|
||||
<div className={styles.vignette} />
|
||||
{scene?.previewAssetType === 'image' && contentRect ? (
|
||||
{(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? (
|
||||
<TokenPathsOverlay
|
||||
tokens={scene.tokens ?? []}
|
||||
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? (scene.npcTokens ?? []) : []}
|
||||
viewport={contentRect}
|
||||
mode="presentation"
|
||||
pathSession={tokenPathSession}
|
||||
/>
|
||||
) : null}
|
||||
{(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? (
|
||||
<SceneTokensOverlay
|
||||
placements={scene.tokens ?? []}
|
||||
library={appTokens}
|
||||
session={sceneTokensSession}
|
||||
viewport={contentRect}
|
||||
poseOverrides={pathLivePoses.tokenPoses}
|
||||
/>
|
||||
) : null}
|
||||
{USERS_BRANCH_FEATURES_ENABLED && scene?.previewAssetType === 'image' && contentRect ? (
|
||||
{USERS_BRANCH_FEATURES_ENABLED &&
|
||||
(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') &&
|
||||
contentRect ? (
|
||||
<SceneNpcTokensOverlay
|
||||
placements={scene.npcTokens ?? []}
|
||||
library={project?.npcs ?? []}
|
||||
session={sceneNpcTokensSession}
|
||||
viewport={contentRect}
|
||||
grid={scene.grid}
|
||||
poseOverrides={pathLivePoses.npcPoses}
|
||||
/>
|
||||
) : null}
|
||||
{USERS_BRANCH_FEATURES_ENABLED && scene?.previewAssetType === 'image' && contentRect ? (
|
||||
{USERS_BRANCH_FEATURES_ENABLED &&
|
||||
(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') &&
|
||||
contentRect ? (
|
||||
<ScenePlayerTokensOverlay
|
||||
library={appPlayers}
|
||||
session={scenePlayerTokensSession}
|
||||
@@ -216,7 +249,7 @@ export function PresentationView({
|
||||
grid={scene.grid}
|
||||
/>
|
||||
) : null}
|
||||
{scene?.previewAssetType === 'image' && contentRect ? (
|
||||
{(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? (
|
||||
<SceneTrapsOverlay
|
||||
traps={scene.traps ?? []}
|
||||
session={sceneTraps}
|
||||
@@ -224,7 +257,7 @@ export function PresentationView({
|
||||
mode="presentation"
|
||||
/>
|
||||
) : null}
|
||||
{showEffects && scene?.previewAssetType !== 'video' ? (
|
||||
{showEffects && (scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') ? (
|
||||
<PixiEffectsOverlay
|
||||
state={fxState}
|
||||
style={{ zIndex: 6 }}
|
||||
@@ -235,10 +268,13 @@ export function PresentationView({
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{showEffects && scene?.previewAssetType !== 'video' ? (
|
||||
{showEffects && (scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') ? (
|
||||
<ExplosionVideoOverlay state={fxState} viewport={contentRect} />
|
||||
) : null}
|
||||
{showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? (
|
||||
{showEffects &&
|
||||
(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') &&
|
||||
scene.darkenScene &&
|
||||
contentRect ? (
|
||||
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} style={{ zIndex: 30 }} />
|
||||
) : null}
|
||||
<SceneOverlayHost active={activeMaterialItems.length > 0 || activeNpcItems.length > 0}>
|
||||
|
||||
@@ -37,6 +37,7 @@ function NpcSprite({
|
||||
ny,
|
||||
viewport,
|
||||
editable,
|
||||
dragEnabled,
|
||||
displayScale,
|
||||
gridFit,
|
||||
disposition,
|
||||
@@ -51,6 +52,7 @@ function NpcSprite({
|
||||
ny: number;
|
||||
viewport: Viewport;
|
||||
editable: boolean;
|
||||
dragEnabled: boolean;
|
||||
displayScale: number;
|
||||
gridFit: number;
|
||||
disposition: NpcDisposition;
|
||||
@@ -74,6 +76,7 @@ function NpcSprite({
|
||||
const pos = localPos ?? { nx, ny };
|
||||
const minDim = Math.min(viewport.w, viewport.h);
|
||||
const sizePx = Math.max(16, placement.sizeN * gridFit * displayScale * minDim);
|
||||
const canDrag = editable && dragEnabled && onMove !== undefined;
|
||||
|
||||
const point = (e: React.PointerEvent) => {
|
||||
const host = e.currentTarget.parentElement;
|
||||
@@ -94,13 +97,14 @@ function NpcSprite({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.token, editable ? styles.editable : ''].filter(Boolean).join(' ')}
|
||||
className={[styles.token, canDrag ? styles.editable : ''].filter(Boolean).join(' ')}
|
||||
data-testid={`session-npc-token-${placement.id}`}
|
||||
style={{
|
||||
left: viewport.x + pos.nx * viewport.w,
|
||||
top: viewport.y + pos.ny * viewport.h,
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
pointerEvents: canDrag || onContextMenu ? 'auto' : undefined,
|
||||
}}
|
||||
onContextMenu={
|
||||
onContextMenu
|
||||
@@ -112,7 +116,7 @@ function NpcSprite({
|
||||
: undefined
|
||||
}
|
||||
onPointerDown={
|
||||
editable && onMove !== undefined
|
||||
canDrag
|
||||
? (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
@@ -132,7 +136,7 @@ function NpcSprite({
|
||||
: undefined
|
||||
}
|
||||
onPointerMove={
|
||||
editable && onMove
|
||||
canDrag
|
||||
? (e) => {
|
||||
const drag = dragRef.current;
|
||||
if (drag?.pointerId !== e.pointerId) return;
|
||||
@@ -173,6 +177,8 @@ export function SceneNpcTokensOverlay({
|
||||
onMove,
|
||||
onContextMenu,
|
||||
snapNorm,
|
||||
poseOverrides = null,
|
||||
dragEnabledById = null,
|
||||
}: {
|
||||
placements: readonly SceneNpcToken[];
|
||||
library: readonly ProjectNpc[];
|
||||
@@ -184,6 +190,8 @@ export function SceneNpcTokensOverlay({
|
||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
poseOverrides?: Record<string, { nx: number; ny: number }> | null;
|
||||
dragEnabledById?: Record<string, boolean> | null;
|
||||
}) {
|
||||
if (!viewport) return null;
|
||||
const byId = new Map(library.map((npc) => [npc.id, npc]));
|
||||
@@ -194,18 +202,22 @@ export function SceneNpcTokensOverlay({
|
||||
{placements.map((placement) => {
|
||||
const npc = byId.get(placement.npcId);
|
||||
if (!npc) return null;
|
||||
const override = session?.byPlacementId[String(placement.id)];
|
||||
const key = String(placement.id);
|
||||
const pose = poseOverrides?.[key];
|
||||
const override = session?.byPlacementId[key];
|
||||
const disposition = resolveNpcTokenDisposition(placement, npc, override?.disposition);
|
||||
const inactive = Boolean(override?.inactive);
|
||||
const dragEnabled = dragEnabledById ? Boolean(dragEnabledById[key]) : true;
|
||||
return (
|
||||
<NpcSprite
|
||||
key={placement.id}
|
||||
placement={placement}
|
||||
npc={npc}
|
||||
nx={override?.nx ?? placement.nx}
|
||||
ny={override?.ny ?? placement.ny}
|
||||
nx={pose?.nx ?? override?.nx ?? placement.nx}
|
||||
ny={pose?.ny ?? override?.ny ?? placement.ny}
|
||||
viewport={viewport}
|
||||
editable={editable}
|
||||
dragEnabled={dragEnabled}
|
||||
displayScale={displayScale}
|
||||
gridFit={gridFit}
|
||||
disposition={disposition}
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.tokenInteractive {
|
||||
pointer-events: auto;
|
||||
cursor: context-menu;
|
||||
}
|
||||
|
||||
.tokenImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -7,6 +7,12 @@ import styles from './SceneTokensOverlay.module.css';
|
||||
|
||||
type Viewport = { x: number; y: number; w: number; h: number };
|
||||
|
||||
export type TokenPoseOverride = {
|
||||
nx: number;
|
||||
ny: number;
|
||||
rotationDeg?: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
placements: readonly SceneToken[];
|
||||
library: readonly AppToken[];
|
||||
@@ -16,24 +22,35 @@ type Props = {
|
||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||
/** Snap во время drag (пульт, привязка к сетке). */
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
onContextMenu?: (e: React.MouseEvent, placement: SceneToken) => void;
|
||||
/** Live path playback / other pose overrides by placement id. */
|
||||
poseOverrides?: Record<string, TokenPoseOverride> | null;
|
||||
/** Per-placement drag lock (e.g. while path is animating). Default: all editable. */
|
||||
dragEnabledById?: Record<string, boolean> | null;
|
||||
};
|
||||
|
||||
function TokenSprite({
|
||||
placement,
|
||||
nx,
|
||||
ny,
|
||||
rotationDeg,
|
||||
viewport,
|
||||
editable,
|
||||
dragEnabled,
|
||||
onMove,
|
||||
snapNorm,
|
||||
onContextMenu,
|
||||
}: {
|
||||
placement: SceneToken;
|
||||
nx: number;
|
||||
ny: number;
|
||||
rotationDeg: number;
|
||||
viewport: Viewport;
|
||||
editable: boolean;
|
||||
dragEnabled: boolean;
|
||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
onContextMenu?: (e: React.MouseEvent, placement: SceneToken) => void;
|
||||
}) {
|
||||
const url = useTokenImageUrl(placement.tokenId);
|
||||
const dragRef = useRef<{
|
||||
@@ -66,6 +83,8 @@ function TokenSprite({
|
||||
const sizePx = Math.max(16, placement.sizeN * minDim);
|
||||
const left = viewport.x + posNx * viewport.w;
|
||||
const top = viewport.y + posNy * viewport.h;
|
||||
const canDrag = editable && dragEnabled && Boolean(onMove);
|
||||
const interactive = canDrag || Boolean(onContextMenu);
|
||||
|
||||
const hostToNorm = (clientX: number, clientY: number, host: HTMLElement) => {
|
||||
const r = host.getBoundingClientRect();
|
||||
@@ -85,7 +104,6 @@ function TokenSprite({
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = 0;
|
||||
}
|
||||
// Финальный commit в session store — один раз на отпускание.
|
||||
onMove?.(String(placement.id), d.lastNx, d.lastNy);
|
||||
setLocalPos({ nx: d.lastNx, ny: d.lastNy });
|
||||
try {
|
||||
@@ -97,16 +115,31 @@ function TokenSprite({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.token, editable ? styles.tokenEditable : ''].filter(Boolean).join(' ')}
|
||||
className={[
|
||||
styles.token,
|
||||
canDrag ? styles.tokenEditable : '',
|
||||
interactive && !canDrag ? styles.tokenInteractive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
transform: `translate(-50%, -50%) rotate(${String(placement.rotationDeg)}deg)`,
|
||||
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
|
||||
}}
|
||||
onContextMenu={
|
||||
onContextMenu
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, placement);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerDown={
|
||||
editable && onMove
|
||||
canDrag
|
||||
? (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
@@ -128,7 +161,7 @@ function TokenSprite({
|
||||
: undefined
|
||||
}
|
||||
onPointerMove={
|
||||
editable && onMove
|
||||
canDrag
|
||||
? (e) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || d.pointerId !== e.pointerId) return;
|
||||
@@ -169,6 +202,9 @@ export function SceneTokensOverlay({
|
||||
editable = false,
|
||||
onMove,
|
||||
snapNorm,
|
||||
onContextMenu,
|
||||
poseOverrides = null,
|
||||
dragEnabledById = null,
|
||||
}: Props) {
|
||||
if (!viewport || placements.length === 0) return null;
|
||||
const known = new Set(library.map((t) => t.id));
|
||||
@@ -179,19 +215,25 @@ export function SceneTokensOverlay({
|
||||
.filter((p) => known.has(p.tokenId))
|
||||
.map((placement) => {
|
||||
const key = String(placement.id);
|
||||
const pose = poseOverrides?.[key];
|
||||
const override = session?.byPlacementId[key] ?? session?.byPlacementId[placement.id];
|
||||
const nx = override?.nx ?? placement.nx;
|
||||
const ny = override?.ny ?? placement.ny;
|
||||
const nx = pose?.nx ?? override?.nx ?? placement.nx;
|
||||
const ny = pose?.ny ?? override?.ny ?? placement.ny;
|
||||
const rotationDeg = pose?.rotationDeg ?? placement.rotationDeg;
|
||||
const dragEnabled = dragEnabledById ? Boolean(dragEnabledById[key]) : true;
|
||||
return (
|
||||
<TokenSprite
|
||||
key={key}
|
||||
placement={placement}
|
||||
nx={nx}
|
||||
ny={ny}
|
||||
rotationDeg={rotationDeg}
|
||||
viewport={viewport}
|
||||
editable={editable}
|
||||
dragEnabled={dragEnabled}
|
||||
{...(onMove ? { onMove } : {})}
|
||||
{...(snapNorm ? { snapNorm } : {})}
|
||||
{...(onContextMenu ? { onContextMenu } : {})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
.layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 7;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.lineGlow {
|
||||
stroke: rgba(40, 180, 255, 0.28);
|
||||
stroke-width: 6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.line {
|
||||
stroke: rgba(90, 210, 255, 0.92);
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-dasharray: 7 5;
|
||||
}
|
||||
|
||||
.start {
|
||||
fill: rgba(120, 230, 255, 0.95);
|
||||
stroke: rgba(0, 0, 0, 0.45);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.closed {
|
||||
stroke: rgba(255, 200, 80, 0.9);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.emphasized .line {
|
||||
stroke: rgba(255, 220, 120, 0.95);
|
||||
stroke-dasharray: none;
|
||||
}
|
||||
|
||||
.emphasized .lineGlow {
|
||||
stroke: rgba(255, 200, 80, 0.35);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { TokenPath } from '../../../shared/types/tokenPath';
|
||||
import { tokenPathPolyline } from '../../../shared/types/tokenPath';
|
||||
import type { SceneNpcToken, SceneToken } from '../../../shared/types';
|
||||
import { tokenPathKey, type TokenPathSessionState } from '../../../shared/types/tokenPathSession';
|
||||
|
||||
import styles from './TokenPathsOverlay.module.css';
|
||||
|
||||
type Viewport = { x: number; y: number; w: number; h: number };
|
||||
|
||||
type PathItem = {
|
||||
key: string;
|
||||
path: TokenPath;
|
||||
emphasized?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
tokens: readonly SceneToken[];
|
||||
npcTokens?: readonly SceneNpcToken[];
|
||||
viewport: Viewport | null;
|
||||
/** control/editor: always; presentation: only presentationVisible */
|
||||
mode: 'always' | 'presentation';
|
||||
pathSession?: TokenPathSessionState | null;
|
||||
/** Highlight path currently edited */
|
||||
emphasizeKey?: string | null;
|
||||
};
|
||||
|
||||
function toSvgPoints(path: TokenPath, viewport: Viewport): string {
|
||||
const pts = tokenPathPolyline(path);
|
||||
return pts
|
||||
.map((p) => {
|
||||
const x = viewport.x + p.nx * viewport.w;
|
||||
const y = viewport.y + p.ny * viewport.h;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function TokenPathsOverlay({
|
||||
tokens,
|
||||
npcTokens = [],
|
||||
viewport,
|
||||
mode,
|
||||
pathSession = null,
|
||||
emphasizeKey = null,
|
||||
}: Props) {
|
||||
if (!viewport) return null;
|
||||
|
||||
const items: PathItem[] = [];
|
||||
for (const t of tokens) {
|
||||
if (!t.path || t.path.points.length < 2) continue;
|
||||
const key = tokenPathKey('token', String(t.id));
|
||||
if (mode === 'presentation' && !pathSession?.presentationVisible[key]) continue;
|
||||
items.push({ key, path: t.path, emphasized: emphasizeKey === key });
|
||||
}
|
||||
for (const t of npcTokens) {
|
||||
if (!t.path || t.path.points.length < 2) continue;
|
||||
const key = tokenPathKey('npcToken', String(t.id));
|
||||
if (mode === 'presentation' && !pathSession?.presentationVisible[key]) continue;
|
||||
items.push({ key, path: t.path, emphasized: emphasizeKey === key });
|
||||
}
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<svg className={styles.layer} width="100%" height="100%" aria-hidden>
|
||||
{items.map((item) => {
|
||||
const pts = toSvgPoints(item.path, viewport);
|
||||
if (!pts) return null;
|
||||
const first = item.path.points[0]!;
|
||||
const fx = viewport.x + first.nx * viewport.w;
|
||||
const fy = viewport.y + first.ny * viewport.h;
|
||||
return (
|
||||
<g key={item.key} className={item.emphasized ? styles.emphasized : undefined}>
|
||||
<polyline className={styles.lineGlow} points={pts} fill="none" />
|
||||
<polyline className={styles.line} points={pts} fill="none" />
|
||||
<circle className={styles.start} cx={fx} cy={fy} r={4} />
|
||||
{item.path.closed ? (
|
||||
<circle
|
||||
className={styles.closed}
|
||||
cx={fx}
|
||||
cy={fy}
|
||||
r={7}
|
||||
fill="none"
|
||||
/>
|
||||
) : null}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useRef, useState } 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* RAF poses for control/presentation while path playback is active.
|
||||
* When phase === 'stopped', pose is omitted so session/placement (drag) wins.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function useTokenPathLivePoses(args: {
|
||||
tokens: readonly SceneToken[];
|
||||
npcTokens: readonly SceneNpcToken[];
|
||||
pathSession: TokenPathSessionState | null;
|
||||
enabled?: boolean;
|
||||
onMarkDone?: (kind: 'token' | 'npcToken', placementId: string, atDist: number) => void;
|
||||
}): PoseMaps {
|
||||
const { tokens, npcTokens, pathSession, enabled = true, onMarkDone } = args;
|
||||
const [poses, setPoses] = useState<PoseMaps>(EMPTY);
|
||||
const markedDoneRef = useRef<Set<string>>(new Set());
|
||||
const onMarkDoneRef = useRef(onMarkDone);
|
||||
onMarkDoneRef.current = onMarkDone;
|
||||
|
||||
const pathSessionRef = useRef(pathSession);
|
||||
pathSessionRef.current = pathSession;
|
||||
const tokensRef = useRef(tokens);
|
||||
tokensRef.current = tokens;
|
||||
const npcTokensRef = useRef(npcTokens);
|
||||
npcTokensRef.current = npcTokens;
|
||||
|
||||
useEffect(() => {
|
||||
markedDoneRef.current.clear();
|
||||
}, [pathSession?.revision]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setPoses(EMPTY);
|
||||
return;
|
||||
}
|
||||
let raf = 0;
|
||||
let alive = true;
|
||||
|
||||
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 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];
|
||||
if (!entry) return;
|
||||
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,
|
||||
};
|
||||
if (result.markDone && !markedDoneRef.current.has(key)) {
|
||||
markedDoneRef.current.add(key);
|
||||
onMarkDoneRef.current?.(kind, placementId, result.sample.dist);
|
||||
}
|
||||
};
|
||||
|
||||
for (const t of tokensRef.current) {
|
||||
if (t.path && t.path.points.length >= 2) {
|
||||
sampleOne('token', String(t.id), t.path, tokenPoses);
|
||||
}
|
||||
}
|
||||
for (const t of npcTokensRef.current) {
|
||||
if (t.path && t.path.points.length >= 2) {
|
||||
sampleOne('npcToken', String(t.id), t.path, npcPoses);
|
||||
}
|
||||
}
|
||||
|
||||
const next: PoseMaps = { tokenPoses, npcPoses };
|
||||
setPoses((prev) => (posesEqual(prev, next) ? prev : next));
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
alive = false;
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return poses;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type { TokenPathSessionEvent, TokenPathSessionState } from '../../../shared/types';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
export function useTokenPathSession(): [
|
||||
TokenPathSessionState | null,
|
||||
{ dispatch: (event: TokenPathSessionEvent) => Promise<void> },
|
||||
] {
|
||||
const api = getDndApi();
|
||||
const [state, setState] = useState<TokenPathSessionState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.tokenPathSession.getState, {}).then(({ state: s }) => {
|
||||
setState(s);
|
||||
});
|
||||
return api.on(ipcChannels.tokenPathSession.stateChanged, ({ state: s }) => {
|
||||
setState(s);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const apiWrap = useMemo(
|
||||
() => ({
|
||||
dispatch: async (event: TokenPathSessionEvent) => {
|
||||
const res = await api.invoke(ipcChannels.tokenPathSession.dispatch, { event });
|
||||
void res;
|
||||
},
|
||||
}),
|
||||
[api],
|
||||
);
|
||||
|
||||
return [state, apiWrap];
|
||||
}
|
||||
@@ -11,13 +11,15 @@
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 2px solid rgba(255, 255, 255, 0.72);
|
||||
background: rgba(12, 14, 20, 0.62);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: auto;
|
||||
cursor: context-menu;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.45),
|
||||
0 0 10px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.trapActive {
|
||||
@@ -30,12 +32,18 @@
|
||||
.trapDisarmed {
|
||||
border-color: #9ca3af;
|
||||
filter: grayscale(0.7);
|
||||
opacity: 0.75;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.trapGmHidden {
|
||||
opacity: 0.55;
|
||||
/* Hidden from players, but still readable for the GM on control preview. */
|
||||
opacity: 0.88;
|
||||
border-style: dashed;
|
||||
border-color: rgba(255, 230, 160, 0.85);
|
||||
background: rgba(28, 24, 12, 0.72);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.4),
|
||||
0 0 12px rgba(255, 200, 80, 0.22);
|
||||
}
|
||||
|
||||
.trapNonInteractive {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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));
|
||||
const rendererRoot = path.resolve(here, '..');
|
||||
|
||||
void test('video scenes share map overlays / effects with image scenes', () => {
|
||||
const control = fs.readFileSync(path.join(rendererRoot, 'control/ControlApp.tsx'), 'utf8');
|
||||
const presentation = fs.readFileSync(path.join(rendererRoot, 'shared/PresentationView.tsx'), 'utf8');
|
||||
const sceneEditor = fs.readFileSync(path.join(rendererRoot, 'sceneEditor/SceneEditorApp.tsx'), 'utf8');
|
||||
const editor = fs.readFileSync(path.join(rendererRoot, 'editor/EditorApp.tsx'), 'utf8');
|
||||
const main = fs.readFileSync(path.join(rendererRoot, '../main/index.ts'), 'utf8');
|
||||
|
||||
assert.equal(control.includes('isVideoPreviewScene'), false);
|
||||
assert.ok(fs.readFileSync(path.join(rendererRoot, 'control/ControlScenePreview.tsx'), 'utf8').includes('ContainedVideo'));
|
||||
|
||||
assert.ok(presentation.includes('ContainedVideo'));
|
||||
assert.ok(presentation.includes("previewAssetType === 'video'"));
|
||||
assert.ok(presentation.includes('SceneTrapsOverlay'));
|
||||
assert.ok(presentation.includes('PixiEffectsOverlay'));
|
||||
assert.ok(presentation.includes('SceneDarknessOverlay'));
|
||||
|
||||
assert.ok(sceneEditor.includes('ContainedVideo'));
|
||||
assert.ok(sceneEditor.includes('hasMapMedia'));
|
||||
assert.ok(sceneEditor.includes('Нужно изображение или видео сцены'));
|
||||
|
||||
assert.ok(editor.includes("previewAssetType === 'image' || previewAssetType === 'video'"));
|
||||
assert.ok(editor.includes('windows.openSceneEditor'));
|
||||
assert.ok(editor.includes('ContainedVideo'));
|
||||
assert.ok(editor.includes('onRotatePreview'));
|
||||
assert.match(
|
||||
editor,
|
||||
/previewAssetId && \(previewAssetType === 'image' \|\| previewAssetType === 'video'\) \? \([\s\S]*?onRotatePreview/,
|
||||
);
|
||||
|
||||
assert.ok(fs.readFileSync(path.join(rendererRoot, 'shared/ContainedVideo.tsx'), 'utf8').includes('rotationDeg'));
|
||||
assert.ok(
|
||||
fs
|
||||
.readFileSync(path.join(rendererRoot, 'control/ControlScenePreview.tsx'), 'utf8')
|
||||
.includes('rotationDeg={rot}'),
|
||||
);
|
||||
assert.ok(presentation.includes('rotationDeg={rot}'));
|
||||
assert.ok(sceneEditor.includes('rotationDeg={rot}'));
|
||||
|
||||
assert.ok(main.includes("scene?.previewAssetType === 'video'"));
|
||||
assert.ok(main.includes('syncSceneDarknessForProject'));
|
||||
assert.match(
|
||||
main,
|
||||
/darkenScene[\s\S]{0,120}previewAssetType === 'image'[\s\S]{0,80}previewAssetType === 'video'/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('control traps: GM-hidden markers stay relatively bright', () => {
|
||||
const css = fs.readFileSync(
|
||||
path.join(rendererRoot, 'shared/traps/SceneTrapsOverlay.module.css'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(css.includes('.trapGmHidden'));
|
||||
assert.doesNotMatch(css, /\.trapGmHidden\s*\{[^}]*opacity:\s*0\.[0-6]/);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<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="/tokenPathEditor/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,183 @@
|
||||
.page {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #12141a);
|
||||
color: var(--text, #e8eaef);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--stroke, #2a2f3a);
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 800;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actions > * {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 11px;
|
||||
opacity: 0.65;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
background: #0b0d12;
|
||||
}
|
||||
|
||||
.host {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.pathSvg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.pathLine {
|
||||
stroke: rgba(90, 210, 255, 0.95);
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-dasharray: 8 5;
|
||||
}
|
||||
|
||||
.point {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
background: rgba(20, 90, 140, 0.92);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.point:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.tokenPreview {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.tokenPreview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ctxMenuBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctxMenu {
|
||||
position: fixed;
|
||||
z-index: 41;
|
||||
min-width: 160px;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--stroke, #2a2f3a);
|
||||
background: #1a1e27;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.ctxItem,
|
||||
.ctxItemDanger {
|
||||
text-align: left;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ctxItemDanger {
|
||||
color: #ff8f8f;
|
||||
}
|
||||
|
||||
.ctxItem:hover,
|
||||
.ctxItemDanger:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||
import type { SceneNpcToken, SceneToken, TokenPath, TokenPathPoint } from '../../shared/types';
|
||||
import {
|
||||
createEmptyTokenPath,
|
||||
normalizeTokenPath,
|
||||
reverseTokenPathPoints,
|
||||
sampleTokenPathAtProgress,
|
||||
tokenPathPolyline,
|
||||
tryAppendPathPoint,
|
||||
} from '../../shared/types/tokenPath';
|
||||
import type { TokenPathTargetKind } from '../../shared/types/tokenPathSession';
|
||||
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { ContainedVideo } from '../shared/ContainedVideo';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { useAppTokens } from '../shared/tokens/useAppTokens';
|
||||
import { useTokenImageUrl } from '../shared/tokens/useTokenImageUrl';
|
||||
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
|
||||
import { normalizeNpcDisposition, npcDispositionRingColor } from '../../shared/types/npcDisposition';
|
||||
import { sceneGridTokenFitFactor } from '../../shared/types/sceneGrid';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
import { Button, Input, Select } from '../shared/ui/controls';
|
||||
|
||||
import styles from './TokenPathEditorApp.module.css';
|
||||
|
||||
type Target = { kind: TokenPathTargetKind; placementId: string };
|
||||
|
||||
type Draft = TokenPath;
|
||||
|
||||
type PointMenu = { x: number; y: number; index: number };
|
||||
|
||||
function cloneDraft(path: TokenPath | null | undefined): Draft {
|
||||
if (!path) return createEmptyTokenPath();
|
||||
return {
|
||||
...path,
|
||||
points: path.points.map((p) => ({ ...p })),
|
||||
};
|
||||
}
|
||||
|
||||
function TokenPreview({
|
||||
kind,
|
||||
token,
|
||||
npcToken,
|
||||
npcName,
|
||||
npcAvatarUrl,
|
||||
ringColor,
|
||||
imageOffset,
|
||||
imageScale,
|
||||
left,
|
||||
top,
|
||||
sizePx,
|
||||
rotationDeg,
|
||||
}: {
|
||||
kind: TokenPathTargetKind;
|
||||
token?: SceneToken;
|
||||
npcToken?: SceneNpcToken;
|
||||
npcName?: string;
|
||||
npcAvatarUrl?: string | null;
|
||||
ringColor?: string;
|
||||
imageOffset?: { x: number; y: number };
|
||||
imageScale?: number;
|
||||
left: number;
|
||||
top: number;
|
||||
sizePx: number;
|
||||
rotationDeg: number;
|
||||
}) {
|
||||
const url = useTokenImageUrl(token?.tokenId ?? null);
|
||||
if (kind === 'token' && token) {
|
||||
return (
|
||||
<div
|
||||
className={styles.tokenPreview}
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
|
||||
}}
|
||||
>
|
||||
{url ? <img src={url} alt="" draggable={false} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kind === 'npcToken' && npcToken) {
|
||||
return (
|
||||
<div className={styles.tokenPreview} style={{ left, top, width: sizePx, height: sizePx }}>
|
||||
<PlayerTokenView
|
||||
name={npcName ?? ''}
|
||||
imageUrl={npcAvatarUrl ?? null}
|
||||
ringColor={ringColor ?? '#888'}
|
||||
sizePx={sizePx}
|
||||
{...(imageOffset ? { imageOffset } : {})}
|
||||
{...(typeof imageScale === 'number' ? { imageScale } : {})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TokenPathEditorApp() {
|
||||
const api = getDndApi();
|
||||
const appTokens = useAppTokens();
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [target, setTarget] = useState<Target | null>(null);
|
||||
const [draft, setDraft] = useState<Draft>(createEmptyTokenPath());
|
||||
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [pointMenu, setPointMenu] = useState<PointMenu | null>(null);
|
||||
const [previewPlaying, setPreviewPlaying] = useState(false);
|
||||
const [previewU, setPreviewU] = useState(0);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragPointRef = useRef<{ index: number; pointerId: number } | null>(null);
|
||||
const saveTimerRef = useRef(0);
|
||||
const draftRef = useRef(draft);
|
||||
draftRef.current = draft;
|
||||
|
||||
const project = session?.project ?? null;
|
||||
const sceneId = project?.currentSceneId ?? null;
|
||||
const scene = sceneId && project ? project.scenes[sceneId] : undefined;
|
||||
const url = useAssetUrl(scene?.previewAssetId ?? null);
|
||||
const rot = scene?.previewRotationDeg ?? 0;
|
||||
const isImage = scene?.previewAssetType === 'image';
|
||||
const isVideo = scene?.previewAssetType === 'video';
|
||||
|
||||
const placement = useMemo(() => {
|
||||
if (!target || !scene) return null;
|
||||
if (target.kind === 'token') {
|
||||
return (scene.tokens ?? []).find((t) => String(t.id) === target.placementId) ?? null;
|
||||
}
|
||||
return (scene.npcTokens ?? []).find((t) => String(t.id) === target.placementId) ?? null;
|
||||
}, [scene, target]);
|
||||
|
||||
const npcMeta = useMemo(() => {
|
||||
if (!target || target.kind !== 'npcToken' || !placement || !('npcId' in placement)) return null;
|
||||
const npc = project?.npcs.find((n) => n.id === placement.npcId);
|
||||
return npc ?? null;
|
||||
}, [placement, project?.npcs, target]);
|
||||
|
||||
const npcAvatarUrl = useAssetUrl(npcMeta?.avatarAssetId ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.project.get, {}).then(({ project: p }) => {
|
||||
setSession({ project: p, currentSceneId: p?.currentSceneId ?? null });
|
||||
});
|
||||
return api.on(ipcChannels.session.stateChanged, ({ state }) => setSession(state));
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.windows.getTokenPathEditorTarget, {}).then((t) => {
|
||||
setTarget(t);
|
||||
});
|
||||
return api.on(ipcChannels.windows.tokenPathEditorTargetChanged, (t) => {
|
||||
setTarget(t);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!placement) {
|
||||
setDraft(createEmptyTokenPath());
|
||||
setDirty(false);
|
||||
return;
|
||||
}
|
||||
setDraft(cloneDraft(placement.path ?? null));
|
||||
setDirty(false);
|
||||
setPreviewPlaying(false);
|
||||
setPreviewU(0);
|
||||
setStatus(null);
|
||||
}, [placement?.id, target?.kind, target?.placementId]);
|
||||
|
||||
const persistDraft = useCallback(
|
||||
(next: Draft, immediate = false) => {
|
||||
if (!sceneId || !target) return;
|
||||
const normalized = normalizeTokenPath(next);
|
||||
const run = () => {
|
||||
if (target.kind === 'token') {
|
||||
const tokens = (scene?.tokens ?? []).map((t) =>
|
||||
String(t.id) === target.placementId ? { ...t, path: normalized } : t,
|
||||
);
|
||||
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { tokens } });
|
||||
} else {
|
||||
const npcTokens = (scene?.npcTokens ?? []).map((t) =>
|
||||
String(t.id) === target.placementId ? { ...t, path: normalized } : t,
|
||||
);
|
||||
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { npcTokens } });
|
||||
}
|
||||
setDirty(false);
|
||||
setStatus(normalized ? 'Сохранено' : 'Путь очищен (нужно ≥2 точки)');
|
||||
};
|
||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||
if (immediate) {
|
||||
run();
|
||||
return;
|
||||
}
|
||||
saveTimerRef.current = window.setTimeout(run, 180);
|
||||
},
|
||||
[api, scene?.npcTokens, scene?.tokens, sceneId, target],
|
||||
);
|
||||
|
||||
const updateDraft = useCallback(
|
||||
(updater: (prev: Draft) => Draft, opts?: { save?: boolean; immediate?: boolean }) => {
|
||||
setDraft((prev) => {
|
||||
const next = updater(prev);
|
||||
draftRef.current = next;
|
||||
if (opts?.save !== false) {
|
||||
setDirty(true);
|
||||
persistDraft(next, opts?.immediate);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[persistDraft],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewPlaying) return;
|
||||
const started = performance.now();
|
||||
const durationMs = Math.max(0.5, draft.durationSec) * 1000;
|
||||
const loopMode = draft.loopMode;
|
||||
const closed = draft.closed;
|
||||
let raf = 0;
|
||||
let stopped = false;
|
||||
const tick = (now: number) => {
|
||||
if (stopped) return;
|
||||
const elapsed = Math.max(0, now - started);
|
||||
let u = 0;
|
||||
if (loopMode === 'pingpong') {
|
||||
const period = Math.max(durationMs * 2, 1e-9);
|
||||
let t = elapsed % period;
|
||||
if (t > durationMs) t = period - t;
|
||||
u = t / durationMs;
|
||||
} else if (loopMode === 'loop' && closed) {
|
||||
u = (elapsed % durationMs) / durationMs;
|
||||
} else {
|
||||
// once (и loop без замыкания)
|
||||
u = Math.min(1, elapsed / durationMs);
|
||||
setPreviewU(u);
|
||||
if (u >= 1) {
|
||||
stopped = true;
|
||||
setPreviewPlaying(false);
|
||||
return;
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
setPreviewU(u);
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
stopped = true;
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [previewPlaying, draft.durationSec, draft.points, draft.closed, draft.loopMode]);
|
||||
|
||||
const hostToNorm = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
const host = hostRef.current;
|
||||
if (!host || !contentRect) return null;
|
||||
const r = host.getBoundingClientRect();
|
||||
return {
|
||||
nx: Math.max(0, Math.min(1, (clientX - (r.left + contentRect.x)) / Math.max(1e-6, contentRect.w))),
|
||||
ny: Math.max(0, Math.min(1, (clientY - (r.top + contentRect.y)) / Math.max(1e-6, contentRect.h))),
|
||||
};
|
||||
},
|
||||
[contentRect],
|
||||
);
|
||||
|
||||
const previewSample = useMemo(() => {
|
||||
if (!previewPlaying || draft.points.length < 2) return null;
|
||||
const normalized = normalizeTokenPath(draft);
|
||||
if (!normalized) return null;
|
||||
return sampleTokenPathAtProgress(normalized, previewU);
|
||||
}, [draft, previewPlaying, previewU]);
|
||||
|
||||
const tokenLeftTop = useMemo(() => {
|
||||
if (!contentRect || !placement) return null;
|
||||
const nx = previewSample?.nx ?? placement.nx;
|
||||
const ny = previewSample?.ny ?? placement.ny;
|
||||
const minDim = Math.min(contentRect.w, contentRect.h);
|
||||
const sizeN =
|
||||
target?.kind === 'npcToken' && 'sizeN' in placement
|
||||
? placement.sizeN * sceneGridTokenFitFactor(scene?.grid ?? null)
|
||||
: placement.sizeN;
|
||||
const sizePx = Math.max(16, sizeN * minDim);
|
||||
return {
|
||||
left: contentRect.x + nx * contentRect.w,
|
||||
top: contentRect.y + ny * contentRect.h,
|
||||
sizePx,
|
||||
rotationDeg:
|
||||
previewSample?.rotationDeg ??
|
||||
(target?.kind === 'token' && 'rotationDeg' in placement ? placement.rotationDeg : 0),
|
||||
};
|
||||
}, [contentRect, placement, previewSample, scene?.grid, target?.kind]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (!target) return 'Движение токена';
|
||||
if (target.kind === 'token') {
|
||||
const tok = placement && 'tokenId' in placement ? appTokens.find((a) => a.id === placement.tokenId) : null;
|
||||
return tok ? `Движение: ${tok.name}` : 'Движение токена';
|
||||
}
|
||||
return npcMeta ? `Движение: ${npcMeta.name}` : 'Движение НПС';
|
||||
}, [appTokens, npcMeta, placement, target]);
|
||||
|
||||
if (!USERS_BRANCH_FEATURES_ENABLED && target?.kind === 'npcToken') {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.empty}>НПС недоступны в этой сборке.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<aside className={styles.sidebar}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
<p className={styles.hint}>
|
||||
ЛКМ по карте — добавить точку. Перетаскивайте точки для правки. ПКМ по точке — замкнуть или удалить.
|
||||
</p>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>Длительность, сек</span>
|
||||
<Input
|
||||
value={String(draft.durationSec)}
|
||||
onChange={(v) => {
|
||||
const n = Number(v);
|
||||
updateDraft((d) => ({
|
||||
...d,
|
||||
durationSec: Number.isFinite(n) ? n : d.durationSec,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>Режим цикла</span>
|
||||
<Select
|
||||
value={draft.loopMode}
|
||||
options={[
|
||||
{ value: 'once', label: 'Один раз' },
|
||||
{ value: 'pingpong', label: 'Туда-обратно' },
|
||||
{
|
||||
value: 'loop',
|
||||
label: draft.closed ? 'Зациклить' : 'Зациклить (нужно замкнуть)',
|
||||
disabled: !draft.closed,
|
||||
},
|
||||
]}
|
||||
onChange={(v) => {
|
||||
updateDraft((d) => ({
|
||||
...d,
|
||||
loopMode: v === 'loop' && !d.closed ? 'once' : (v as Draft['loopMode']),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>Старт</span>
|
||||
<Select
|
||||
value={draft.startMode}
|
||||
options={[
|
||||
{ value: 'onEnter', label: 'При входе в сцену' },
|
||||
{ value: 'delayed', label: 'С задержкой' },
|
||||
]}
|
||||
onChange={(v) => {
|
||||
updateDraft((d) => ({
|
||||
...d,
|
||||
startMode: v === 'delayed' ? 'delayed' : 'onEnter',
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{draft.startMode === 'delayed' ? (
|
||||
<label className={styles.field}>
|
||||
<span>Задержка, сек</span>
|
||||
<Input
|
||||
value={String(draft.delaySec)}
|
||||
onChange={(v) => {
|
||||
const n = Number(v);
|
||||
updateDraft((d) => ({
|
||||
...d,
|
||||
delaySec: Number.isFinite(n) ? n : d.delaySec,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>Ориентация</span>
|
||||
<Select
|
||||
value={draft.facingMode}
|
||||
options={[
|
||||
{ value: 'tangentSmooth', label: 'По касательной' },
|
||||
{ value: 'fixed', label: 'Фиксированный угол' },
|
||||
]}
|
||||
onChange={(v) => {
|
||||
updateDraft((d) => ({
|
||||
...d,
|
||||
facingMode: v === 'fixed' ? 'fixed' : 'tangentSmooth',
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{draft.facingMode === 'fixed' ? (
|
||||
<label className={styles.field}>
|
||||
<span>Угол, °</span>
|
||||
<Input
|
||||
value={String(draft.fixedRotationDeg)}
|
||||
onChange={(v) => {
|
||||
const n = Number(v);
|
||||
updateDraft((d) => ({
|
||||
...d,
|
||||
fixedRotationDeg: Number.isFinite(n) ? n : d.fixedRotationDeg,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
updateDraft((d) => ({
|
||||
...d,
|
||||
points: reverseTokenPathPoints(d.points),
|
||||
}));
|
||||
}}
|
||||
disabled={draft.points.length < 2}
|
||||
>
|
||||
Обратить путь
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setPreviewPlaying((p) => !p);
|
||||
setPreviewU(0);
|
||||
}}
|
||||
disabled={draft.points.length < 2}
|
||||
>
|
||||
{previewPlaying ? 'Стоп превью' : 'Превью'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
updateDraft(() => createEmptyTokenPath(), { immediate: true });
|
||||
setPreviewPlaying(false);
|
||||
}}
|
||||
>
|
||||
Очистить путь
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
persistDraft(draftRef.current, true);
|
||||
}}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.meta}>
|
||||
Точек: {draft.points.length}
|
||||
{draft.closed ? ' · замкнут' : ''}
|
||||
{dirty ? ' · есть изменения' : ''}
|
||||
{status ? ` · ${status}` : ''}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className={styles.main}>
|
||||
{!scene || (!isImage && !isVideo) || !url ? (
|
||||
<div className={styles.empty}>Нет карты сцены для редактирования пути.</div>
|
||||
) : !target || !placement ? (
|
||||
<div className={styles.empty}>Выберите токен в редакторе сцены: ПКМ → «Указать движение».</div>
|
||||
) : (
|
||||
<div
|
||||
ref={hostRef}
|
||||
className={styles.host}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest('[data-path-point]')) return;
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
updateDraft((d) => {
|
||||
const next = tryAppendPathPoint(d.points, p);
|
||||
if (!next) return d;
|
||||
return { ...d, points: next, closed: false, loopMode: d.loopMode === 'loop' ? 'once' : d.loopMode };
|
||||
});
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const drag = dragPointRef.current;
|
||||
if (!drag || drag.pointerId !== e.pointerId) return;
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
updateDraft((d) => {
|
||||
const points = d.points.map((pt, i) => (i === drag.index ? p : pt));
|
||||
return { ...d, points };
|
||||
});
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (dragPointRef.current?.pointerId === e.pointerId) {
|
||||
dragPointRef.current = null;
|
||||
persistDraft(draftRef.current, true);
|
||||
}
|
||||
}}
|
||||
onPointerCancel={(e) => {
|
||||
if (dragPointRef.current?.pointerId === e.pointerId) {
|
||||
dragPointRef.current = null;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isImage ? (
|
||||
<RotatedImage url={url} rotationDeg={rot} mode="contain" onContentRectChange={setContentRect} />
|
||||
) : (
|
||||
<ContainedVideo
|
||||
url={url}
|
||||
rotationDeg={rot}
|
||||
muted
|
||||
playsInline
|
||||
loop
|
||||
preload="metadata"
|
||||
onContentRectChange={setContentRect}
|
||||
/>
|
||||
)}
|
||||
{contentRect ? (
|
||||
<svg className={styles.pathSvg} width="100%" height="100%" aria-hidden>
|
||||
{draft.points.length >= 2 ? (
|
||||
<polyline
|
||||
className={styles.pathLine}
|
||||
fill="none"
|
||||
points={tokenPathPolyline(draft)
|
||||
.map((p) => {
|
||||
const x = contentRect.x + p.nx * contentRect.w;
|
||||
const y = contentRect.y + p.ny * contentRect.h;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(' ')}
|
||||
/>
|
||||
) : null}
|
||||
</svg>
|
||||
) : null}
|
||||
{contentRect
|
||||
? draft.points.map((p: TokenPathPoint, index: number) => {
|
||||
const left = contentRect.x + p.nx * contentRect.w;
|
||||
const top = contentRect.y + p.ny * contentRect.h;
|
||||
return (
|
||||
<button
|
||||
key={`${index}_${p.nx}_${p.ny}`}
|
||||
type="button"
|
||||
data-path-point
|
||||
className={styles.point}
|
||||
style={{ left, top }}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLButtonElement).setPointerCapture(e.pointerId);
|
||||
dragPointRef.current = { index, pointerId: e.pointerId };
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPointMenu({ x: e.clientX, y: e.clientY, index });
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{contentRect && tokenLeftTop && target ? (
|
||||
<TokenPreview
|
||||
kind={target.kind}
|
||||
left={tokenLeftTop.left}
|
||||
top={tokenLeftTop.top}
|
||||
sizePx={tokenLeftTop.sizePx}
|
||||
rotationDeg={tokenLeftTop.rotationDeg}
|
||||
{...(target.kind === 'token' && placement && 'tokenId' in placement
|
||||
? { token: placement as SceneToken }
|
||||
: {})}
|
||||
{...(target.kind === 'npcToken' && placement && 'npcId' in placement
|
||||
? { npcToken: placement as SceneNpcToken }
|
||||
: {})}
|
||||
{...(npcMeta?.name ? { npcName: npcMeta.name } : {})}
|
||||
{...(npcAvatarUrl ? { npcAvatarUrl } : {})}
|
||||
{...(npcMeta
|
||||
? {
|
||||
ringColor: npcDispositionRingColor(
|
||||
normalizeNpcDisposition(
|
||||
(placement && 'disposition' in placement ? placement.disposition : undefined) ??
|
||||
npcMeta.disposition,
|
||||
),
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
{...(npcMeta?.imageOffset ? { imageOffset: npcMeta.imageOffset } : {})}
|
||||
{...(typeof npcMeta?.imageScale === 'number'
|
||||
? { imageScale: npcMeta.imageScale }
|
||||
: {})}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{pointMenu
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxMenuBackdrop}
|
||||
aria-label="Закрыть"
|
||||
onClick={() => setPointMenu(null)}
|
||||
/>
|
||||
<div className={styles.ctxMenu} style={{ left: pointMenu.x, top: pointMenu.y }} role="menu">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
updateDraft((d) => ({
|
||||
...d,
|
||||
closed: true,
|
||||
loopMode: d.loopMode === 'once' ? d.loopMode : d.loopMode,
|
||||
}));
|
||||
setPointMenu(null);
|
||||
}}
|
||||
>
|
||||
Замкнуть путь
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
updateDraft((d) => ({
|
||||
...d,
|
||||
closed: false,
|
||||
loopMode: d.loopMode === 'loop' ? 'once' : d.loopMode,
|
||||
}));
|
||||
setPointMenu(null);
|
||||
}}
|
||||
>
|
||||
Разомкнуть
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItemDanger}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const idx = pointMenu.index;
|
||||
updateDraft((d) => {
|
||||
const points = d.points.filter((_, i) => i !== idx);
|
||||
return {
|
||||
...d,
|
||||
points,
|
||||
closed: points.length >= 2 ? d.closed : false,
|
||||
loopMode: points.length >= 2 && d.closed ? d.loopMode : d.loopMode === 'loop' ? 'once' : d.loopMode,
|
||||
};
|
||||
});
|
||||
setPointMenu(null);
|
||||
}}
|
||||
>
|
||||
Удалить точку
|
||||
</button>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 { TokenPathEditorApp } from './TokenPathEditorApp';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) {
|
||||
throw new Error('Missing #root element');
|
||||
}
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<WindowErrorBoundary title="Движение токена">
|
||||
<EditorI18nProvider>
|
||||
<TokenPathEditorApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -24,6 +24,7 @@ export type AppWindowKind =
|
||||
| 'materials'
|
||||
| 'npcsEditor'
|
||||
| 'sceneEditor'
|
||||
| 'tokenPathEditor'
|
||||
| 'npcs';
|
||||
|
||||
const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
|
||||
@@ -35,6 +36,7 @@ const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
|
||||
materials: { ru: 'Материалы', en: 'Materials' },
|
||||
npcsEditor: { ru: 'НПС', en: 'NPCs' },
|
||||
sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' },
|
||||
tokenPathEditor: { ru: 'Движение токена', en: 'Token path' },
|
||||
npcs: { ru: 'НПС', en: 'NPCs' },
|
||||
};
|
||||
|
||||
|
||||
@@ -43,6 +43,9 @@ import type {
|
||||
SceneViewEvent,
|
||||
SceneViewState,
|
||||
TokenId,
|
||||
TokenPathSessionEvent,
|
||||
TokenPathSessionState,
|
||||
TokenPathTargetKind,
|
||||
VideoPlaybackEvent,
|
||||
VideoPlaybackState,
|
||||
} from '../types';
|
||||
@@ -147,6 +150,10 @@ export const ipcChannels = {
|
||||
closeNpcs: 'windows.closeNpcs',
|
||||
openSceneEditor: 'windows.openSceneEditor',
|
||||
closeSceneEditor: 'windows.closeSceneEditor',
|
||||
openTokenPathEditor: 'windows.openTokenPathEditor',
|
||||
closeTokenPathEditor: 'windows.closeTokenPathEditor',
|
||||
getTokenPathEditorTarget: 'windows.getTokenPathEditorTarget',
|
||||
tokenPathEditorTargetChanged: 'windows.tokenPathEditorTargetChanged',
|
||||
syncChromeTitles: 'windows.syncChromeTitles',
|
||||
getPresentationContentSize: 'windows.getPresentationContentSize',
|
||||
presentationContentSizeChanged: 'windows.presentationContentSizeChanged',
|
||||
@@ -197,6 +204,11 @@ export const ipcChannels = {
|
||||
dispatch: 'sceneTokensSession.dispatch',
|
||||
stateChanged: 'sceneTokensSession.stateChanged',
|
||||
},
|
||||
tokenPathSession: {
|
||||
getState: 'tokenPathSession.getState',
|
||||
dispatch: 'tokenPathSession.dispatch',
|
||||
stateChanged: 'tokenPathSession.stateChanged',
|
||||
},
|
||||
tokenGridSnap: {
|
||||
getState: 'tokenGridSnap.getState',
|
||||
setEnabled: 'tokenGridSnap.setEnabled',
|
||||
@@ -298,6 +310,11 @@ export type IpcEventMap = {
|
||||
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
||||
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
|
||||
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
|
||||
[ipcChannels.tokenPathSession.stateChanged]: { state: TokenPathSessionState };
|
||||
[ipcChannels.windows.tokenPathEditorTargetChanged]: {
|
||||
kind: TokenPathTargetKind;
|
||||
placementId: string;
|
||||
} | null;
|
||||
[ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean };
|
||||
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
||||
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
|
||||
@@ -694,6 +711,18 @@ export type IpcInvokeMap = {
|
||||
req: Record<string, never>;
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.windows.openTokenPathEditor]: {
|
||||
req: { kind: TokenPathTargetKind; placementId: string };
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.windows.closeTokenPathEditor]: {
|
||||
req: Record<string, never>;
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.windows.getTokenPathEditorTarget]: {
|
||||
req: Record<string, never>;
|
||||
res: { kind: TokenPathTargetKind; placementId: string } | null;
|
||||
};
|
||||
[ipcChannels.windows.syncChromeTitles]: {
|
||||
req: { localeTag: string };
|
||||
res: { ok: true };
|
||||
@@ -774,6 +803,14 @@ export type IpcInvokeMap = {
|
||||
req: { event: SceneTokensSessionEvent };
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.tokenPathSession.getState]: {
|
||||
req: Record<string, never>;
|
||||
res: { state: TokenPathSessionState };
|
||||
};
|
||||
[ipcChannels.tokenPathSession.dispatch]: {
|
||||
req: { event: TokenPathSessionEvent };
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.tokenGridSnap.getState]: {
|
||||
req: Record<string, never>;
|
||||
res: { enabled: boolean };
|
||||
@@ -887,6 +924,7 @@ export type LegacyIpcEventMap = {
|
||||
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
||||
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
|
||||
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
|
||||
[ipcChannels.tokenPathSession.stateChanged]: { state: TokenPathSessionState };
|
||||
[ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean };
|
||||
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
||||
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { normalizeNpcDisposition } from './npcDisposition';
|
||||
import type { NpcId, PlayerId, PlayerTeamId, SceneNpcTokenId } from './ids';
|
||||
import { asNpcId, asPlayerId, asPlayerTeamId, asSceneNpcTokenId } from './ids';
|
||||
import { normalizeHexColor } from '../npcs/npcGroups';
|
||||
import { normalizeTokenPath, type TokenPath } from './tokenPath';
|
||||
|
||||
export type { PlayerId, PlayerTeamId, SceneNpcTokenId };
|
||||
export { asPlayerId, asPlayerTeamId, asSceneNpcTokenId };
|
||||
@@ -41,6 +42,8 @@ export type SceneNpcToken = {
|
||||
sizeN: number;
|
||||
/** Состояние экземпляра на карте (копируется из НПС при постановке). */
|
||||
disposition: NpcDisposition;
|
||||
/** Optional movement path (editor → session playback). */
|
||||
path?: TokenPath | null;
|
||||
};
|
||||
|
||||
export const DEFAULT_PLAYER_RING_COLOR = '#c9a227';
|
||||
@@ -207,5 +210,9 @@ export function normalizeSceneNpcToken(raw: unknown): SceneNpcToken | null {
|
||||
ny: Math.max(0, Math.min(1, ny)),
|
||||
sizeN: clampSceneNpcTokenSizeN(typeof obj.sizeN === 'number' ? obj.sizeN : DEFAULT_SCENE_NPC_TOKEN_SIZE_N),
|
||||
disposition: normalizeNpcDisposition((obj as { disposition?: unknown }).disposition),
|
||||
...(() => {
|
||||
const path = normalizeTokenPath((obj as { path?: unknown }).path);
|
||||
return path ? { path } : {};
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { SceneTokenId, TokenId } from './ids';
|
||||
import { asSceneTokenId, asTokenId } from './ids';
|
||||
import { normalizeTokenPath, type TokenPath } from './tokenPath';
|
||||
|
||||
export type { SceneTokenId, TokenId };
|
||||
export { asSceneTokenId, asTokenId };
|
||||
@@ -24,6 +25,8 @@ export type SceneToken = {
|
||||
sizeN: number;
|
||||
/** Непрерывный угол поворота в градусах. */
|
||||
rotationDeg: number;
|
||||
/** Optional movement path (editor → session playback). */
|
||||
path?: TokenPath | null;
|
||||
};
|
||||
|
||||
export const DEFAULT_SCENE_TOKEN_SIZE_N = 0.08;
|
||||
@@ -56,6 +59,7 @@ export function normalizeSceneToken(raw: unknown): SceneToken | null {
|
||||
const sizeN = clampSceneTokenSizeN(typeof obj.sizeN === 'number' ? obj.sizeN : DEFAULT_SCENE_TOKEN_SIZE_N);
|
||||
const rotationDeg =
|
||||
typeof obj.rotationDeg === 'number' && Number.isFinite(obj.rotationDeg) ? obj.rotationDeg : 0;
|
||||
const path = normalizeTokenPath((obj as { path?: unknown }).path);
|
||||
return {
|
||||
id: asSceneTokenId(obj.id),
|
||||
tokenId: asTokenId(obj.tokenId),
|
||||
@@ -63,6 +67,7 @@ export function normalizeSceneToken(raw: unknown): SceneToken | null {
|
||||
ny: Math.max(0, Math.min(1, ny)),
|
||||
sizeN,
|
||||
rotationDeg,
|
||||
...(path ? { path } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { containMediaLayout, containMediaRect } from './containMediaRect';
|
||||
|
||||
void test('containMediaRect: letterboxes 16:9 into square host', () => {
|
||||
const r = containMediaRect({
|
||||
hostW: 400,
|
||||
hostH: 400,
|
||||
mediaW: 1920,
|
||||
mediaH: 1080,
|
||||
scale: 1,
|
||||
ox: 0.5,
|
||||
oy: 0.5,
|
||||
});
|
||||
assert.ok(r);
|
||||
assert.ok(Math.abs(r!.w - 400) < 0.01);
|
||||
assert.ok(Math.abs(r!.h - (400 * 1080) / 1920) < 0.01);
|
||||
assert.ok(Math.abs(r!.x - 0) < 0.01);
|
||||
assert.ok(r!.y > 0);
|
||||
});
|
||||
|
||||
void test('containMediaRect: zoom grows rect around ox/oy', () => {
|
||||
const base = containMediaRect({
|
||||
hostW: 800,
|
||||
hostH: 450,
|
||||
mediaW: 800,
|
||||
mediaH: 450,
|
||||
scale: 1,
|
||||
ox: 0.5,
|
||||
oy: 0.5,
|
||||
});
|
||||
const zoomed = containMediaRect({
|
||||
hostW: 800,
|
||||
hostH: 450,
|
||||
mediaW: 800,
|
||||
mediaH: 450,
|
||||
scale: 2,
|
||||
ox: 0.5,
|
||||
oy: 0.5,
|
||||
});
|
||||
assert.ok(base && zoomed);
|
||||
assert.ok(zoomed!.w > base!.w);
|
||||
assert.ok(zoomed!.x < base!.x);
|
||||
});
|
||||
|
||||
void test('containMediaLayout: 90° swaps fit axes and element stays unrotated size', () => {
|
||||
const layout = containMediaLayout({
|
||||
hostW: 400,
|
||||
hostH: 400,
|
||||
mediaW: 1920,
|
||||
mediaH: 1080,
|
||||
scale: 1,
|
||||
ox: 0.5,
|
||||
oy: 0.5,
|
||||
rotationDeg: 90,
|
||||
});
|
||||
assert.ok(layout);
|
||||
// After 90°, layout AABB is portrait 1080x1920 fitted into square → height fills.
|
||||
assert.ok(Math.abs(layout!.contentRect.h - 400) < 0.01);
|
||||
assert.ok(Math.abs(layout!.contentRect.w - (400 * 1080) / 1920) < 0.01);
|
||||
assert.ok(Math.abs(layout!.elementW - layout!.contentRect.h) < 0.01);
|
||||
assert.ok(Math.abs(layout!.elementH - layout!.contentRect.w) < 0.01);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Pure layout math shared by ContainedVideo / RotatedImage contain-mode.
|
||||
* Kept free of DOM so unit tests can lock overlay alignment for video scenes.
|
||||
*/
|
||||
|
||||
export type MediaRotationDeg = 0 | 90 | 180 | 270;
|
||||
|
||||
export type ContainMediaRect = { x: number; y: number; w: number; h: number };
|
||||
|
||||
export type ContainMediaLayout = {
|
||||
/** Bounding box of the visible media after rotation (overlay coordinate space). */
|
||||
contentRect: ContainMediaRect;
|
||||
/** Unrotated element size; apply CSS rotate(rotationDeg) on the media node. */
|
||||
elementW: number;
|
||||
elementH: number;
|
||||
};
|
||||
|
||||
export function containMediaLayout(args: {
|
||||
hostW: number;
|
||||
hostH: number;
|
||||
mediaW: number;
|
||||
mediaH: number;
|
||||
scale: number;
|
||||
ox: number;
|
||||
oy: number;
|
||||
rotationDeg?: MediaRotationDeg;
|
||||
mode?: 'contain' | 'cover';
|
||||
}): ContainMediaLayout | null {
|
||||
const {
|
||||
hostW,
|
||||
hostH,
|
||||
mediaW,
|
||||
mediaH,
|
||||
scale,
|
||||
ox,
|
||||
oy,
|
||||
rotationDeg = 0,
|
||||
mode = 'contain',
|
||||
} = args;
|
||||
if (hostW <= 1 || hostH <= 1 || mediaW <= 0 || mediaH <= 0) return null;
|
||||
const rotated = rotationDeg === 90 || rotationDeg === 270;
|
||||
const layoutW = rotated ? mediaH : mediaW;
|
||||
const layoutH = rotated ? mediaW : mediaH;
|
||||
const sx = hostW / layoutW;
|
||||
const sy = hostH / layoutH;
|
||||
const fit = mode === 'cover' ? Math.max(sx, sy) : Math.min(sx, sy);
|
||||
const s = fit * Math.max(1, scale);
|
||||
const w = layoutW * s;
|
||||
const h = layoutH * s;
|
||||
return {
|
||||
contentRect: {
|
||||
x: hostW / 2 - ox * w,
|
||||
y: hostH / 2 - oy * h,
|
||||
w,
|
||||
h,
|
||||
},
|
||||
elementW: mediaW * s,
|
||||
elementH: mediaH * s,
|
||||
};
|
||||
}
|
||||
|
||||
export function containMediaRect(args: {
|
||||
hostW: number;
|
||||
hostH: number;
|
||||
mediaW: number;
|
||||
mediaH: number;
|
||||
scale: number;
|
||||
ox: number;
|
||||
oy: number;
|
||||
rotationDeg?: MediaRotationDeg;
|
||||
mode?: 'contain' | 'cover';
|
||||
}): ContainMediaRect | null {
|
||||
return containMediaLayout(args)?.contentRect ?? null;
|
||||
}
|
||||
@@ -10,6 +10,10 @@ export * from './npcs';
|
||||
export * from './sceneDarkness';
|
||||
export * from './sceneGrid';
|
||||
export * from './sceneGridSnap';
|
||||
export * from './tokenPath';
|
||||
export * from './tokenPathSession';
|
||||
export * from './tokenPathPlayback';
|
||||
export * from './scenePreviewRotation';
|
||||
export * from './sceneTraps';
|
||||
export * from './sceneView';
|
||||
export * from './videoPlayback';
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
applyPreviewRotationToSceneMarkers,
|
||||
previewRotationStepsCw,
|
||||
rotateMapNormPointByCwSteps,
|
||||
rotateMapNormPointCw90,
|
||||
rotateSceneTokensByCwSteps,
|
||||
} from './scenePreviewRotation';
|
||||
|
||||
void test('previewRotationStepsCw wraps correctly', () => {
|
||||
assert.equal(previewRotationStepsCw(0, 90), 1);
|
||||
assert.equal(previewRotationStepsCw(90, 180), 1);
|
||||
assert.equal(previewRotationStepsCw(0, 270), 3);
|
||||
assert.equal(previewRotationStepsCw(270, 0), 1);
|
||||
assert.equal(previewRotationStepsCw(90, 90), 0);
|
||||
});
|
||||
|
||||
void test('rotateMapNormPointCw90 matches CSS rotate(90deg) y-down', () => {
|
||||
assert.deepEqual(rotateMapNormPointCw90(0, 0), { nx: 1, ny: 0 });
|
||||
assert.deepEqual(rotateMapNormPointCw90(1, 0), { nx: 1, ny: 1 });
|
||||
assert.deepEqual(rotateMapNormPointCw90(1, 1), { nx: 0, ny: 1 });
|
||||
assert.deepEqual(rotateMapNormPointCw90(0, 1), { nx: 0, ny: 0 });
|
||||
assert.deepEqual(rotateMapNormPointCw90(0.25, 0.1), { nx: 0.9, ny: 0.25 });
|
||||
});
|
||||
|
||||
void test('four Cw90 steps return to start', () => {
|
||||
const start = { nx: 0.2, ny: 0.7 };
|
||||
assert.deepEqual(rotateMapNormPointByCwSteps(start.nx, start.ny, 4), start);
|
||||
});
|
||||
|
||||
void test('rotateSceneTokensByCwSteps also turns facing', () => {
|
||||
const [tok] = rotateSceneTokensByCwSteps([{ id: 'a', nx: 0.2, ny: 0.1, rotationDeg: 15 }], 1);
|
||||
assert.ok(tok);
|
||||
assert.equal(tok.nx, 0.9);
|
||||
assert.equal(tok.ny, 0.2);
|
||||
assert.equal(tok.rotationDeg, 105);
|
||||
});
|
||||
|
||||
void test('applyPreviewRotationToSceneMarkers remaps when rotation changes', () => {
|
||||
const scene = {
|
||||
previewRotationDeg: 0 as const,
|
||||
tokens: [{ id: 't', nx: 0.2, ny: 0.1, rotationDeg: 0 }],
|
||||
npcTokens: [{ id: 'n', nx: 0.5, ny: 0.5 }],
|
||||
traps: [{ id: 'tr', nx: 0, ny: 0 }],
|
||||
};
|
||||
const next = applyPreviewRotationToSceneMarkers(scene, 90);
|
||||
assert.deepEqual(next.tokens[0], { id: 't', nx: 0.9, ny: 0.2, rotationDeg: 90 });
|
||||
assert.deepEqual(next.npcTokens[0], { id: 'n', nx: 0.5, ny: 0.5 });
|
||||
assert.deepEqual(next.traps[0], { id: 'tr', nx: 1, ny: 0 });
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { rotateTokenPathByCwSteps, type TokenPath } from './tokenPath';
|
||||
|
||||
/** Rotate map-normalized markers with scene previewRotationDeg (CSS rotate, y-down). */
|
||||
|
||||
export type PreviewRotationDeg = 0 | 90 | 180 | 270;
|
||||
|
||||
export function asPreviewRotationDeg(raw: unknown): PreviewRotationDeg {
|
||||
return raw === 90 || raw === 180 || raw === 270 ? raw : 0;
|
||||
}
|
||||
|
||||
/** How many 90° clockwise steps take `from` to `to` (0..3). */
|
||||
export function previewRotationStepsCw(from: PreviewRotationDeg, to: PreviewRotationDeg): number {
|
||||
return ((((to - from) / 90) % 4) + 4) % 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* One 90° clockwise step in image/AABB normalized space (origin top-left, y down).
|
||||
* Matches CSS `transform: rotate(90deg)` of the media around its center.
|
||||
*/
|
||||
export function rotateMapNormPointCw90(nx: number, ny: number): { nx: number; ny: number } {
|
||||
return {
|
||||
nx: clamp01(1 - ny),
|
||||
ny: clamp01(nx),
|
||||
};
|
||||
}
|
||||
|
||||
export function rotateMapNormPointByCwSteps(
|
||||
nx: number,
|
||||
ny: number,
|
||||
steps: number,
|
||||
): { nx: number; ny: number } {
|
||||
let x = nx;
|
||||
let y = ny;
|
||||
const n = ((steps % 4) + 4) % 4;
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const next = rotateMapNormPointCw90(x, y);
|
||||
x = next.nx;
|
||||
y = next.ny;
|
||||
}
|
||||
return { nx: x, ny: y };
|
||||
}
|
||||
|
||||
function clamp01(v: number): number {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
return Math.max(0, Math.min(1, v));
|
||||
}
|
||||
|
||||
export function rotateMapMarkersByCwSteps<T extends { nx: number; ny: number; path?: TokenPath | null }>(
|
||||
items: readonly T[],
|
||||
steps: number,
|
||||
): T[] {
|
||||
const n = ((steps % 4) + 4) % 4;
|
||||
if (n === 0) {
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
...(item.path ? { path: rotateTokenPathByCwSteps(item.path, 0) } : null),
|
||||
}));
|
||||
}
|
||||
return items.map((item) => {
|
||||
const p = rotateMapNormPointByCwSteps(item.nx, item.ny, n);
|
||||
const next: T = { ...item, nx: p.nx, ny: p.ny };
|
||||
if (item.path) {
|
||||
(next as { path?: TokenPath | null }).path = rotateTokenPathByCwSteps(item.path, n);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/** Non-player tokens: move with the map and keep facing relative to the art. */
|
||||
export function rotateSceneTokensByCwSteps<
|
||||
T extends { nx: number; ny: number; rotationDeg: number; path?: TokenPath | null },
|
||||
>(items: readonly T[], steps: number): T[] {
|
||||
const n = ((steps % 4) + 4) % 4;
|
||||
if (n === 0) {
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
...(item.path ? { path: rotateTokenPathByCwSteps(item.path, 0) } : null),
|
||||
}));
|
||||
}
|
||||
const delta = n * 90;
|
||||
return items.map((item) => {
|
||||
const p = rotateMapNormPointByCwSteps(item.nx, item.ny, n);
|
||||
const next: T = { ...item, nx: p.nx, ny: p.ny, rotationDeg: item.rotationDeg + delta };
|
||||
if (item.path) {
|
||||
(next as { path?: TokenPath | null }).path = rotateTokenPathByCwSteps(item.path, n);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply marker remaps when preview rotation changes.
|
||||
* Skips collections explicitly provided in the patch (caller already sent final values).
|
||||
*/
|
||||
export function applyPreviewRotationToSceneMarkers<
|
||||
TScene extends {
|
||||
previewRotationDeg: PreviewRotationDeg;
|
||||
tokens: readonly { nx: number; ny: number; rotationDeg: number }[];
|
||||
npcTokens: readonly { nx: number; ny: number }[];
|
||||
traps: readonly { nx: number; ny: number }[];
|
||||
},
|
||||
>(
|
||||
scene: TScene,
|
||||
nextRotationDeg: PreviewRotationDeg,
|
||||
opts?: {
|
||||
tokensProvided?: boolean;
|
||||
npcTokensProvided?: boolean;
|
||||
trapsProvided?: boolean;
|
||||
},
|
||||
): Pick<TScene, 'tokens' | 'npcTokens' | 'traps'> {
|
||||
const steps = previewRotationStepsCw(scene.previewRotationDeg, nextRotationDeg);
|
||||
return {
|
||||
tokens: opts?.tokensProvided
|
||||
? (scene.tokens as TScene['tokens'])
|
||||
: (rotateSceneTokensByCwSteps(scene.tokens, steps) as TScene['tokens']),
|
||||
npcTokens: opts?.npcTokensProvided
|
||||
? (scene.npcTokens as TScene['npcTokens'])
|
||||
: (rotateMapMarkersByCwSteps(scene.npcTokens, steps) as TScene['npcTokens']),
|
||||
traps: opts?.trapsProvided
|
||||
? (scene.traps as TScene['traps'])
|
||||
: (rotateMapMarkersByCwSteps(scene.traps, steps) as TScene['traps']),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
findNearestAheadDistance,
|
||||
normalizeTokenPath,
|
||||
reverseTokenPathPoints,
|
||||
rotateTokenPathByCwSteps,
|
||||
sampleTokenPathAtProgress,
|
||||
tokenPathTotalLength,
|
||||
tryAppendPathPoint,
|
||||
TOKEN_PATH_MIN_POINT_DIST,
|
||||
} from './tokenPath';
|
||||
|
||||
void test('tryAppendPathPoint rejects double-click distance', () => {
|
||||
const a = tryAppendPathPoint([], { nx: 0.1, ny: 0.1 });
|
||||
assert.ok(a);
|
||||
const b = tryAppendPathPoint(a!, { nx: 0.1 + TOKEN_PATH_MIN_POINT_DIST / 2, ny: 0.1 });
|
||||
assert.equal(b, null);
|
||||
const c = tryAppendPathPoint(a!, { nx: 0.5, ny: 0.5 });
|
||||
assert.equal(c?.length, 2);
|
||||
});
|
||||
|
||||
void test('normalizeTokenPath requires ≥2 points', () => {
|
||||
assert.equal(normalizeTokenPath({ points: [{ nx: 0.1, ny: 0.1 }] }), null);
|
||||
const p = normalizeTokenPath({
|
||||
points: [
|
||||
{ nx: 0.1, ny: 0.1 },
|
||||
{ nx: 0.5, ny: 0.1 },
|
||||
],
|
||||
durationSec: 10,
|
||||
closed: true,
|
||||
loopMode: 'loop',
|
||||
});
|
||||
assert.ok(p);
|
||||
assert.equal(p!.loopMode, 'loop');
|
||||
assert.equal(p!.closed, true);
|
||||
});
|
||||
|
||||
void test('loop forced to once when not closed', () => {
|
||||
const p = normalizeTokenPath({
|
||||
points: [
|
||||
{ nx: 0, ny: 0 },
|
||||
{ nx: 1, ny: 0 },
|
||||
],
|
||||
closed: false,
|
||||
loopMode: 'loop',
|
||||
});
|
||||
assert.ok(p);
|
||||
assert.equal(p!.loopMode, 'once');
|
||||
});
|
||||
|
||||
void test('sampleTokenPathAtProgress endpoints', () => {
|
||||
const p = normalizeTokenPath({
|
||||
points: [
|
||||
{ nx: 0, ny: 0.5 },
|
||||
{ nx: 1, ny: 0.5 },
|
||||
],
|
||||
durationSec: 5,
|
||||
facingMode: 'fixed',
|
||||
fixedRotationDeg: 45,
|
||||
});
|
||||
assert.ok(p);
|
||||
const a = sampleTokenPathAtProgress(p!, 0);
|
||||
const b = sampleTokenPathAtProgress(p!, 1);
|
||||
assert.ok(a && b);
|
||||
assert.ok(Math.abs(a!.nx - 0) < 1e-6);
|
||||
assert.ok(Math.abs(b!.nx - 1) < 1e-6);
|
||||
assert.equal(a!.rotationDeg, 45);
|
||||
});
|
||||
|
||||
void test('reverse and rotate path', () => {
|
||||
const pts = reverseTokenPathPoints([
|
||||
{ nx: 0.1, ny: 0.2 },
|
||||
{ nx: 0.8, ny: 0.2 },
|
||||
]);
|
||||
assert.deepEqual(pts[0], { nx: 0.8, ny: 0.2 });
|
||||
const path = normalizeTokenPath({
|
||||
points: [
|
||||
{ nx: 0.2, ny: 0.1 },
|
||||
{ nx: 0.8, ny: 0.1 },
|
||||
],
|
||||
fixedRotationDeg: 10,
|
||||
})!;
|
||||
const rotated = rotateTokenPathByCwSteps(path, 1);
|
||||
assert.ok(Math.abs(rotated.points[0]!.nx - 0.9) < 1e-6);
|
||||
assert.ok(Math.abs(rotated.points[0]!.ny - 0.2) < 1e-6);
|
||||
assert.equal(rotated.fixedRotationDeg, 100);
|
||||
assert.ok(tokenPathTotalLength(path) > 0);
|
||||
});
|
||||
|
||||
void test('findNearestAheadDistance prefers ahead of fromDist', () => {
|
||||
const path = normalizeTokenPath({
|
||||
points: [
|
||||
{ nx: 0, ny: 0.5 },
|
||||
{ nx: 1, ny: 0.5 },
|
||||
],
|
||||
})!;
|
||||
const mid = findNearestAheadDistance(path, 0.7, 0.5, 0.2);
|
||||
assert.ok(mid > 0.2);
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
/** Path animation for scene tokens / NPC tokens (project-persisted). */
|
||||
|
||||
export type TokenPathPoint = { nx: number; ny: number };
|
||||
|
||||
/** How the token faces while moving. */
|
||||
export type TokenPathFacingMode = 'tangentSmooth' | 'fixed';
|
||||
|
||||
/**
|
||||
* once — play to end and stay.
|
||||
* pingpong — reverse at ends forever (until stopped).
|
||||
* loop — only when path is closed (explicit «Замкнуть»).
|
||||
*/
|
||||
export type TokenPathLoopMode = 'once' | 'pingpong' | 'loop';
|
||||
|
||||
/** When playback starts after the scene becomes current. */
|
||||
export type TokenPathStartMode = 'onEnter' | 'delayed';
|
||||
|
||||
export type TokenPath = {
|
||||
points: TokenPathPoint[];
|
||||
/** Explicit close (RMB «Замкнуть» on a point). Enables loop mode. */
|
||||
closed: boolean;
|
||||
loopMode: TokenPathLoopMode;
|
||||
/** Seconds to traverse the full path once (or one direction for pingpong). */
|
||||
durationSec: number;
|
||||
startMode: TokenPathStartMode;
|
||||
/** Delay after scene enter when startMode === 'delayed'. */
|
||||
delaySec: number;
|
||||
facingMode: TokenPathFacingMode;
|
||||
/** Used when facingMode === 'fixed'. */
|
||||
fixedRotationDeg: number;
|
||||
};
|
||||
|
||||
export const TOKEN_PATH_MIN_POINT_DIST = 0.02;
|
||||
export const TOKEN_PATH_DURATION_MIN_SEC = 0.5;
|
||||
export const TOKEN_PATH_DURATION_MAX_SEC = 600;
|
||||
export const TOKEN_PATH_DELAY_MAX_SEC = 600;
|
||||
export const DEFAULT_TOKEN_PATH_DURATION_SEC = 8;
|
||||
export const DEFAULT_TOKEN_PATH_DELAY_SEC = 3;
|
||||
|
||||
export function clamp01(v: number): number {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
return Math.max(0, Math.min(1, v));
|
||||
}
|
||||
|
||||
export function distNorm(a: TokenPathPoint, b: TokenPathPoint): number {
|
||||
const dx = a.nx - b.nx;
|
||||
const dy = a.ny - b.ny;
|
||||
return Math.hypot(dx, dy);
|
||||
}
|
||||
|
||||
export function createEmptyTokenPath(): TokenPath {
|
||||
return {
|
||||
points: [],
|
||||
closed: false,
|
||||
loopMode: 'once',
|
||||
durationSec: DEFAULT_TOKEN_PATH_DURATION_SEC,
|
||||
startMode: 'onEnter',
|
||||
delaySec: DEFAULT_TOKEN_PATH_DELAY_SEC,
|
||||
facingMode: 'tangentSmooth',
|
||||
fixedRotationDeg: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTokenPathFacingMode(raw: unknown): TokenPathFacingMode {
|
||||
return raw === 'fixed' ? 'fixed' : 'tangentSmooth';
|
||||
}
|
||||
|
||||
export function normalizeTokenPathLoopMode(raw: unknown, closed: boolean): TokenPathLoopMode {
|
||||
if (raw === 'pingpong') return 'pingpong';
|
||||
if (raw === 'loop' && closed) return 'loop';
|
||||
return 'once';
|
||||
}
|
||||
|
||||
export function normalizeTokenPathStartMode(raw: unknown): TokenPathStartMode {
|
||||
return raw === 'delayed' ? 'delayed' : 'onEnter';
|
||||
}
|
||||
|
||||
export function clampTokenPathDurationSec(raw: unknown): number {
|
||||
const n = typeof raw === 'number' && Number.isFinite(raw) ? raw : DEFAULT_TOKEN_PATH_DURATION_SEC;
|
||||
return Math.max(TOKEN_PATH_DURATION_MIN_SEC, Math.min(TOKEN_PATH_DURATION_MAX_SEC, n));
|
||||
}
|
||||
|
||||
export function clampTokenPathDelaySec(raw: unknown): number {
|
||||
const n = typeof raw === 'number' && Number.isFinite(raw) ? raw : DEFAULT_TOKEN_PATH_DELAY_SEC;
|
||||
return Math.max(0, Math.min(TOKEN_PATH_DELAY_MAX_SEC, n));
|
||||
}
|
||||
|
||||
export function normalizeTokenPathPoint(raw: unknown): TokenPathPoint | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const obj = raw as Partial<TokenPathPoint>;
|
||||
if (typeof obj.nx !== 'number' || !Number.isFinite(obj.nx)) return null;
|
||||
if (typeof obj.ny !== 'number' || !Number.isFinite(obj.ny)) return null;
|
||||
return { nx: clamp01(obj.nx), ny: clamp01(obj.ny) };
|
||||
}
|
||||
|
||||
/** Drop points closer than TOKEN_PATH_MIN_POINT_DIST to the previous kept point. */
|
||||
export function filterMinPointDistance(points: readonly TokenPathPoint[]): TokenPathPoint[] {
|
||||
if (points.length === 0) return [];
|
||||
const out: TokenPathPoint[] = [{ ...points[0]! }];
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
const p = points[i]!;
|
||||
const prev = out[out.length - 1]!;
|
||||
if (distNorm(prev, p) + 1e-9 >= TOKEN_PATH_MIN_POINT_DIST) {
|
||||
out.push({ ...p });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeTokenPath(raw: unknown): TokenPath | null {
|
||||
if (raw == null) return null;
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const obj = raw as Partial<TokenPath>;
|
||||
const points0 = Array.isArray(obj.points)
|
||||
? obj.points.map(normalizeTokenPathPoint).filter((p): p is TokenPathPoint => Boolean(p))
|
||||
: [];
|
||||
const points = filterMinPointDistance(points0);
|
||||
if (points.length < 2) return null;
|
||||
const closed = Boolean(obj.closed);
|
||||
const loopMode = normalizeTokenPathLoopMode(obj.loopMode, closed);
|
||||
return {
|
||||
points,
|
||||
closed,
|
||||
loopMode: closed ? loopMode : loopMode === 'loop' ? 'once' : loopMode,
|
||||
durationSec: clampTokenPathDurationSec(obj.durationSec),
|
||||
startMode: normalizeTokenPathStartMode(obj.startMode),
|
||||
delaySec: clampTokenPathDelaySec(obj.delaySec),
|
||||
facingMode: normalizeTokenPathFacingMode(obj.facingMode),
|
||||
fixedRotationDeg:
|
||||
typeof obj.fixedRotationDeg === 'number' && Number.isFinite(obj.fixedRotationDeg)
|
||||
? obj.fixedRotationDeg
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Points used for drawing/length: if closed, append first at end when not already equal. */
|
||||
export function tokenPathPolyline(path: TokenPath): TokenPathPoint[] {
|
||||
if (path.points.length === 0) return [];
|
||||
if (!path.closed) return path.points.map((p) => ({ ...p }));
|
||||
const first = path.points[0]!;
|
||||
const last = path.points[path.points.length - 1]!;
|
||||
if (distNorm(first, last) < 1e-6) return path.points.map((p) => ({ ...p }));
|
||||
return [...path.points.map((p) => ({ ...p })), { ...first }];
|
||||
}
|
||||
|
||||
export function tokenPathTotalLength(path: TokenPath): number {
|
||||
const pts = tokenPathPolyline(path);
|
||||
let len = 0;
|
||||
for (let i = 1; i < pts.length; i += 1) {
|
||||
len += distNorm(pts[i - 1]!, pts[i]!);
|
||||
}
|
||||
return len;
|
||||
}
|
||||
|
||||
export type TokenPathSample = {
|
||||
nx: number;
|
||||
ny: number;
|
||||
/** Degrees, CSS-style (0 = right? we use same as token rotationDeg — image up + rotate). */
|
||||
rotationDeg: number;
|
||||
/** Distance along polyline [0, length]. */
|
||||
dist: number;
|
||||
};
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function segmentAngleDeg(a: TokenPathPoint, b: TokenPathPoint): number {
|
||||
// Screen y-down: atan2(dy, dx) with dy positive downward.
|
||||
const deg = (Math.atan2(b.ny - a.ny, b.nx - a.nx) * 180) / Math.PI;
|
||||
// Token art faces "up" (−Y); rotate so forward matches travel.
|
||||
return deg + 90;
|
||||
}
|
||||
|
||||
function shortestAngleLerp(from: number, to: number, t: number): number {
|
||||
let delta = ((to - from + 540) % 360) - 180;
|
||||
return from + delta * t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample position/facing at distance `dist` along the polyline (clamped).
|
||||
*/
|
||||
export function sampleTokenPathAtDistance(path: TokenPath, dist: number): TokenPathSample | null {
|
||||
const pts = tokenPathPolyline(path);
|
||||
if (pts.length < 2) return null;
|
||||
const total = tokenPathTotalLength(path);
|
||||
if (total <= 1e-9) {
|
||||
const p = pts[0]!;
|
||||
return {
|
||||
nx: p.nx,
|
||||
ny: p.ny,
|
||||
rotationDeg: path.facingMode === 'fixed' ? path.fixedRotationDeg : 0,
|
||||
dist: 0,
|
||||
};
|
||||
}
|
||||
const d = Math.max(0, Math.min(total, dist));
|
||||
let walked = 0;
|
||||
for (let i = 1; i < pts.length; i += 1) {
|
||||
const a = pts[i - 1]!;
|
||||
const b = pts[i]!;
|
||||
const seg = distNorm(a, b);
|
||||
if (seg <= 1e-9) continue;
|
||||
if (walked + seg >= d - 1e-9) {
|
||||
const t = (d - walked) / seg;
|
||||
const nx = lerp(a.nx, b.nx, t);
|
||||
const ny = lerp(a.ny, b.ny, t);
|
||||
let rotationDeg = path.fixedRotationDeg;
|
||||
if (path.facingMode === 'tangentSmooth') {
|
||||
const ang = segmentAngleDeg(a, b);
|
||||
// Blend with previous segment for smoother corners.
|
||||
if (i >= 2) {
|
||||
const prevA = pts[i - 2]!;
|
||||
const prevAng = segmentAngleDeg(prevA, a);
|
||||
rotationDeg = shortestAngleLerp(prevAng, ang, Math.min(1, t + 0.35));
|
||||
} else {
|
||||
rotationDeg = ang;
|
||||
}
|
||||
}
|
||||
return { nx, ny, rotationDeg, dist: d };
|
||||
}
|
||||
walked += seg;
|
||||
}
|
||||
const last = pts[pts.length - 1]!;
|
||||
const prev = pts[pts.length - 2]!;
|
||||
return {
|
||||
nx: last.nx,
|
||||
ny: last.ny,
|
||||
rotationDeg:
|
||||
path.facingMode === 'fixed' ? path.fixedRotationDeg : segmentAngleDeg(prev, last),
|
||||
dist: total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress u in [0,1] over one durationSec pass (pingpong handled by caller via triangle wave).
|
||||
*/
|
||||
export function sampleTokenPathAtProgress(path: TokenPath, u: number): TokenPathSample | null {
|
||||
const total = tokenPathTotalLength(path);
|
||||
const t = Math.max(0, Math.min(1, u));
|
||||
return sampleTokenPathAtDistance(path, t * total);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nearest point on the polyline that lies at or ahead of `fromDist` along the route direction.
|
||||
* Returns distance along path to that sample (for resume-after-drag).
|
||||
*/
|
||||
export function findNearestAheadDistance(
|
||||
path: TokenPath,
|
||||
nx: number,
|
||||
ny: number,
|
||||
fromDist: number,
|
||||
): number {
|
||||
const pts = tokenPathPolyline(path);
|
||||
const total = tokenPathTotalLength(path);
|
||||
if (pts.length < 2 || total <= 1e-9) return 0;
|
||||
const start = Math.max(0, Math.min(total, fromDist));
|
||||
const samples = 64;
|
||||
let bestDist = start;
|
||||
let bestErr = Number.POSITIVE_INFINITY;
|
||||
for (let i = 0; i <= samples; i += 1) {
|
||||
const d = start + ((total - start) * i) / samples;
|
||||
const s = sampleTokenPathAtDistance(path, d);
|
||||
if (!s) continue;
|
||||
const err = Math.hypot(s.nx - nx, s.ny - ny);
|
||||
if (err < bestErr) {
|
||||
bestErr = err;
|
||||
bestDist = d;
|
||||
}
|
||||
}
|
||||
// Also check remaining if pingpong will reverse — for once/loop, ahead is enough.
|
||||
return bestDist;
|
||||
}
|
||||
|
||||
export function reverseTokenPathPoints(points: readonly TokenPathPoint[]): TokenPathPoint[] {
|
||||
return [...points].reverse().map((p) => ({ ...p }));
|
||||
}
|
||||
|
||||
export function rotateTokenPathByCwSteps(path: TokenPath, steps: number): TokenPath {
|
||||
const n = ((steps % 4) + 4) % 4;
|
||||
if (n === 0) {
|
||||
return {
|
||||
...path,
|
||||
points: path.points.map((p) => ({ ...p })),
|
||||
};
|
||||
}
|
||||
// Import inline to avoid circular deps — callers use scenePreviewRotation.
|
||||
// We duplicate the CW formula here for a self-contained module OR accept a rotator.
|
||||
let points = path.points.map((p) => ({ ...p }));
|
||||
for (let s = 0; s < n; s += 1) {
|
||||
points = points.map((p) => ({ nx: clamp01(1 - p.ny), ny: clamp01(p.nx) }));
|
||||
}
|
||||
const fixedRotationDeg = path.fixedRotationDeg + n * 90;
|
||||
return { ...path, points, fixedRotationDeg };
|
||||
}
|
||||
|
||||
/** Try add a point; returns null if too close to the last point. */
|
||||
export function tryAppendPathPoint(
|
||||
points: readonly TokenPathPoint[],
|
||||
next: TokenPathPoint,
|
||||
): TokenPathPoint[] | null {
|
||||
const p = { nx: clamp01(next.nx), ny: clamp01(next.ny) };
|
||||
if (points.length === 0) return [p];
|
||||
const last = points[points.length - 1]!;
|
||||
if (distNorm(last, p) < TOKEN_PATH_MIN_POINT_DIST) return null;
|
||||
return [...points, p];
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { computePathPlaybackSample, resumeFromPose } from './tokenPathPlayback';
|
||||
import type { TokenPath } from './tokenPath';
|
||||
import type { TokenPathPlaybackEntry } from './tokenPathSession';
|
||||
|
||||
function path(): TokenPath {
|
||||
return {
|
||||
points: [
|
||||
{ nx: 0, ny: 0.5 },
|
||||
{ nx: 1, ny: 0.5 },
|
||||
],
|
||||
closed: false,
|
||||
loopMode: 'once',
|
||||
durationSec: 2,
|
||||
startMode: 'onEnter',
|
||||
delaySec: 0,
|
||||
facingMode: 'tangentSmooth',
|
||||
fixedRotationDeg: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function entry(partial: Partial<TokenPathPlaybackEntry>): TokenPathPlaybackEntry {
|
||||
return {
|
||||
kind: 'token',
|
||||
placementId: 't1',
|
||||
phase: 'moving',
|
||||
segmentStartedAtMs: 0,
|
||||
baseDist: 0,
|
||||
direction: 1,
|
||||
rejoinDist: null,
|
||||
durationSec: 2,
|
||||
pathLength: 1,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
void test('once reaches end and marks done', () => {
|
||||
const p = path();
|
||||
const r = computePathPlaybackSample({
|
||||
path: p,
|
||||
entry: entry({ phase: 'moving', segmentStartedAtMs: 0 }),
|
||||
nowMs: 2500,
|
||||
});
|
||||
assert.ok(r);
|
||||
assert.equal(r!.markDone, true);
|
||||
assert.equal(r!.phase, 'done');
|
||||
assert.ok(Math.abs(r!.sample.nx - 1) < 1e-6);
|
||||
});
|
||||
|
||||
void test('delay holds at start until delay elapses', () => {
|
||||
const p = { ...path(), startMode: 'delayed' as const, delaySec: 1 };
|
||||
const early = computePathPlaybackSample({
|
||||
path: p,
|
||||
entry: entry({ phase: 'delay', segmentStartedAtMs: 0 }),
|
||||
nowMs: 400,
|
||||
});
|
||||
assert.ok(early);
|
||||
assert.equal(early!.phase, 'delay');
|
||||
assert.ok(Math.abs(early!.sample.nx) < 1e-6);
|
||||
|
||||
const late = computePathPlaybackSample({
|
||||
path: p,
|
||||
entry: entry({ phase: 'delay', segmentStartedAtMs: 0 }),
|
||||
nowMs: 1500,
|
||||
});
|
||||
assert.ok(late);
|
||||
assert.equal(late!.phase, 'moving');
|
||||
assert.ok(late!.sample.nx > 0.2);
|
||||
});
|
||||
|
||||
void test('stopped freezes at baseDist', () => {
|
||||
const p = path();
|
||||
const r = computePathPlaybackSample({
|
||||
path: p,
|
||||
entry: entry({ phase: 'stopped', baseDist: 0.25 }),
|
||||
nowMs: 99999,
|
||||
});
|
||||
assert.ok(r);
|
||||
assert.equal(r!.phase, 'stopped');
|
||||
assert.ok(Math.abs(r!.sample.nx - 0.25) < 1e-6);
|
||||
});
|
||||
|
||||
void test('pingpong reverses after end', () => {
|
||||
const p = { ...path(), loopMode: 'pingpong' as const };
|
||||
const r = computePathPlaybackSample({
|
||||
path: p,
|
||||
entry: entry({ phase: 'moving', durationSec: 2, pathLength: 1 }),
|
||||
nowMs: 3000, // 1.5 path lengths → back toward start
|
||||
});
|
||||
assert.ok(r);
|
||||
assert.equal(r!.markDone, false);
|
||||
assert.ok(r!.sample.nx < 0.6);
|
||||
});
|
||||
|
||||
void test('resumeFromPose picks ahead distance', () => {
|
||||
const p = path();
|
||||
const d = resumeFromPose(p, 0.4, 0.5, 0.1);
|
||||
assert.ok(d >= 0.1);
|
||||
assert.ok(d <= 1);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Pure playback sampling for token paths (shared by control / presentation / editor preview).
|
||||
*/
|
||||
|
||||
import {
|
||||
findNearestAheadDistance,
|
||||
sampleTokenPathAtDistance,
|
||||
tokenPathTotalLength,
|
||||
type TokenPath,
|
||||
type TokenPathSample,
|
||||
} from './tokenPath';
|
||||
import type { TokenPathPlaybackEntry } from './tokenPathSession';
|
||||
|
||||
export function computePathPlaybackSample(args: {
|
||||
path: TokenPath;
|
||||
entry: TokenPathPlaybackEntry;
|
||||
nowMs: number;
|
||||
}): { sample: TokenPathSample; phase: TokenPathPlaybackEntry['phase']; markDone: boolean } | null {
|
||||
const { path, entry, nowMs } = args;
|
||||
const length = entry.pathLength > 1e-9 ? entry.pathLength : tokenPathTotalLength(path);
|
||||
if (length <= 1e-9) return null;
|
||||
const durationMs = Math.max(0.5, entry.durationSec) * 1000;
|
||||
const speed = length / durationMs;
|
||||
|
||||
if (entry.phase === 'stopped' || entry.phase === 'done') {
|
||||
const sample = sampleTokenPathAtDistance(path, clampDist(entry.baseDist, length));
|
||||
if (!sample) return null;
|
||||
return { sample, phase: entry.phase, markDone: false };
|
||||
}
|
||||
|
||||
let elapsed = Math.max(0, nowMs - entry.segmentStartedAtMs);
|
||||
|
||||
if (entry.phase === 'delay') {
|
||||
const delayMs = Math.max(0, path.delaySec) * 1000;
|
||||
if (elapsed < delayMs) {
|
||||
const start = sampleTokenPathAtDistance(path, 0);
|
||||
if (!start) return null;
|
||||
return { sample: start, phase: 'delay', markDone: false };
|
||||
}
|
||||
elapsed -= delayMs;
|
||||
}
|
||||
|
||||
// Moving (including post-delay).
|
||||
let base = entry.baseDist;
|
||||
let dir: 1 | -1 = entry.direction || 1;
|
||||
let travel = speed * elapsed;
|
||||
|
||||
if (entry.rejoinDist != null && Number.isFinite(entry.rejoinDist)) {
|
||||
const target = clampDist(entry.rejoinDist, length);
|
||||
const need = Math.abs(target - base);
|
||||
if (travel < need) {
|
||||
const dist = base + Math.sign(target - base || 1) * travel;
|
||||
const sample = sampleTokenPathAtDistance(path, clampDist(dist, length));
|
||||
if (!sample) return null;
|
||||
return { sample, phase: 'moving', markDone: false };
|
||||
}
|
||||
travel -= need;
|
||||
base = target;
|
||||
}
|
||||
|
||||
return advanceAlongPath({
|
||||
path,
|
||||
length,
|
||||
baseDist: base,
|
||||
direction: dir,
|
||||
travelDist: travel,
|
||||
});
|
||||
}
|
||||
|
||||
function clampDist(d: number, length: number): number {
|
||||
return Math.max(0, Math.min(length, d));
|
||||
}
|
||||
|
||||
function advanceAlongPath(args: {
|
||||
path: TokenPath;
|
||||
length: number;
|
||||
baseDist: number;
|
||||
direction: 1 | -1;
|
||||
travelDist: number;
|
||||
}): { sample: TokenPathSample; phase: TokenPathPlaybackEntry['phase']; markDone: boolean } | null {
|
||||
const { path, length, baseDist, direction, travelDist } = args;
|
||||
const loopMode = path.loopMode;
|
||||
const closed = path.closed;
|
||||
|
||||
let dist = baseDist + direction * travelDist;
|
||||
let markDone = false;
|
||||
|
||||
if (loopMode === 'pingpong') {
|
||||
const period = Math.max(length * 2, 1e-9);
|
||||
let d = ((dist % period) + period) % period;
|
||||
if (d > length) d = period - d;
|
||||
dist = d;
|
||||
} else if (loopMode === 'loop' && closed) {
|
||||
dist = ((dist % length) + length) % length;
|
||||
} else {
|
||||
// once (or loop without closed)
|
||||
if (dist >= length) {
|
||||
dist = length;
|
||||
markDone = true;
|
||||
} else if (dist <= 0) {
|
||||
dist = 0;
|
||||
markDone = true;
|
||||
}
|
||||
}
|
||||
|
||||
const sample = sampleTokenPathAtDistance(path, dist);
|
||||
if (!sample) return null;
|
||||
return { sample, phase: markDone ? 'done' : 'moving', markDone };
|
||||
}
|
||||
|
||||
export function resumeFromPose(path: TokenPath, nx: number, ny: number, fromDist: number): number {
|
||||
return findNearestAheadDistance(path, nx, ny, fromDist);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/** Session runtime for token/NPC path playback and presentation visibility. */
|
||||
|
||||
export type TokenPathTargetKind = 'token' | 'npcToken';
|
||||
|
||||
export type TokenPathTargetRef = {
|
||||
kind: TokenPathTargetKind;
|
||||
placementId: string;
|
||||
};
|
||||
|
||||
export function tokenPathKey(kind: TokenPathTargetKind, placementId: string): string {
|
||||
return `${kind}:${placementId}`;
|
||||
}
|
||||
|
||||
export function parseTokenPathKey(key: string): TokenPathTargetRef | null {
|
||||
const i = key.indexOf(':');
|
||||
if (i <= 0) return null;
|
||||
const kind = key.slice(0, i);
|
||||
const placementId = key.slice(i + 1);
|
||||
if ((kind !== 'token' && kind !== 'npcToken') || !placementId) return null;
|
||||
return { kind, placementId };
|
||||
}
|
||||
|
||||
/**
|
||||
* delay — waiting startMode delayed.
|
||||
* moving — animating along path (or rejoining after drag).
|
||||
* done — once finished at end.
|
||||
* stopped — user stopped; drag allowed; can resume.
|
||||
*/
|
||||
export type TokenPathPlaybackPhase = 'delay' | 'moving' | 'done' | 'stopped';
|
||||
|
||||
export type TokenPathPlaybackEntry = {
|
||||
kind: TokenPathTargetKind;
|
||||
placementId: string;
|
||||
phase: TokenPathPlaybackPhase;
|
||||
/** Wall-clock ms when current moving/delay segment started (main Date.now). */
|
||||
segmentStartedAtMs: number;
|
||||
/** Path distance at segment start (for moving). */
|
||||
baseDist: number;
|
||||
direction: 1 | -1;
|
||||
/** If set while moving: first travel to this dist (rejoin), then clear. */
|
||||
rejoinDist: number | null;
|
||||
/** Snapshot of durationSec from path at start. */
|
||||
durationSec: number;
|
||||
/** Snapshot of total polyline length at start. */
|
||||
pathLength: number;
|
||||
};
|
||||
|
||||
export type TokenPathSessionState = {
|
||||
revision: number;
|
||||
/** Keys visible on presentation («Показать путь»). */
|
||||
presentationVisible: Record<string, true>;
|
||||
playback: Record<string, TokenPathPlaybackEntry>;
|
||||
/** Monotonic clock for renderers (updated on dispatch / heartbeats). */
|
||||
serverNowMs: number;
|
||||
};
|
||||
|
||||
export type TokenPathSessionEvent =
|
||||
| { kind: 'clear' }
|
||||
| { kind: 'showPresentation'; target: TokenPathTargetRef }
|
||||
| { kind: 'hidePresentation'; target: TokenPathTargetRef }
|
||||
| { kind: 'stop'; target: TokenPathTargetRef; atDist?: number }
|
||||
| { kind: 'resume'; target: TokenPathTargetRef; nx: number; ny: number; fromDist: number }
|
||||
| { kind: 'resetToStart'; target: TokenPathTargetRef }
|
||||
| {
|
||||
kind: 'seedPlayback';
|
||||
entry: Omit<TokenPathPlaybackEntry, 'segmentStartedAtMs'> & { segmentStartedAtMs?: number };
|
||||
}
|
||||
| { kind: 'markDone'; target: TokenPathTargetRef };
|
||||
|
||||
export function emptyTokenPathSessionState(revision = 1): TokenPathSessionState {
|
||||
return {
|
||||
revision,
|
||||
presentationVisible: {},
|
||||
playback: {},
|
||||
serverNowMs: Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# ТЗ v1: анимация движения токенов (закрыто)
|
||||
|
||||
## Scope
|
||||
- Неигровые токены + НПС. Игроки — вне scope.
|
||||
- Редактор сцены: ПКМ меню → «Указать движение» / «Удалить»; «Сбросить на старт пути».
|
||||
- Одно Electron-окно редактора пути (фокус/смена токена, закрытие с scene editor).
|
||||
- Путь: точки, min dist, правка, обратить, замкнуть (ПКМ на точке), once/pingpong/loop(closed), durationSec, onEnter|delayed+delaySec, facing tangentSmooth|fixed.
|
||||
- Путь виден: редактор + пульт всегда; презентация — только после «Показать путь» (линия).
|
||||
- Playback: stop → drag; resume → nearest ahead; once → stay at end.
|
||||
- Поворот сцены ремапит points (+ fixedRotationDeg).
|
||||
|
||||
## Модули
|
||||
- `app/shared/types/tokenPath.ts` (+ tests)
|
||||
- path на `SceneToken` / `SceneNpcToken`
|
||||
- window `tokenPathEditor`
|
||||
- overlays + session store playback
|
||||
|
||||
## Статус
|
||||
- [x] Модель + normalize + geometry + rotation helpers
|
||||
- [x] Persist / remap wiring complete
|
||||
- [x] Window + UI
|
||||
- [x] Overlays + session playback + control menus
|
||||
+5
-5
@@ -10,15 +10,15 @@
|
||||
"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/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/main/windows/createWindows.editorClose.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/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/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.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/shared/ipc/contracts.players.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",
|
||||
"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/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/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.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/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.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/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",
|
||||
"format": "prettier . --check",
|
||||
"format:write": "prettier . --write",
|
||||
"postinstall": "patch-package",
|
||||
"release:info": "node scripts/print-release-info.mjs",
|
||||
"pack": "npm run build && node scripts/release-win-prep.mjs && electron-builder",
|
||||
"pack:dir": "npm run build && node scripts/release-win-prep.mjs && electron-builder --dir",
|
||||
"pack:mac": "npm run build && node scripts/release-mac-prep.mjs && electron-builder --mac",
|
||||
"pack:win": "npm run build && node scripts/release-win-prep.mjs && electron-builder --win",
|
||||
"pack": "npm run build && node scripts/release-win-prep.mjs && electron-builder && node scripts/verify-packaged-sharp.mjs",
|
||||
"pack:dir": "npm run build && node scripts/release-win-prep.mjs && electron-builder --dir && node scripts/verify-packaged-sharp.mjs",
|
||||
"pack:mac": "npm run build && node scripts/release-mac-prep.mjs && electron-builder --mac && node scripts/verify-packaged-sharp.mjs",
|
||||
"pack:win": "npm run build && node scripts/release-win-prep.mjs && electron-builder --win && node scripts/verify-packaged-sharp.mjs",
|
||||
"pack:linux": "node scripts/release-linux-pack.mjs",
|
||||
"release": "powershell -ExecutionPolicy Bypass -File scripts/ttrpg-release/release.ps1",
|
||||
"release:all": "powershell -ExecutionPolicy Bypass -File scripts/ttrpg-release/release-all.ps1",
|
||||
|
||||
@@ -34,11 +34,16 @@ function normalizeLinuxReleaseNames() {
|
||||
for (const name of fs.readdirSync(releaseDir)) {
|
||||
if (!name.includes('x86_64')) continue;
|
||||
const from = path.join(releaseDir, name);
|
||||
const to = path.join(releaseDir, name.replaceAll('x86_64', 'x64'));
|
||||
if (from !== to && !fs.existsSync(to)) {
|
||||
fs.renameSync(from, to);
|
||||
console.log(`[pack:linux] renamed ${name} -> ${path.basename(to)}`);
|
||||
const toName = name.replaceAll('x86_64', 'x64');
|
||||
const to = path.join(releaseDir, toName);
|
||||
if (from === to) continue;
|
||||
// Always replace stale x64 — otherwise feed points at an old AppImage.
|
||||
if (fs.existsSync(to)) {
|
||||
fs.rmSync(to, { force: true });
|
||||
console.log(`[pack:linux] removed stale ${toName}`);
|
||||
}
|
||||
fs.renameSync(from, to);
|
||||
console.log(`[pack:linux] renamed ${name} -> ${toName}`);
|
||||
}
|
||||
|
||||
for (const ymlName of fs.readdirSync(releaseDir)) {
|
||||
@@ -71,3 +76,4 @@ run('npm', ['run', 'build']);
|
||||
ensureReleaseNativeDeps(projectRoot, 'linux');
|
||||
run('electron-builder', ['--linux']);
|
||||
normalizeLinuxReleaseNames();
|
||||
run('node', [path.join(projectRoot, 'scripts', 'verify-packaged-sharp.mjs')]);
|
||||
|
||||
@@ -367,20 +367,37 @@ function Copy-ReleaseArtifacts {
|
||||
|
||||
$copied = 0
|
||||
foreach ($name in ($names | Sort-Object)) {
|
||||
# Always publish Linux AppImage under feed/site name (x64), never leave x86_64 alias.
|
||||
if ($name -match 'x86_64' -and $name -match '\.AppImage$') {
|
||||
$canonical = $name -replace 'x86_64', 'x64'
|
||||
if ($names.Contains($canonical)) {
|
||||
continue
|
||||
}
|
||||
$name = $canonical
|
||||
}
|
||||
|
||||
$src = Join-Path $BuildReleaseDir $name
|
||||
$destName = $name
|
||||
if (-not (Test-Path -LiteralPath $src)) {
|
||||
if ($name -match 'x64' -and $name -notmatch 'x86_64') {
|
||||
$alt = $name -replace 'x64', 'x86_64'
|
||||
$src = Join-Path $BuildReleaseDir $alt
|
||||
$altPath = Join-Path $BuildReleaseDir $alt
|
||||
if (Test-Path -LiteralPath $altPath) {
|
||||
$src = $altPath
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $src)) {
|
||||
Write-Host " [--] skip (not built): $name" -ForegroundColor Yellow
|
||||
continue
|
||||
}
|
||||
$dest = Join-Path $TargetDir ([System.IO.Path]::GetFileName($src))
|
||||
$dest = Join-Path $TargetDir $destName
|
||||
Copy-Item -LiteralPath $src -Destination $dest -Force
|
||||
Write-Ok "copied $([System.IO.Path]::GetFileName($src))"
|
||||
if ([System.IO.Path]::GetFileName($src) -ne $destName) {
|
||||
Write-Ok "copied $([System.IO.Path]::GetFileName($src)) -> $destName"
|
||||
} else {
|
||||
Write-Ok "copied $destName"
|
||||
}
|
||||
$copied += 1
|
||||
}
|
||||
|
||||
|
||||
@@ -102,12 +102,36 @@ function Resolve-ReleaseFile([string]$name) {
|
||||
return $null
|
||||
}
|
||||
|
||||
function Add-FileToUploadSet {
|
||||
# electron-builder may leave TTRPGPlayer-x86_64.AppImage while yml/site expect x64.
|
||||
# Prefer the newer x86_64 build and force the feed name before upload.
|
||||
function Normalize-LinuxAppImageNames {
|
||||
$imgs = Get-ChildItem -LiteralPath $ReleaseDir -Filter 'TTRPGPlayer-*.AppImage' -File -ErrorAction SilentlyContinue
|
||||
foreach ($img in $imgs) {
|
||||
if ($img.Name -notmatch 'x86_64') { continue }
|
||||
$toName = $img.Name -replace 'x86_64', 'x64'
|
||||
$toPath = Join-Path $ReleaseDir $toName
|
||||
if (Test-Path -LiteralPath $toPath) {
|
||||
Remove-Item -LiteralPath $toPath -Force
|
||||
Write-Warn "removed stale $toName (replaced by $($img.Name))"
|
||||
}
|
||||
Rename-Item -LiteralPath $img.FullName -NewName $toName
|
||||
Write-Ok "normalized $($img.Name) -> $toName"
|
||||
}
|
||||
}
|
||||
|
||||
function Add-Upload {
|
||||
param(
|
||||
[System.Collections.Generic.HashSet[string]]$set,
|
||||
[System.Collections.Generic.Dictionary[string, string]]$map,
|
||||
[string]$remoteName,
|
||||
[System.IO.FileInfo]$file
|
||||
)
|
||||
[void]$set.Add($file.FullName)
|
||||
if ($map.ContainsKey($remoteName)) {
|
||||
$prev = $map[$remoteName]
|
||||
if (-not $prev.Equals($file.FullName, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
Write-Warn "upload name collision for $remoteName: keeping $($file.Name)"
|
||||
}
|
||||
}
|
||||
$map[$remoteName] = $file.FullName
|
||||
}
|
||||
|
||||
Write-Title 'TTRPG Release Publisher'
|
||||
@@ -129,7 +153,8 @@ if (-not (Test-Path -LiteralPath $sshKey)) {
|
||||
|
||||
$errors = [System.Collections.Generic.List[string]]::new()
|
||||
$warnings = [System.Collections.Generic.List[string]]::new()
|
||||
$uploadFiles = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
# remote file name -> local full path (scp must use the feed/site name, not disk alias)
|
||||
$uploadMap = [System.Collections.Generic.Dictionary[string, string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
|
||||
Write-Title 'Windows (required)'
|
||||
$winYml = Join-Path $ReleaseDir 'latest.yml'
|
||||
@@ -137,14 +162,14 @@ if (-not (Test-Path -LiteralPath $winYml)) {
|
||||
$errors.Add('Missing latest.yml')
|
||||
} else {
|
||||
Write-Ok 'latest.yml'
|
||||
[void]$uploadFiles.Add($winYml)
|
||||
Add-Upload $uploadMap ([System.IO.Path]::GetFileName($winYml)) (Get-Item -LiteralPath $winYml)
|
||||
foreach ($name in (Get-YmlReferencedFiles $winYml)) {
|
||||
$file = Resolve-ReleaseFile $name
|
||||
if ($null -eq $file) {
|
||||
$errors.Add("Windows: missing file $name (from latest.yml)")
|
||||
} else {
|
||||
Write-Ok $file.Name
|
||||
Add-FileToUploadSet $uploadFiles $file
|
||||
Add-Upload $uploadMap $name $file
|
||||
}
|
||||
}
|
||||
$blockmap = Resolve-ReleaseFile 'TTRPGPlayer-Setup.exe.blockmap'
|
||||
@@ -152,29 +177,30 @@ if (-not (Test-Path -LiteralPath $winYml)) {
|
||||
$warnings.Add('Missing TTRPGPlayer-Setup.exe.blockmap (recommended)')
|
||||
} else {
|
||||
Write-Ok $blockmap.Name
|
||||
Add-FileToUploadSet $uploadFiles $blockmap
|
||||
Add-Upload $uploadMap 'TTRPGPlayer-Setup.exe.blockmap' $blockmap
|
||||
}
|
||||
}
|
||||
|
||||
Write-Title 'Linux (if latest-linux*.yml present)'
|
||||
Normalize-LinuxAppImageNames
|
||||
$linuxYmls = Get-ChildItem -LiteralPath $ReleaseDir -Filter 'latest-linux*.yml' -File -ErrorAction SilentlyContinue
|
||||
if ($linuxYmls.Count -eq 0) {
|
||||
Write-Warn 'No latest-linux*.yml - skipping Linux'
|
||||
} else {
|
||||
foreach ($yml in $linuxYmls) {
|
||||
Write-Ok $yml.Name
|
||||
[void]$uploadFiles.Add($yml.FullName)
|
||||
Add-Upload $uploadMap $yml.Name $yml
|
||||
foreach ($name in (Get-YmlReferencedFiles $yml.FullName)) {
|
||||
$file = Resolve-ReleaseFile $name
|
||||
if ($null -eq $file) {
|
||||
$errors.Add("Linux ($($yml.Name)): missing file $name")
|
||||
} else {
|
||||
if ($file.Name -ne $name) {
|
||||
Write-Warn "$($yml.Name): yml expects $name, disk has $($file.Name) - will upload $($file.Name)"
|
||||
Write-Warn "$($yml.Name): yml expects $name, disk has $($file.Name) - uploading as $name"
|
||||
} else {
|
||||
Write-Ok "$($yml.Name) -> $name"
|
||||
}
|
||||
Add-FileToUploadSet $uploadFiles $file
|
||||
Add-Upload $uploadMap $name $file
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,7 +214,7 @@ if ($SkipMac) {
|
||||
Write-Warn 'No latest-mac.yml - skipping macOS'
|
||||
} else {
|
||||
Write-Ok 'latest-mac.yml'
|
||||
[void]$uploadFiles.Add($macYml)
|
||||
Add-Upload $uploadMap ([System.IO.Path]::GetFileName($macYml)) (Get-Item -LiteralPath $macYml)
|
||||
|
||||
$macVersion = Get-YmlVersion $macYml
|
||||
$winYml = Join-Path $ReleaseDir 'latest.yml'
|
||||
@@ -216,7 +242,7 @@ if ($SkipMac) {
|
||||
)
|
||||
} else {
|
||||
Write-Ok "primary update: $($primaryFile.Name)"
|
||||
Add-FileToUploadSet $uploadFiles $primaryFile
|
||||
Add-Upload $uploadMap $primaryName $primaryFile
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +252,7 @@ if ($SkipMac) {
|
||||
$warnings.Add("macOS: optional file missing (not uploaded): $name")
|
||||
} else {
|
||||
Write-Ok "optional: $($file.Name)"
|
||||
Add-FileToUploadSet $uploadFiles $file
|
||||
Add-Upload $uploadMap $name $file
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,9 +271,14 @@ if ($errors.Count -gt 0) {
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "Files to upload: $($uploadFiles.Count)" -ForegroundColor Green
|
||||
foreach ($path in ($uploadFiles | Sort-Object)) {
|
||||
Write-Host " - $([System.IO.Path]::GetFileName($path))"
|
||||
Write-Host "Files to upload: $($uploadMap.Count)" -ForegroundColor Green
|
||||
foreach ($remoteName in ($uploadMap.Keys | Sort-Object)) {
|
||||
$localName = [System.IO.Path]::GetFileName($uploadMap[$remoteName])
|
||||
if ($localName -eq $remoteName) {
|
||||
Write-Host " - $remoteName"
|
||||
} else {
|
||||
Write-Host " - $remoteName (from $localName)"
|
||||
}
|
||||
}
|
||||
|
||||
if ($CheckOnly) {
|
||||
@@ -260,12 +291,12 @@ Write-Title 'Upload'
|
||||
Write-Host "Target: ${sshTarget}:${remoteDir}"
|
||||
Write-Host "Feed: $feedUrl"
|
||||
|
||||
foreach ($path in ($uploadFiles | Sort-Object)) {
|
||||
$name = [System.IO.Path]::GetFileName($path)
|
||||
Write-Host " -> $name"
|
||||
& scp -i $sshKey -q $path "${sshTarget}:${remoteDir}/"
|
||||
foreach ($remoteName in ($uploadMap.Keys | Sort-Object)) {
|
||||
$localPath = $uploadMap[$remoteName]
|
||||
Write-Host " -> $remoteName"
|
||||
& scp -i $sshKey -q $localPath "${sshTarget}:${remoteDir}/$remoteName"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "scp failed for $name (exit $LASTEXITCODE)"
|
||||
throw "scp failed for $remoteName (exit $LASTEXITCODE)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* After electron-builder: fail the pack if sharp / @img look missing or truncated
|
||||
* in release unpacked dirs. Catches AV quarantine and incomplete asarUnpack before shipping.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
/**
|
||||
* @param {string} startDir
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function findAsarUnpackedNodeModules(startDir) {
|
||||
const direct = path.join(startDir, 'resources', 'app.asar.unpacked', 'node_modules');
|
||||
if (fs.existsSync(direct)) return direct;
|
||||
|
||||
// macOS: *.app/Contents/Resources/app.asar.unpacked/node_modules
|
||||
if (!fs.existsSync(startDir)) return null;
|
||||
for (const name of fs.readdirSync(startDir)) {
|
||||
if (!name.endsWith('.app')) continue;
|
||||
const macNm = path.join(
|
||||
startDir,
|
||||
name,
|
||||
'Contents',
|
||||
'Resources',
|
||||
'app.asar.unpacked',
|
||||
'node_modules',
|
||||
);
|
||||
if (fs.existsSync(macNm)) return macNm;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filePath
|
||||
* @param {number} minBytes
|
||||
*/
|
||||
function assertJsLooksIntact(filePath, minBytes) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`[verify-packaged-sharp] missing: ${filePath}`);
|
||||
}
|
||||
const st = fs.statSync(filePath);
|
||||
if (st.size < minBytes) {
|
||||
throw new Error(
|
||||
`[verify-packaged-sharp] truncated (${st.size} B < ${minBytes}): ${filePath}`,
|
||||
);
|
||||
}
|
||||
const head = fs.readFileSync(filePath, { encoding: 'utf8', flag: 'r' }).slice(0, 120);
|
||||
const trimmed = head.trimStart();
|
||||
const ok =
|
||||
trimmed.startsWith("'use strict'") ||
|
||||
trimmed.startsWith('"use strict"') ||
|
||||
head.includes('require(') ||
|
||||
head.includes('module.exports');
|
||||
if (!ok) {
|
||||
throw new Error(
|
||||
`[verify-packaged-sharp] sharp entry does not look like JS (corrupt?): ${filePath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dir
|
||||
*/
|
||||
function assertHasNativeBinary(dir) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
throw new Error(`[verify-packaged-sharp] missing native package dir: ${dir}`);
|
||||
}
|
||||
const stack = [dir];
|
||||
while (stack.length) {
|
||||
const cur = stack.pop();
|
||||
if (!cur) break;
|
||||
for (const name of fs.readdirSync(cur)) {
|
||||
const p = path.join(cur, name);
|
||||
const st = fs.statSync(p);
|
||||
if (st.isDirectory()) {
|
||||
stack.push(p);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
name.endsWith('.node') ||
|
||||
name.endsWith('.dll') ||
|
||||
name.endsWith('.dylib') ||
|
||||
/\.so(\.|$)/u.test(name)
|
||||
) {
|
||||
if (st.size < 50_000) {
|
||||
throw new Error(
|
||||
`[verify-packaged-sharp] native binary too small (${st.size} B): ${p}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`[verify-packaged-sharp] no .node/.dll/.so under ${dir}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ label: string; unpackedRoot: string; imgDirs: string[] }} probe
|
||||
*/
|
||||
export function verifyUnpackedSharp(probe) {
|
||||
const unpackedNm =
|
||||
findAsarUnpackedNodeModules(probe.unpackedRoot) ??
|
||||
path.join(probe.unpackedRoot, 'resources', 'app.asar.unpacked', 'node_modules');
|
||||
|
||||
const sharpIndex = path.join(unpackedNm, 'sharp', 'lib', 'index.js');
|
||||
// Real sharp/lib/index.js is typically several KB; empty/AV-quarantined files fail here.
|
||||
assertJsLooksIntact(sharpIndex, 32);
|
||||
|
||||
for (const img of probe.imgDirs) {
|
||||
assertHasNativeBinary(path.join(unpackedNm, '@img', img));
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {{ label: string; unpackedRoot: string; imgDirs: string[] }[]} */
|
||||
const PROBES = [
|
||||
{
|
||||
label: 'win-unpacked',
|
||||
unpackedRoot: path.join(root, 'release', 'win-unpacked'),
|
||||
imgDirs: ['sharp-win32-x64'],
|
||||
},
|
||||
{
|
||||
label: 'mac-arm64',
|
||||
unpackedRoot: path.join(root, 'release', 'mac-arm64'),
|
||||
imgDirs: ['sharp-darwin-arm64'],
|
||||
},
|
||||
{
|
||||
label: 'mac',
|
||||
unpackedRoot: path.join(root, 'release', 'mac'),
|
||||
imgDirs: ['sharp-darwin-x64', 'sharp-darwin-arm64'],
|
||||
},
|
||||
{
|
||||
label: 'linux-unpacked',
|
||||
unpackedRoot: path.join(root, 'release', 'linux-unpacked'),
|
||||
imgDirs: ['sharp-linux-x64', 'sharp-linux-arm64'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Verify every existing unpacked release dir. At least one must exist.
|
||||
* For multi-arch probes, require at least one listed @img package that is present.
|
||||
*/
|
||||
export function verifyPackagedSharpRelease(releaseRoot = root) {
|
||||
const probes = PROBES.map((p) => ({
|
||||
...p,
|
||||
unpackedRoot: path.join(
|
||||
releaseRoot,
|
||||
path.relative(root, p.unpackedRoot),
|
||||
),
|
||||
}));
|
||||
const existing = probes.filter((p) => fs.existsSync(p.unpackedRoot));
|
||||
if (existing.length === 0) {
|
||||
throw new Error(
|
||||
'[verify-packaged-sharp] no release unpacked dir found — run electron-builder first',
|
||||
);
|
||||
}
|
||||
|
||||
for (const probe of existing) {
|
||||
const nm = findAsarUnpackedNodeModules(probe.unpackedRoot);
|
||||
if (!nm) {
|
||||
throw new Error(
|
||||
`[verify-packaged-sharp] app.asar.unpacked/node_modules missing under ${probe.unpackedRoot}`,
|
||||
);
|
||||
}
|
||||
const presentImg = probe.imgDirs.filter((d) =>
|
||||
fs.existsSync(path.join(nm, '@img', d)),
|
||||
);
|
||||
if (presentImg.length === 0) {
|
||||
throw new Error(
|
||||
`[verify-packaged-sharp] none of @img/{${probe.imgDirs.join(',')}} under ${nm}`,
|
||||
);
|
||||
}
|
||||
verifyUnpackedSharp({ ...probe, imgDirs: presentImg });
|
||||
console.log(`[verify-packaged-sharp] OK: ${probe.label}`);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
verifyPackagedSharpRelease();
|
||||
}
|
||||
|
||||
const entry = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : '';
|
||||
if (import.meta.url === entry) {
|
||||
try {
|
||||
main();
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { verifyUnpackedSharp } from './verify-packaged-sharp.mjs';
|
||||
|
||||
void test('verifyUnpackedSharp: accepts intact layout', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dnd-sharp-verify-'));
|
||||
try {
|
||||
const nm = path.join(root, 'resources', 'app.asar.unpacked', 'node_modules');
|
||||
const sharpLib = path.join(nm, 'sharp', 'lib');
|
||||
fs.mkdirSync(sharpLib, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(sharpLib, 'index.js'),
|
||||
"'use strict';\nmodule.exports = require('./constructor');\n",
|
||||
'utf8',
|
||||
);
|
||||
const imgDir = path.join(nm, '@img', 'sharp-win32-x64');
|
||||
fs.mkdirSync(imgDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(imgDir, 'sharp.node'), Buffer.alloc(60_000, 1));
|
||||
|
||||
verifyUnpackedSharp({
|
||||
label: 'fixture',
|
||||
unpackedRoot: root,
|
||||
imgDirs: ['sharp-win32-x64'],
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
void test('verifyUnpackedSharp: rejects truncated sharp index', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dnd-sharp-verify-'));
|
||||
try {
|
||||
const nm = path.join(root, 'resources', 'app.asar.unpacked', 'node_modules');
|
||||
const sharpLib = path.join(nm, 'sharp', 'lib');
|
||||
fs.mkdirSync(sharpLib, { recursive: true });
|
||||
fs.writeFileSync(path.join(sharpLib, 'index.js'), 'x', 'utf8');
|
||||
const imgDir = path.join(nm, '@img', 'sharp-win32-x64');
|
||||
fs.mkdirSync(imgDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(imgDir, 'sharp.node'), Buffer.alloc(60_000, 1));
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
verifyUnpackedSharp({
|
||||
label: 'fixture',
|
||||
unpackedRoot: root,
|
||||
imgDirs: ['sharp-win32-x64'],
|
||||
}),
|
||||
/truncated|corrupt/i,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
void test('verifyUnpackedSharp: accepts mac .app layout', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dnd-sharp-verify-mac-'));
|
||||
try {
|
||||
const nm = path.join(
|
||||
root,
|
||||
'TTRPGPlayer.app',
|
||||
'Contents',
|
||||
'Resources',
|
||||
'app.asar.unpacked',
|
||||
'node_modules',
|
||||
);
|
||||
const sharpLib = path.join(nm, 'sharp', 'lib');
|
||||
fs.mkdirSync(sharpLib, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(sharpLib, 'index.js'),
|
||||
"'use strict';\nmodule.exports = {};\n",
|
||||
'utf8',
|
||||
);
|
||||
const imgDir = path.join(nm, '@img', 'sharp-darwin-arm64');
|
||||
fs.mkdirSync(imgDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(imgDir, 'sharp.node'), Buffer.alloc(60_000, 1));
|
||||
|
||||
verifyUnpackedSharp({
|
||||
label: 'mac-fixture',
|
||||
unpackedRoot: root,
|
||||
imgDirs: ['sharp-darwin-arm64'],
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -56,6 +56,7 @@ export default defineConfig(({ mode }) => {
|
||||
sceneDescription: path.resolve(__dirname, 'app/renderer/sceneDescription.html'),
|
||||
materials: path.resolve(__dirname, 'app/renderer/materials.html'),
|
||||
sceneEditor: path.resolve(__dirname, 'app/renderer/sceneEditor.html'),
|
||||
tokenPathEditor: path.resolve(__dirname, 'app/renderer/tokenPathEditor.html'),
|
||||
npcsEditor: path.resolve(__dirname, 'app/renderer/npcsEditor.html'),
|
||||
npcs: path.resolve(__dirname, 'app/renderer/npcs.html'),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user