feat(tokens): animated paths for scene and NPC tokens
Add path editor window, session playback on control/presentation, and RMB controls. Fix live pose clock so motion no longer freezes after ~250ms. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -59,17 +59,20 @@ import {
|
|||||||
focusEditorWindow,
|
focusEditorWindow,
|
||||||
getPresentationContentSize,
|
getPresentationContentSize,
|
||||||
getSceneDescriptionContent,
|
getSceneDescriptionContent,
|
||||||
|
getTokenPathEditorTarget,
|
||||||
isMultiWindowOpen,
|
isMultiWindowOpen,
|
||||||
markAppQuitting,
|
markAppQuitting,
|
||||||
openMaterialsWindow,
|
openMaterialsWindow,
|
||||||
openMultiWindow,
|
openMultiWindow,
|
||||||
openNpcsEditorWindow,
|
openNpcsEditorWindow,
|
||||||
openSceneEditorWindow,
|
openSceneEditorWindow,
|
||||||
|
openTokenPathEditorWindow,
|
||||||
openNpcsWindow,
|
openNpcsWindow,
|
||||||
openSceneDescriptionWindow,
|
openSceneDescriptionWindow,
|
||||||
closeMaterialsWindow,
|
closeMaterialsWindow,
|
||||||
closeNpcsEditorWindow,
|
closeNpcsEditorWindow,
|
||||||
closeSceneEditorWindow,
|
closeSceneEditorWindow,
|
||||||
|
closeTokenPathEditorWindow,
|
||||||
closeNpcsWindow,
|
closeNpcsWindow,
|
||||||
sendToAppWindows,
|
sendToAppWindows,
|
||||||
syncAllWindowChromeTitles,
|
syncAllWindowChromeTitles,
|
||||||
@@ -77,6 +80,9 @@ import {
|
|||||||
waitForEditorWindowReady,
|
waitForEditorWindowReady,
|
||||||
warmNpcsEditorWindow,
|
warmNpcsEditorWindow,
|
||||||
} from './windows/createWindows';
|
} from './windows/createWindows';
|
||||||
|
import { TokenPathSessionStore } from './tokens/tokenPathSessionStore';
|
||||||
|
import { tokenPathTotalLength } from '../shared/types/tokenPath';
|
||||||
|
import type { TokenPathTargetKind } from '../shared/types/tokenPathSession';
|
||||||
|
|
||||||
function emitZipProgress(evt: {
|
function emitZipProgress(evt: {
|
||||||
kind: 'import' | 'export';
|
kind: 'import' | 'export';
|
||||||
@@ -160,12 +166,59 @@ const videoStore = new VideoPlaybackStore();
|
|||||||
const materialsOverlayStore = new MaterialsOverlayStore();
|
const materialsOverlayStore = new MaterialsOverlayStore();
|
||||||
const npcsOverlayStore = new NpcsOverlayStore();
|
const npcsOverlayStore = new NpcsOverlayStore();
|
||||||
const sceneTokensSessionStore = new SceneTokensSessionStore();
|
const sceneTokensSessionStore = new SceneTokensSessionStore();
|
||||||
|
const tokenPathSessionStore = new TokenPathSessionStore();
|
||||||
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
|
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
|
||||||
const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore();
|
const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore();
|
||||||
const tokenGridSnapSessionStore = new TokenGridSnapSessionStore();
|
const tokenGridSnapSessionStore = new TokenGridSnapSessionStore();
|
||||||
let tokensStore: TokensStore | null = null;
|
let tokensStore: TokensStore | null = null;
|
||||||
let playersStore: PlayersStore | 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 {
|
function emitEffectsState(): void {
|
||||||
const state = effectsStore.getState();
|
const state = effectsStore.getState();
|
||||||
for (const win of BrowserWindow.getAllWindows()) {
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
@@ -446,6 +499,7 @@ async function main() {
|
|||||||
sceneDarknessStore.resetSession();
|
sceneDarknessStore.resetSession();
|
||||||
sceneTrapsStore.resetSession();
|
sceneTrapsStore.resetSession();
|
||||||
sceneTokensSessionStore.reset();
|
sceneTokensSessionStore.reset();
|
||||||
|
tokenPathSessionStore.reset();
|
||||||
sceneNpcTokensSessionStore.reset();
|
sceneNpcTokensSessionStore.reset();
|
||||||
scenePlayerTokensSessionStore.reset();
|
scenePlayerTokensSessionStore.reset();
|
||||||
tokenGridSnapSessionStore.reset();
|
tokenGridSnapSessionStore.reset();
|
||||||
@@ -461,6 +515,9 @@ async function main() {
|
|||||||
if (project) {
|
if (project) {
|
||||||
syncSceneDarknessForProject(project);
|
syncSceneDarknessForProject(project);
|
||||||
syncSceneTrapsForProject(project);
|
syncSceneTrapsForProject(project);
|
||||||
|
seedTokenPathPlaybackForProject(project);
|
||||||
|
} else {
|
||||||
|
emitTokenPathSessionState();
|
||||||
}
|
}
|
||||||
emitSceneDarknessState();
|
emitSceneDarknessState();
|
||||||
emitSceneTrapsState();
|
emitSceneTrapsState();
|
||||||
@@ -527,6 +584,15 @@ async function main() {
|
|||||||
closeSceneEditorWindow();
|
closeSceneEditorWindow();
|
||||||
return { ok: true };
|
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) => {
|
registerHandler(ipcChannels.windows.openNpcs, (req) => {
|
||||||
openNpcsWindow();
|
openNpcsWindow();
|
||||||
const npcId = req?.npcId ? asNpcId(String(req.npcId)) : null;
|
const npcId = req?.npcId ? asNpcId(String(req.npcId)) : null;
|
||||||
@@ -643,6 +709,7 @@ async function main() {
|
|||||||
emitSceneViewState();
|
emitSceneViewState();
|
||||||
emitSceneTokensSessionState();
|
emitSceneTokensSessionState();
|
||||||
emitScenePlayerTokensSessionState();
|
emitScenePlayerTokensSessionState();
|
||||||
|
seedTokenPathPlaybackForProject(project);
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
|
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
|
||||||
});
|
});
|
||||||
@@ -674,6 +741,7 @@ async function main() {
|
|||||||
emitSceneViewState();
|
emitSceneViewState();
|
||||||
emitSceneTokensSessionState();
|
emitSceneTokensSessionState();
|
||||||
emitScenePlayerTokensSessionState();
|
emitScenePlayerTokensSessionState();
|
||||||
|
seedTokenPathPlaybackForProject(project);
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
const p = projectStore.getOpenProject();
|
const p = projectStore.getOpenProject();
|
||||||
return {
|
return {
|
||||||
@@ -1411,6 +1479,14 @@ async function main() {
|
|||||||
emitSceneTokensSessionState();
|
emitSceneTokensSessionState();
|
||||||
return { ok: true };
|
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.getState, () => tokenGridSnapSessionStore.getState());
|
||||||
registerHandler(ipcChannels.tokenGridSnap.setEnabled, ({ enabled }) => {
|
registerHandler(ipcChannels.tokenGridSnap.setEnabled, ({ enabled }) => {
|
||||||
|
|||||||
@@ -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'
|
| 'materials'
|
||||||
| 'npcsEditor'
|
| 'npcsEditor'
|
||||||
| 'sceneEditor'
|
| 'sceneEditor'
|
||||||
|
| 'tokenPathEditor'
|
||||||
| 'npcs';
|
| 'npcs';
|
||||||
|
|
||||||
/** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */
|
/** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */
|
||||||
@@ -28,6 +29,7 @@ export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [
|
|||||||
'npcs',
|
'npcs',
|
||||||
'npcsEditor',
|
'npcsEditor',
|
||||||
'sceneEditor',
|
'sceneEditor',
|
||||||
|
'tokenPathEditor',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const windows = new Map<WindowKind, BrowserWindow>();
|
const windows = new Map<WindowKind, BrowserWindow>();
|
||||||
@@ -209,6 +211,8 @@ function pageNameForKind(kind: WindowKind): string {
|
|||||||
return 'npcsEditor.html';
|
return 'npcsEditor.html';
|
||||||
case 'sceneEditor':
|
case 'sceneEditor':
|
||||||
return 'sceneEditor.html';
|
return 'sceneEditor.html';
|
||||||
|
case 'tokenPathEditor':
|
||||||
|
return 'tokenPathEditor.html';
|
||||||
case 'npcs':
|
case 'npcs':
|
||||||
return 'npcs.html';
|
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 === '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 === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT };
|
||||||
if (kind === 'sceneEditor') return { width: 1280, height: 800 };
|
if (kind === 'sceneEditor') return { width: 1280, height: 800 };
|
||||||
|
if (kind === 'tokenPathEditor') return { width: 1100, height: 760 };
|
||||||
if (kind === 'npcs') return { width: NPCS_WINDOW_WIDTH, height: NPCS_WINDOW_HEIGHT };
|
if (kind === 'npcs') return { width: NPCS_WINDOW_WIDTH, height: NPCS_WINDOW_HEIGHT };
|
||||||
return { width: 1280, height: 800 };
|
return { width: 1280, height: 800 };
|
||||||
}
|
}
|
||||||
@@ -323,6 +328,14 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
minHeight: 600,
|
minHeight: 600,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(kind === 'tokenPathEditor'
|
||||||
|
? {
|
||||||
|
width: 1100,
|
||||||
|
height: 760,
|
||||||
|
minWidth: 900,
|
||||||
|
minHeight: 560,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(kind === 'npcs'
|
...(kind === 'npcs'
|
||||||
? {
|
? {
|
||||||
width: NPCS_WINDOW_WIDTH,
|
width: NPCS_WINDOW_WIDTH,
|
||||||
@@ -361,6 +374,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
kind === 'materials' ||
|
kind === 'materials' ||
|
||||||
kind === 'npcsEditor' ||
|
kind === 'npcsEditor' ||
|
||||||
kind === 'sceneEditor' ||
|
kind === 'sceneEditor' ||
|
||||||
|
kind === 'tokenPathEditor' ||
|
||||||
kind === 'npcs'
|
kind === 'npcs'
|
||||||
) {
|
) {
|
||||||
win.setMenuBarVisibility(false);
|
win.setMenuBarVisibility(false);
|
||||||
@@ -408,6 +422,11 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
win.on('closed', () => windows.delete(kind));
|
win.on('closed', () => windows.delete(kind));
|
||||||
|
if (kind === 'sceneEditor') {
|
||||||
|
win.on('closed', () => {
|
||||||
|
closeTokenPathEditorWindow();
|
||||||
|
});
|
||||||
|
}
|
||||||
win.on('closed', () => {
|
win.on('closed', () => {
|
||||||
if (kind !== 'presentation' && kind !== 'control') return;
|
if (kind !== 'presentation' && kind !== 'control') return;
|
||||||
const open = windows.has('presentation') || windows.has('control');
|
const open = windows.has('presentation') || windows.has('control');
|
||||||
@@ -535,12 +554,73 @@ export function closeNpcsEditorWindow(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function closeSceneEditorWindow(): void {
|
export function closeSceneEditorWindow(): void {
|
||||||
|
closeTokenPathEditorWindow();
|
||||||
const win = windows.get('sceneEditor');
|
const win = windows.get('sceneEditor');
|
||||||
if (win && !win.isDestroyed()) {
|
if (win && !win.isDestroyed()) {
|
||||||
win.close();
|
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 {
|
export function closeNpcsWindow(): void {
|
||||||
const win = windows.get('npcs');
|
const win = windows.get('npcs');
|
||||||
if (win && !win.isDestroyed()) {
|
if (win && !win.isDestroyed()) {
|
||||||
|
|||||||
@@ -56,9 +56,12 @@ import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost';
|
|||||||
import { useSceneViewState } from '../shared/sceneView/useSceneViewState';
|
import { useSceneViewState } from '../shared/sceneView/useSceneViewState';
|
||||||
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
|
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
|
||||||
import { SceneTokensOverlay } from '../shared/tokens/SceneTokensOverlay';
|
import { SceneTokensOverlay } from '../shared/tokens/SceneTokensOverlay';
|
||||||
|
import { TokenPathsOverlay } from '../shared/tokens/TokenPathsOverlay';
|
||||||
import { useAppTokens } from '../shared/tokens/useAppTokens';
|
import { useAppTokens } from '../shared/tokens/useAppTokens';
|
||||||
import { useSceneTokensSession } from '../shared/tokens/useSceneTokensSession';
|
import { useSceneTokensSession } from '../shared/tokens/useSceneTokensSession';
|
||||||
import { useTokenGridSnapSession } from '../shared/tokens/useTokenGridSnapSession';
|
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 { SceneTrapsOverlay } from '../shared/traps/SceneTrapsOverlay';
|
||||||
import { useSceneTrapsState } from '../shared/traps/useSceneTrapsState';
|
import { useSceneTrapsState } from '../shared/traps/useSceneTrapsState';
|
||||||
import { Button } from '../shared/ui/controls';
|
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 ellipsisStyles from '../shared/ui/ellipsisText.module.css';
|
||||||
import { Surface } from '../shared/ui/Surface';
|
import { Surface } from '../shared/ui/Surface';
|
||||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
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 styles from './ControlApp.module.css';
|
||||||
import { ControlAudioCard } from './ControlAudioCard';
|
import { ControlAudioCard } from './ControlAudioCard';
|
||||||
@@ -156,6 +162,7 @@ export function ControlApp() {
|
|||||||
const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession();
|
const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession();
|
||||||
const [sceneNpcTokensSession, sceneNpcTokensApi] = useSceneNpcTokensSession();
|
const [sceneNpcTokensSession, sceneNpcTokensApi] = useSceneNpcTokensSession();
|
||||||
const [scenePlayerTokensSession, scenePlayerTokensApi] = useScenePlayerTokensSession();
|
const [scenePlayerTokensSession, scenePlayerTokensApi] = useScenePlayerTokensSession();
|
||||||
|
const [tokenPathSession, tokenPathApi] = useTokenPathSession();
|
||||||
const [tokenGridSnap, tokenGridSnapApi] = useTokenGridSnapSession();
|
const [tokenGridSnap, tokenGridSnapApi] = useTokenGridSnapSession();
|
||||||
const { players: appPlayers } = useAppPlayers();
|
const { players: appPlayers } = useAppPlayers();
|
||||||
const [npcSessionCtxMenu, setNpcSessionCtxMenu] = useState<{
|
const [npcSessionCtxMenu, setNpcSessionCtxMenu] = useState<{
|
||||||
@@ -163,6 +170,11 @@ export function ControlApp() {
|
|||||||
y: number;
|
y: number;
|
||||||
placementId: string;
|
placementId: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [tokenSessionCtxMenu, setTokenSessionCtxMenu] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
placementId: string;
|
||||||
|
} | null>(null);
|
||||||
const [sceneView, sceneViewApi] = useSceneViewState();
|
const [sceneView, sceneViewApi] = useSceneViewState();
|
||||||
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
|
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
|
||||||
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
|
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
|
||||||
@@ -991,8 +1003,63 @@ export function ControlApp() {
|
|||||||
/** Действия с токенами/ловушками только без активной кисти эффектов. */
|
/** Действия с токенами/ловушками только без активной кисти эффектов. */
|
||||||
const markersInteractive = tool.tool === 'none';
|
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(() => {
|
useEffect(() => {
|
||||||
if (!markersInteractive) setNpcSessionCtxMenu(null);
|
if (!markersInteractive) {
|
||||||
|
setNpcSessionCtxMenu(null);
|
||||||
|
setTokenSessionCtxMenu(null);
|
||||||
|
}
|
||||||
}, [markersInteractive]);
|
}, [markersInteractive]);
|
||||||
toolRef.current = tool;
|
toolRef.current = tool;
|
||||||
|
|
||||||
@@ -2139,6 +2206,15 @@ export function ControlApp() {
|
|||||||
clearDraftFromPixi();
|
clearDraftFromPixi();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{previewContentRect ? (
|
||||||
|
<TokenPathsOverlay
|
||||||
|
tokens={currentScene?.tokens ?? []}
|
||||||
|
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? (currentScene?.npcTokens ?? []) : []}
|
||||||
|
viewport={previewContentRect}
|
||||||
|
mode="always"
|
||||||
|
pathSession={tokenPathSession}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{previewContentRect ? (
|
{previewContentRect ? (
|
||||||
<SceneTokensOverlay
|
<SceneTokensOverlay
|
||||||
placements={currentScene?.tokens ?? []}
|
placements={currentScene?.tokens ?? []}
|
||||||
@@ -2147,6 +2223,8 @@ export function ControlApp() {
|
|||||||
viewport={previewContentRect}
|
viewport={previewContentRect}
|
||||||
editable={markersInteractive}
|
editable={markersInteractive}
|
||||||
snapNorm={snapNormActive}
|
snapNorm={snapNormActive}
|
||||||
|
poseOverrides={pathLivePoses.tokenPoses}
|
||||||
|
dragEnabledById={tokenPathDragEnabled}
|
||||||
onMove={(placementId, nx, ny) => {
|
onMove={(placementId, nx, ny) => {
|
||||||
const snapped = snapNormActive(nx, ny);
|
const snapped = snapNormActive(nx, ny);
|
||||||
void sceneTokensApi.dispatch({
|
void sceneTokensApi.dispatch({
|
||||||
@@ -2156,6 +2234,20 @@ export function ControlApp() {
|
|||||||
ny: snapped.ny,
|
ny: snapped.ny,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
{...(markersInteractive
|
||||||
|
? {
|
||||||
|
onContextMenu: (
|
||||||
|
e: React.MouseEvent,
|
||||||
|
placement: { id: string },
|
||||||
|
) => {
|
||||||
|
setTokenSessionCtxMenu({
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
placementId: String(placement.id),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{USERS_BRANCH_FEATURES_ENABLED && previewContentRect ? (
|
{USERS_BRANCH_FEATURES_ENABLED && previewContentRect ? (
|
||||||
@@ -2167,6 +2259,8 @@ export function ControlApp() {
|
|||||||
grid={currentScene?.grid ?? null}
|
grid={currentScene?.grid ?? null}
|
||||||
editable={markersInteractive}
|
editable={markersInteractive}
|
||||||
snapNorm={snapNormActive}
|
snapNorm={snapNormActive}
|
||||||
|
poseOverrides={pathLivePoses.npcPoses}
|
||||||
|
dragEnabledById={npcPathDragEnabled}
|
||||||
onMove={(placementId, nx, ny) => {
|
onMove={(placementId, nx, ny) => {
|
||||||
const snapped = snapNormActive(nx, ny);
|
const snapped = snapNormActive(nx, ny);
|
||||||
sceneNpcTokensApi.dispatch({
|
sceneNpcTokensApi.dispatch({
|
||||||
@@ -2660,6 +2754,151 @@ export function ControlApp() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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
|
{USERS_BRANCH_FEATURES_ENABLED && npcSessionCtxMenu
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<>
|
<>
|
||||||
@@ -2690,6 +2929,14 @@ export function ControlApp() {
|
|||||||
override?.disposition,
|
override?.disposition,
|
||||||
);
|
);
|
||||||
const inactive = Boolean(override?.inactive);
|
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) {
|
if (inactive) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -2711,6 +2958,109 @@ export function ControlApp() {
|
|||||||
}
|
}
|
||||||
return (
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.ctxItem}
|
className={styles.ctxItem}
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
|
|
||||||
'help.section.tokens.title': 'Неигровые токены',
|
'help.section.tokens.title': 'Неигровые токены',
|
||||||
'help.section.tokens.body':
|
'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\n4) На экране презентации токены только отображаются: клики и перетаскивание для игроков недоступны.\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.title': 'Аудио игры',
|
||||||
'help.section.campaignAudio.body':
|
'help.section.campaignAudio.body':
|
||||||
@@ -816,7 +816,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
|
|
||||||
'help.section.tokens.title': 'Non-player tokens',
|
'help.section.tokens.title': 'Non-player tokens',
|
||||||
'help.section.tokens.body':
|
'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 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 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) 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.',
|
'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.title': 'Game audio',
|
||||||
'help.section.campaignAudio.body':
|
'help.section.campaignAudio.body':
|
||||||
|
|||||||
@@ -54,10 +54,12 @@ import { ContainedVideo } from '../shared/ContainedVideo';
|
|||||||
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
|
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
|
||||||
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
|
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
|
||||||
import { RotatedImage } from '../shared/RotatedImage';
|
import { RotatedImage } from '../shared/RotatedImage';
|
||||||
|
import { TokenPathsOverlay } from '../shared/tokens/TokenPathsOverlay';
|
||||||
import { useAppTokens } from '../shared/tokens/useAppTokens';
|
import { useAppTokens } from '../shared/tokens/useAppTokens';
|
||||||
import { TrapGlyph } from '../shared/traps/TrapGlyph';
|
import { TrapGlyph } from '../shared/traps/TrapGlyph';
|
||||||
import { Button, Input, Select } from '../shared/ui/controls';
|
import { Button, Input, Select } from '../shared/ui/controls';
|
||||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||||
|
import { sampleTokenPathAtDistance } from '../../shared/types/tokenPath';
|
||||||
|
|
||||||
import styles from './SceneEditorApp.module.css';
|
import styles from './SceneEditorApp.module.css';
|
||||||
import { SceneTokenMarker } from './SceneTokenMarker';
|
import { SceneTokenMarker } from './SceneTokenMarker';
|
||||||
@@ -235,6 +237,11 @@ export function SceneEditorApp() {
|
|||||||
y: number;
|
y: number;
|
||||||
placementId: string;
|
placementId: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [tokenCtxMenu, setTokenCtxMenu] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
placementId: string;
|
||||||
|
} | null>(null);
|
||||||
const [selected, setSelected] = useState<Selection>(null);
|
const [selected, setSelected] = useState<Selection>(null);
|
||||||
const [view, setView] = useState<LocalView>({ scale: 1, ox: 0.5, oy: 0.5 });
|
const [view, setView] = useState<LocalView>({ scale: 1, ox: 0.5, oy: 0.5 });
|
||||||
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
|
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||||
@@ -832,6 +839,12 @@ export function SceneEditorApp() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<SceneGridOverlay grid={localGrid} viewport={contentRect} />
|
<SceneGridOverlay grid={localGrid} viewport={contentRect} />
|
||||||
|
<TokenPathsOverlay
|
||||||
|
tokens={localTokens}
|
||||||
|
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? localNpcTokens : []}
|
||||||
|
viewport={contentRect}
|
||||||
|
mode="always"
|
||||||
|
/>
|
||||||
{contentRect
|
{contentRect
|
||||||
? localTokens.map((tok) => {
|
? localTokens.map((tok) => {
|
||||||
const minDim = Math.min(contentRect.w, contentRect.h);
|
const minDim = Math.min(contentRect.w, contentRect.h);
|
||||||
@@ -850,8 +863,8 @@ export function SceneEditorApp() {
|
|||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
persistTokens(tokensRef.current.filter((t) => t.id !== tok.id));
|
setSelected({ kind: 'token', id: tok.id });
|
||||||
setSelected((cur) => (cur?.kind === 'token' && cur.id === tok.id ? null : cur));
|
setTokenCtxMenu({ x: e.clientX, y: e.clientY, placementId: tok.id });
|
||||||
}}
|
}}
|
||||||
onMovePointerDown={(e) => {
|
onMovePointerDown={(e) => {
|
||||||
if (spaceDownRef.current) return;
|
if (spaceDownRef.current) return;
|
||||||
@@ -1069,6 +1082,84 @@ export function SceneEditorApp() {
|
|||||||
)
|
)
|
||||||
: null}
|
: 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
|
{USERS_BRANCH_FEATURES_ENABLED && npcCtxMenu
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<>
|
<>
|
||||||
@@ -1083,6 +1174,43 @@ export function SceneEditorApp() {
|
|||||||
style={{ left: npcCtxMenu.x, top: npcCtxMenu.y }}
|
style={{ left: npcCtxMenu.x, top: npcCtxMenu.y }}
|
||||||
role="menu"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.ctxItemDanger}
|
className={styles.ctxItemDanger}
|
||||||
|
|||||||
@@ -24,8 +24,11 @@ import { useScenePlayerTokensSession } from './playerToken/useScenePlayerTokensS
|
|||||||
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
|
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
|
||||||
import { useSceneViewState } from './sceneView/useSceneViewState';
|
import { useSceneViewState } from './sceneView/useSceneViewState';
|
||||||
import { SceneTokensOverlay } from './tokens/SceneTokensOverlay';
|
import { SceneTokensOverlay } from './tokens/SceneTokensOverlay';
|
||||||
|
import { TokenPathsOverlay } from './tokens/TokenPathsOverlay';
|
||||||
import { useAppTokens } from './tokens/useAppTokens';
|
import { useAppTokens } from './tokens/useAppTokens';
|
||||||
import { useSceneTokensSession } from './tokens/useSceneTokensSession';
|
import { useSceneTokensSession } from './tokens/useSceneTokensSession';
|
||||||
|
import { useTokenPathLivePoses } from './tokens/useTokenPathLivePoses';
|
||||||
|
import { useTokenPathSession } from './tokens/useTokenPathSession';
|
||||||
import { SceneTrapsOverlay } from './traps/SceneTrapsOverlay';
|
import { SceneTrapsOverlay } from './traps/SceneTrapsOverlay';
|
||||||
import { useSceneTrapsState } from './traps/useSceneTrapsState';
|
import { useSceneTrapsState } from './traps/useSceneTrapsState';
|
||||||
import styles from './PresentationView.module.css';
|
import styles from './PresentationView.module.css';
|
||||||
@@ -61,6 +64,7 @@ export function PresentationView({
|
|||||||
const [sceneTokensSession] = useSceneTokensSession();
|
const [sceneTokensSession] = useSceneTokensSession();
|
||||||
const [sceneNpcTokensSession] = useSceneNpcTokensSession();
|
const [sceneNpcTokensSession] = useSceneNpcTokensSession();
|
||||||
const [scenePlayerTokensSession] = useScenePlayerTokensSession();
|
const [scenePlayerTokensSession] = useScenePlayerTokensSession();
|
||||||
|
const [tokenPathSession, tokenPathApi] = useTokenPathSession();
|
||||||
const [vp] = useVideoPlaybackState();
|
const [vp] = useVideoPlaybackState();
|
||||||
const videoElRef = useRef<HTMLVideoElement | null>(null);
|
const videoElRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(
|
const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(
|
||||||
@@ -69,6 +73,15 @@ export function PresentationView({
|
|||||||
const scene =
|
const scene =
|
||||||
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
|
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
|
||||||
const project = session?.project;
|
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 =
|
const activeMaterialItems =
|
||||||
project && (materialsOverlay?.activeMaterialIds?.length ?? 0) > 0
|
project && (materialsOverlay?.activeMaterialIds?.length ?? 0) > 0
|
||||||
? (materialsOverlay?.activeMaterialIds ?? [])
|
? (materialsOverlay?.activeMaterialIds ?? [])
|
||||||
@@ -195,12 +208,22 @@ export function PresentationView({
|
|||||||
<SceneGridOverlay grid={scene.grid} viewport={contentRect} />
|
<SceneGridOverlay grid={scene.grid} viewport={contentRect} />
|
||||||
) : null}
|
) : null}
|
||||||
<div className={styles.vignette} />
|
<div className={styles.vignette} />
|
||||||
|
{(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 ? (
|
{(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? (
|
||||||
<SceneTokensOverlay
|
<SceneTokensOverlay
|
||||||
placements={scene.tokens ?? []}
|
placements={scene.tokens ?? []}
|
||||||
library={appTokens}
|
library={appTokens}
|
||||||
session={sceneTokensSession}
|
session={sceneTokensSession}
|
||||||
viewport={contentRect}
|
viewport={contentRect}
|
||||||
|
poseOverrides={pathLivePoses.tokenPoses}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{USERS_BRANCH_FEATURES_ENABLED &&
|
{USERS_BRANCH_FEATURES_ENABLED &&
|
||||||
@@ -212,6 +235,7 @@ export function PresentationView({
|
|||||||
session={sceneNpcTokensSession}
|
session={sceneNpcTokensSession}
|
||||||
viewport={contentRect}
|
viewport={contentRect}
|
||||||
grid={scene.grid}
|
grid={scene.grid}
|
||||||
|
poseOverrides={pathLivePoses.npcPoses}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{USERS_BRANCH_FEATURES_ENABLED &&
|
{USERS_BRANCH_FEATURES_ENABLED &&
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ function NpcSprite({
|
|||||||
ny,
|
ny,
|
||||||
viewport,
|
viewport,
|
||||||
editable,
|
editable,
|
||||||
|
dragEnabled,
|
||||||
displayScale,
|
displayScale,
|
||||||
gridFit,
|
gridFit,
|
||||||
disposition,
|
disposition,
|
||||||
@@ -51,6 +52,7 @@ function NpcSprite({
|
|||||||
ny: number;
|
ny: number;
|
||||||
viewport: Viewport;
|
viewport: Viewport;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
|
dragEnabled: boolean;
|
||||||
displayScale: number;
|
displayScale: number;
|
||||||
gridFit: number;
|
gridFit: number;
|
||||||
disposition: NpcDisposition;
|
disposition: NpcDisposition;
|
||||||
@@ -74,6 +76,7 @@ function NpcSprite({
|
|||||||
const pos = localPos ?? { nx, ny };
|
const pos = localPos ?? { nx, ny };
|
||||||
const minDim = Math.min(viewport.w, viewport.h);
|
const minDim = Math.min(viewport.w, viewport.h);
|
||||||
const sizePx = Math.max(16, placement.sizeN * gridFit * displayScale * minDim);
|
const sizePx = Math.max(16, placement.sizeN * gridFit * displayScale * minDim);
|
||||||
|
const canDrag = editable && dragEnabled && onMove !== undefined;
|
||||||
|
|
||||||
const point = (e: React.PointerEvent) => {
|
const point = (e: React.PointerEvent) => {
|
||||||
const host = e.currentTarget.parentElement;
|
const host = e.currentTarget.parentElement;
|
||||||
@@ -94,13 +97,14 @@ function NpcSprite({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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}`}
|
data-testid={`session-npc-token-${placement.id}`}
|
||||||
style={{
|
style={{
|
||||||
left: viewport.x + pos.nx * viewport.w,
|
left: viewport.x + pos.nx * viewport.w,
|
||||||
top: viewport.y + pos.ny * viewport.h,
|
top: viewport.y + pos.ny * viewport.h,
|
||||||
width: sizePx,
|
width: sizePx,
|
||||||
height: sizePx,
|
height: sizePx,
|
||||||
|
pointerEvents: canDrag || onContextMenu ? 'auto' : undefined,
|
||||||
}}
|
}}
|
||||||
onContextMenu={
|
onContextMenu={
|
||||||
onContextMenu
|
onContextMenu
|
||||||
@@ -112,7 +116,7 @@ function NpcSprite({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onPointerDown={
|
onPointerDown={
|
||||||
editable && onMove !== undefined
|
canDrag
|
||||||
? (e) => {
|
? (e) => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -132,7 +136,7 @@ function NpcSprite({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onPointerMove={
|
onPointerMove={
|
||||||
editable && onMove
|
canDrag
|
||||||
? (e) => {
|
? (e) => {
|
||||||
const drag = dragRef.current;
|
const drag = dragRef.current;
|
||||||
if (drag?.pointerId !== e.pointerId) return;
|
if (drag?.pointerId !== e.pointerId) return;
|
||||||
@@ -173,6 +177,8 @@ export function SceneNpcTokensOverlay({
|
|||||||
onMove,
|
onMove,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
snapNorm,
|
snapNorm,
|
||||||
|
poseOverrides = null,
|
||||||
|
dragEnabledById = null,
|
||||||
}: {
|
}: {
|
||||||
placements: readonly SceneNpcToken[];
|
placements: readonly SceneNpcToken[];
|
||||||
library: readonly ProjectNpc[];
|
library: readonly ProjectNpc[];
|
||||||
@@ -184,6 +190,8 @@ export function SceneNpcTokensOverlay({
|
|||||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||||
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
|
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
|
||||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||||
|
poseOverrides?: Record<string, { nx: number; ny: number }> | null;
|
||||||
|
dragEnabledById?: Record<string, boolean> | null;
|
||||||
}) {
|
}) {
|
||||||
if (!viewport) return null;
|
if (!viewport) return null;
|
||||||
const byId = new Map(library.map((npc) => [npc.id, npc]));
|
const byId = new Map(library.map((npc) => [npc.id, npc]));
|
||||||
@@ -194,18 +202,22 @@ export function SceneNpcTokensOverlay({
|
|||||||
{placements.map((placement) => {
|
{placements.map((placement) => {
|
||||||
const npc = byId.get(placement.npcId);
|
const npc = byId.get(placement.npcId);
|
||||||
if (!npc) return null;
|
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 disposition = resolveNpcTokenDisposition(placement, npc, override?.disposition);
|
||||||
const inactive = Boolean(override?.inactive);
|
const inactive = Boolean(override?.inactive);
|
||||||
|
const dragEnabled = dragEnabledById ? Boolean(dragEnabledById[key]) : true;
|
||||||
return (
|
return (
|
||||||
<NpcSprite
|
<NpcSprite
|
||||||
key={placement.id}
|
key={placement.id}
|
||||||
placement={placement}
|
placement={placement}
|
||||||
npc={npc}
|
npc={npc}
|
||||||
nx={override?.nx ?? placement.nx}
|
nx={pose?.nx ?? override?.nx ?? placement.nx}
|
||||||
ny={override?.ny ?? placement.ny}
|
ny={pose?.ny ?? override?.ny ?? placement.ny}
|
||||||
viewport={viewport}
|
viewport={viewport}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
|
dragEnabled={dragEnabled}
|
||||||
displayScale={displayScale}
|
displayScale={displayScale}
|
||||||
gridFit={gridFit}
|
gridFit={gridFit}
|
||||||
disposition={disposition}
|
disposition={disposition}
|
||||||
|
|||||||
@@ -26,6 +26,11 @@
|
|||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tokenInteractive {
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: context-menu;
|
||||||
|
}
|
||||||
|
|
||||||
.tokenImg {
|
.tokenImg {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import styles from './SceneTokensOverlay.module.css';
|
|||||||
|
|
||||||
type Viewport = { x: number; y: number; w: number; h: number };
|
type Viewport = { x: number; y: number; w: number; h: number };
|
||||||
|
|
||||||
|
export type TokenPoseOverride = {
|
||||||
|
nx: number;
|
||||||
|
ny: number;
|
||||||
|
rotationDeg?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
placements: readonly SceneToken[];
|
placements: readonly SceneToken[];
|
||||||
library: readonly AppToken[];
|
library: readonly AppToken[];
|
||||||
@@ -16,24 +22,35 @@ type Props = {
|
|||||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||||
/** Snap во время drag (пульт, привязка к сетке). */
|
/** Snap во время drag (пульт, привязка к сетке). */
|
||||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
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({
|
function TokenSprite({
|
||||||
placement,
|
placement,
|
||||||
nx,
|
nx,
|
||||||
ny,
|
ny,
|
||||||
|
rotationDeg,
|
||||||
viewport,
|
viewport,
|
||||||
editable,
|
editable,
|
||||||
|
dragEnabled,
|
||||||
onMove,
|
onMove,
|
||||||
snapNorm,
|
snapNorm,
|
||||||
|
onContextMenu,
|
||||||
}: {
|
}: {
|
||||||
placement: SceneToken;
|
placement: SceneToken;
|
||||||
nx: number;
|
nx: number;
|
||||||
ny: number;
|
ny: number;
|
||||||
|
rotationDeg: number;
|
||||||
viewport: Viewport;
|
viewport: Viewport;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
|
dragEnabled: boolean;
|
||||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||||
|
onContextMenu?: (e: React.MouseEvent, placement: SceneToken) => void;
|
||||||
}) {
|
}) {
|
||||||
const url = useTokenImageUrl(placement.tokenId);
|
const url = useTokenImageUrl(placement.tokenId);
|
||||||
const dragRef = useRef<{
|
const dragRef = useRef<{
|
||||||
@@ -66,6 +83,8 @@ function TokenSprite({
|
|||||||
const sizePx = Math.max(16, placement.sizeN * minDim);
|
const sizePx = Math.max(16, placement.sizeN * minDim);
|
||||||
const left = viewport.x + posNx * viewport.w;
|
const left = viewport.x + posNx * viewport.w;
|
||||||
const top = viewport.y + posNy * viewport.h;
|
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 hostToNorm = (clientX: number, clientY: number, host: HTMLElement) => {
|
||||||
const r = host.getBoundingClientRect();
|
const r = host.getBoundingClientRect();
|
||||||
@@ -85,7 +104,6 @@ function TokenSprite({
|
|||||||
cancelAnimationFrame(frameRef.current);
|
cancelAnimationFrame(frameRef.current);
|
||||||
frameRef.current = 0;
|
frameRef.current = 0;
|
||||||
}
|
}
|
||||||
// Финальный commit в session store — один раз на отпускание.
|
|
||||||
onMove?.(String(placement.id), d.lastNx, d.lastNy);
|
onMove?.(String(placement.id), d.lastNx, d.lastNy);
|
||||||
setLocalPos({ nx: d.lastNx, ny: d.lastNy });
|
setLocalPos({ nx: d.lastNx, ny: d.lastNy });
|
||||||
try {
|
try {
|
||||||
@@ -97,16 +115,31 @@ function TokenSprite({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={[styles.token, editable ? styles.tokenEditable : ''].filter(Boolean).join(' ')}
|
className={[
|
||||||
|
styles.token,
|
||||||
|
canDrag ? styles.tokenEditable : '',
|
||||||
|
interactive && !canDrag ? styles.tokenInteractive : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
style={{
|
style={{
|
||||||
left,
|
left,
|
||||||
top,
|
top,
|
||||||
width: sizePx,
|
width: sizePx,
|
||||||
height: 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={
|
onPointerDown={
|
||||||
editable && onMove
|
canDrag
|
||||||
? (e) => {
|
? (e) => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -128,7 +161,7 @@ function TokenSprite({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onPointerMove={
|
onPointerMove={
|
||||||
editable && onMove
|
canDrag
|
||||||
? (e) => {
|
? (e) => {
|
||||||
const d = dragRef.current;
|
const d = dragRef.current;
|
||||||
if (!d || d.pointerId !== e.pointerId) return;
|
if (!d || d.pointerId !== e.pointerId) return;
|
||||||
@@ -169,6 +202,9 @@ export function SceneTokensOverlay({
|
|||||||
editable = false,
|
editable = false,
|
||||||
onMove,
|
onMove,
|
||||||
snapNorm,
|
snapNorm,
|
||||||
|
onContextMenu,
|
||||||
|
poseOverrides = null,
|
||||||
|
dragEnabledById = null,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
if (!viewport || placements.length === 0) return null;
|
if (!viewport || placements.length === 0) return null;
|
||||||
const known = new Set(library.map((t) => t.id));
|
const known = new Set(library.map((t) => t.id));
|
||||||
@@ -179,19 +215,25 @@ export function SceneTokensOverlay({
|
|||||||
.filter((p) => known.has(p.tokenId))
|
.filter((p) => known.has(p.tokenId))
|
||||||
.map((placement) => {
|
.map((placement) => {
|
||||||
const key = String(placement.id);
|
const key = String(placement.id);
|
||||||
|
const pose = poseOverrides?.[key];
|
||||||
const override = session?.byPlacementId[key] ?? session?.byPlacementId[placement.id];
|
const override = session?.byPlacementId[key] ?? session?.byPlacementId[placement.id];
|
||||||
const nx = override?.nx ?? placement.nx;
|
const nx = pose?.nx ?? override?.nx ?? placement.nx;
|
||||||
const ny = override?.ny ?? placement.ny;
|
const ny = pose?.ny ?? override?.ny ?? placement.ny;
|
||||||
|
const rotationDeg = pose?.rotationDeg ?? placement.rotationDeg;
|
||||||
|
const dragEnabled = dragEnabledById ? Boolean(dragEnabledById[key]) : true;
|
||||||
return (
|
return (
|
||||||
<TokenSprite
|
<TokenSprite
|
||||||
key={key}
|
key={key}
|
||||||
placement={placement}
|
placement={placement}
|
||||||
nx={nx}
|
nx={nx}
|
||||||
ny={ny}
|
ny={ny}
|
||||||
|
rotationDeg={rotationDeg}
|
||||||
viewport={viewport}
|
viewport={viewport}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
|
dragEnabled={dragEnabled}
|
||||||
{...(onMove ? { onMove } : {})}
|
{...(onMove ? { onMove } : {})}
|
||||||
{...(snapNorm ? { snapNorm } : {})}
|
{...(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];
|
||||||
|
}
|
||||||
@@ -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'
|
| 'materials'
|
||||||
| 'npcsEditor'
|
| 'npcsEditor'
|
||||||
| 'sceneEditor'
|
| 'sceneEditor'
|
||||||
|
| 'tokenPathEditor'
|
||||||
| 'npcs';
|
| 'npcs';
|
||||||
|
|
||||||
const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
|
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' },
|
materials: { ru: 'Материалы', en: 'Materials' },
|
||||||
npcsEditor: { ru: 'НПС', en: 'NPCs' },
|
npcsEditor: { ru: 'НПС', en: 'NPCs' },
|
||||||
sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' },
|
sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' },
|
||||||
|
tokenPathEditor: { ru: 'Движение токена', en: 'Token path' },
|
||||||
npcs: { ru: 'НПС', en: 'NPCs' },
|
npcs: { ru: 'НПС', en: 'NPCs' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ import type {
|
|||||||
SceneViewEvent,
|
SceneViewEvent,
|
||||||
SceneViewState,
|
SceneViewState,
|
||||||
TokenId,
|
TokenId,
|
||||||
|
TokenPathSessionEvent,
|
||||||
|
TokenPathSessionState,
|
||||||
|
TokenPathTargetKind,
|
||||||
VideoPlaybackEvent,
|
VideoPlaybackEvent,
|
||||||
VideoPlaybackState,
|
VideoPlaybackState,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
@@ -147,6 +150,10 @@ export const ipcChannels = {
|
|||||||
closeNpcs: 'windows.closeNpcs',
|
closeNpcs: 'windows.closeNpcs',
|
||||||
openSceneEditor: 'windows.openSceneEditor',
|
openSceneEditor: 'windows.openSceneEditor',
|
||||||
closeSceneEditor: 'windows.closeSceneEditor',
|
closeSceneEditor: 'windows.closeSceneEditor',
|
||||||
|
openTokenPathEditor: 'windows.openTokenPathEditor',
|
||||||
|
closeTokenPathEditor: 'windows.closeTokenPathEditor',
|
||||||
|
getTokenPathEditorTarget: 'windows.getTokenPathEditorTarget',
|
||||||
|
tokenPathEditorTargetChanged: 'windows.tokenPathEditorTargetChanged',
|
||||||
syncChromeTitles: 'windows.syncChromeTitles',
|
syncChromeTitles: 'windows.syncChromeTitles',
|
||||||
getPresentationContentSize: 'windows.getPresentationContentSize',
|
getPresentationContentSize: 'windows.getPresentationContentSize',
|
||||||
presentationContentSizeChanged: 'windows.presentationContentSizeChanged',
|
presentationContentSizeChanged: 'windows.presentationContentSizeChanged',
|
||||||
@@ -197,6 +204,11 @@ export const ipcChannels = {
|
|||||||
dispatch: 'sceneTokensSession.dispatch',
|
dispatch: 'sceneTokensSession.dispatch',
|
||||||
stateChanged: 'sceneTokensSession.stateChanged',
|
stateChanged: 'sceneTokensSession.stateChanged',
|
||||||
},
|
},
|
||||||
|
tokenPathSession: {
|
||||||
|
getState: 'tokenPathSession.getState',
|
||||||
|
dispatch: 'tokenPathSession.dispatch',
|
||||||
|
stateChanged: 'tokenPathSession.stateChanged',
|
||||||
|
},
|
||||||
tokenGridSnap: {
|
tokenGridSnap: {
|
||||||
getState: 'tokenGridSnap.getState',
|
getState: 'tokenGridSnap.getState',
|
||||||
setEnabled: 'tokenGridSnap.setEnabled',
|
setEnabled: 'tokenGridSnap.setEnabled',
|
||||||
@@ -298,6 +310,11 @@ export type IpcEventMap = {
|
|||||||
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
||||||
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
|
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
|
||||||
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
|
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
|
||||||
|
[ipcChannels.tokenPathSession.stateChanged]: { state: TokenPathSessionState };
|
||||||
|
[ipcChannels.windows.tokenPathEditorTargetChanged]: {
|
||||||
|
kind: TokenPathTargetKind;
|
||||||
|
placementId: string;
|
||||||
|
} | null;
|
||||||
[ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean };
|
[ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean };
|
||||||
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
||||||
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
|
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
|
||||||
@@ -694,6 +711,18 @@ export type IpcInvokeMap = {
|
|||||||
req: Record<string, never>;
|
req: Record<string, never>;
|
||||||
res: { ok: true };
|
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]: {
|
[ipcChannels.windows.syncChromeTitles]: {
|
||||||
req: { localeTag: string };
|
req: { localeTag: string };
|
||||||
res: { ok: true };
|
res: { ok: true };
|
||||||
@@ -774,6 +803,14 @@ export type IpcInvokeMap = {
|
|||||||
req: { event: SceneTokensSessionEvent };
|
req: { event: SceneTokensSessionEvent };
|
||||||
res: { ok: true };
|
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]: {
|
[ipcChannels.tokenGridSnap.getState]: {
|
||||||
req: Record<string, never>;
|
req: Record<string, never>;
|
||||||
res: { enabled: boolean };
|
res: { enabled: boolean };
|
||||||
@@ -887,6 +924,7 @@ export type LegacyIpcEventMap = {
|
|||||||
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
||||||
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
|
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
|
||||||
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
|
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
|
||||||
|
[ipcChannels.tokenPathSession.stateChanged]: { state: TokenPathSessionState };
|
||||||
[ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean };
|
[ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean };
|
||||||
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
||||||
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
|
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { normalizeNpcDisposition } from './npcDisposition';
|
|||||||
import type { NpcId, PlayerId, PlayerTeamId, SceneNpcTokenId } from './ids';
|
import type { NpcId, PlayerId, PlayerTeamId, SceneNpcTokenId } from './ids';
|
||||||
import { asNpcId, asPlayerId, asPlayerTeamId, asSceneNpcTokenId } from './ids';
|
import { asNpcId, asPlayerId, asPlayerTeamId, asSceneNpcTokenId } from './ids';
|
||||||
import { normalizeHexColor } from '../npcs/npcGroups';
|
import { normalizeHexColor } from '../npcs/npcGroups';
|
||||||
|
import { normalizeTokenPath, type TokenPath } from './tokenPath';
|
||||||
|
|
||||||
export type { PlayerId, PlayerTeamId, SceneNpcTokenId };
|
export type { PlayerId, PlayerTeamId, SceneNpcTokenId };
|
||||||
export { asPlayerId, asPlayerTeamId, asSceneNpcTokenId };
|
export { asPlayerId, asPlayerTeamId, asSceneNpcTokenId };
|
||||||
@@ -41,6 +42,8 @@ export type SceneNpcToken = {
|
|||||||
sizeN: number;
|
sizeN: number;
|
||||||
/** Состояние экземпляра на карте (копируется из НПС при постановке). */
|
/** Состояние экземпляра на карте (копируется из НПС при постановке). */
|
||||||
disposition: NpcDisposition;
|
disposition: NpcDisposition;
|
||||||
|
/** Optional movement path (editor → session playback). */
|
||||||
|
path?: TokenPath | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_PLAYER_RING_COLOR = '#c9a227';
|
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)),
|
ny: Math.max(0, Math.min(1, ny)),
|
||||||
sizeN: clampSceneNpcTokenSizeN(typeof obj.sizeN === 'number' ? obj.sizeN : DEFAULT_SCENE_NPC_TOKEN_SIZE_N),
|
sizeN: clampSceneNpcTokenSizeN(typeof obj.sizeN === 'number' ? obj.sizeN : DEFAULT_SCENE_NPC_TOKEN_SIZE_N),
|
||||||
disposition: normalizeNpcDisposition((obj as { disposition?: unknown }).disposition),
|
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 type { SceneTokenId, TokenId } from './ids';
|
||||||
import { asSceneTokenId, asTokenId } from './ids';
|
import { asSceneTokenId, asTokenId } from './ids';
|
||||||
|
import { normalizeTokenPath, type TokenPath } from './tokenPath';
|
||||||
|
|
||||||
export type { SceneTokenId, TokenId };
|
export type { SceneTokenId, TokenId };
|
||||||
export { asSceneTokenId, asTokenId };
|
export { asSceneTokenId, asTokenId };
|
||||||
@@ -24,6 +25,8 @@ export type SceneToken = {
|
|||||||
sizeN: number;
|
sizeN: number;
|
||||||
/** Непрерывный угол поворота в градусах. */
|
/** Непрерывный угол поворота в градусах. */
|
||||||
rotationDeg: number;
|
rotationDeg: number;
|
||||||
|
/** Optional movement path (editor → session playback). */
|
||||||
|
path?: TokenPath | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_SCENE_TOKEN_SIZE_N = 0.08;
|
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 sizeN = clampSceneTokenSizeN(typeof obj.sizeN === 'number' ? obj.sizeN : DEFAULT_SCENE_TOKEN_SIZE_N);
|
||||||
const rotationDeg =
|
const rotationDeg =
|
||||||
typeof obj.rotationDeg === 'number' && Number.isFinite(obj.rotationDeg) ? obj.rotationDeg : 0;
|
typeof obj.rotationDeg === 'number' && Number.isFinite(obj.rotationDeg) ? obj.rotationDeg : 0;
|
||||||
|
const path = normalizeTokenPath((obj as { path?: unknown }).path);
|
||||||
return {
|
return {
|
||||||
id: asSceneTokenId(obj.id),
|
id: asSceneTokenId(obj.id),
|
||||||
tokenId: asTokenId(obj.tokenId),
|
tokenId: asTokenId(obj.tokenId),
|
||||||
@@ -63,6 +67,7 @@ export function normalizeSceneToken(raw: unknown): SceneToken | null {
|
|||||||
ny: Math.max(0, Math.min(1, ny)),
|
ny: Math.max(0, Math.min(1, ny)),
|
||||||
sizeN,
|
sizeN,
|
||||||
rotationDeg,
|
rotationDeg,
|
||||||
|
...(path ? { path } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export * from './npcs';
|
|||||||
export * from './sceneDarkness';
|
export * from './sceneDarkness';
|
||||||
export * from './sceneGrid';
|
export * from './sceneGrid';
|
||||||
export * from './sceneGridSnap';
|
export * from './sceneGridSnap';
|
||||||
|
export * from './tokenPath';
|
||||||
|
export * from './tokenPathSession';
|
||||||
|
export * from './tokenPathPlayback';
|
||||||
export * from './scenePreviewRotation';
|
export * from './scenePreviewRotation';
|
||||||
export * from './sceneTraps';
|
export * from './sceneTraps';
|
||||||
export * from './sceneView';
|
export * from './sceneView';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { rotateTokenPathByCwSteps, type TokenPath } from './tokenPath';
|
||||||
|
|
||||||
/** Rotate map-normalized markers with scene previewRotationDeg (CSS rotate, y-down). */
|
/** Rotate map-normalized markers with scene previewRotationDeg (CSS rotate, y-down). */
|
||||||
|
|
||||||
export type PreviewRotationDeg = 0 | 90 | 180 | 270;
|
export type PreviewRotationDeg = 0 | 90 | 180 | 270;
|
||||||
@@ -43,29 +45,46 @@ function clamp01(v: number): number {
|
|||||||
return Math.max(0, Math.min(1, v));
|
return Math.max(0, Math.min(1, v));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rotateMapMarkersByCwSteps<T extends { nx: number; ny: number }>(
|
export function rotateMapMarkersByCwSteps<T extends { nx: number; ny: number; path?: TokenPath | null }>(
|
||||||
items: readonly T[],
|
items: readonly T[],
|
||||||
steps: number,
|
steps: number,
|
||||||
): T[] {
|
): T[] {
|
||||||
const n = ((steps % 4) + 4) % 4;
|
const n = ((steps % 4) + 4) % 4;
|
||||||
if (n === 0) return items.map((item) => ({ ...item }));
|
if (n === 0) {
|
||||||
|
return items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
...(item.path ? { path: rotateTokenPathByCwSteps(item.path, 0) } : null),
|
||||||
|
}));
|
||||||
|
}
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
const p = rotateMapNormPointByCwSteps(item.nx, item.ny, n);
|
const p = rotateMapNormPointByCwSteps(item.nx, item.ny, n);
|
||||||
return { ...item, nx: p.nx, ny: p.ny };
|
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. */
|
/** 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 }>(
|
export function rotateSceneTokensByCwSteps<
|
||||||
items: readonly T[],
|
T extends { nx: number; ny: number; rotationDeg: number; path?: TokenPath | null },
|
||||||
steps: number,
|
>(items: readonly T[], steps: number): T[] {
|
||||||
): T[] {
|
|
||||||
const n = ((steps % 4) + 4) % 4;
|
const n = ((steps % 4) + 4) % 4;
|
||||||
if (n === 0) return items.map((item) => ({ ...item }));
|
if (n === 0) {
|
||||||
|
return items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
...(item.path ? { path: rotateTokenPathByCwSteps(item.path, 0) } : null),
|
||||||
|
}));
|
||||||
|
}
|
||||||
const delta = n * 90;
|
const delta = n * 90;
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
const p = rotateMapNormPointByCwSteps(item.nx, item.ny, n);
|
const p = rotateMapNormPointByCwSteps(item.nx, item.ny, n);
|
||||||
return { ...item, nx: p.nx, ny: p.ny, rotationDeg: item.rotationDeg + delta };
|
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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||||
"lint": "eslint . --max-warnings 0",
|
"lint": "eslint . --max-warnings 0",
|
||||||
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
||||||
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/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 && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs",
|
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/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": "prettier . --check",
|
||||||
"format:write": "prettier . --write",
|
"format:write": "prettier . --write",
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
sceneDescription: path.resolve(__dirname, 'app/renderer/sceneDescription.html'),
|
sceneDescription: path.resolve(__dirname, 'app/renderer/sceneDescription.html'),
|
||||||
materials: path.resolve(__dirname, 'app/renderer/materials.html'),
|
materials: path.resolve(__dirname, 'app/renderer/materials.html'),
|
||||||
sceneEditor: path.resolve(__dirname, 'app/renderer/sceneEditor.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'),
|
npcsEditor: path.resolve(__dirname, 'app/renderer/npcsEditor.html'),
|
||||||
npcs: path.resolve(__dirname, 'app/renderer/npcs.html'),
|
npcs: path.resolve(__dirname, 'app/renderer/npcs.html'),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user