Merge branch 'users' into main

Players library, circular NPC tokens, disposition types; keep main overlay/release fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-04 08:03:22 +08:00
69 changed files with 5943 additions and 327 deletions
+3
View File
@@ -17,3 +17,6 @@ Thumbs.db
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
*.tsbuildinfo *.tsbuildinfo
test-results/
playwright-report/
e2e/fixtures/sample.png
+5
View File
@@ -387,6 +387,7 @@ export async function buildProjectFromFoundryDocuments(
darkenScene: false, darkenScene: false,
traps: [], traps: [],
tokens: [], tokens: [],
npcTokens: [],
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' }, grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
media: { videos: [], audios: audioRefs }, media: { videos: [], audios: audioRefs },
settings: { settings: {
@@ -446,6 +447,10 @@ export async function buildProjectFromFoundryDocuments(
x: 80 + (npcIndex % 4) * 220, x: 80 + (npcIndex % 4) * 220,
y: 80 + Math.floor(npcIndex / 4) * 200, y: 80 + Math.floor(npcIndex / 4) * 200,
groupId, groupId,
ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
}); });
npcIndex += 1; npcIndex += 1;
} }
+191 -33
View File
@@ -29,6 +29,9 @@ import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
import { ZipProjectStore } from './project/zipStore'; import { ZipProjectStore } from './project/zipStore';
import { SceneViewStore } from './sceneView/sceneViewStore'; import { SceneViewStore } from './sceneView/sceneViewStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol'; import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { PlayersStore } from './players/playersStore';
import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore';
import { ScenePlayerTokensSessionStore } from './players/scenePlayerTokensSessionStore';
import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore'; import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore';
import { TokensStore } from './tokens/tokensStore'; import { TokensStore } from './tokens/tokensStore';
import { installAutoUpdater } from './update/installAutoUpdater'; import { installAutoUpdater } from './update/installAutoUpdater';
@@ -88,21 +91,13 @@ function emitScenePreviewImportProgress(evt: ScenePreviewImportEvent): void {
} }
} }
function emitMaterialUpsertProgress(evt: { function emitMaterialUpsertProgress(evt: { percent: number; stage: string; detail?: string }): void {
percent: number;
stage: string;
detail?: string;
}): void {
for (const win of BrowserWindow.getAllWindows()) { for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.project.materialUpsertProgress, evt); win.webContents.send(ipcChannels.project.materialUpsertProgress, evt);
} }
} }
function emitNpcUpsertProgress(evt: { function emitNpcUpsertProgress(evt: { percent: number; stage: string; detail?: string }): void {
percent: number;
stage: string;
detail?: string;
}): void {
for (const win of BrowserWindow.getAllWindows()) { for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.project.npcUpsertProgress, evt); win.webContents.send(ipcChannels.project.npcUpsertProgress, evt);
} }
@@ -158,7 +153,10 @@ 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 sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore();
let tokensStore: TokensStore | null = null; let tokensStore: TokensStore | null = null;
let playersStore: PlayersStore | null = null;
function emitEffectsState(): void { function emitEffectsState(): void {
const state = effectsStore.getState(); const state = effectsStore.getState();
@@ -227,6 +225,34 @@ function emitSceneTokensSessionState(): void {
} }
} }
function emitPlayersState(): void {
const players = playersStore?.listPlayers() ?? [];
const teams = playersStore?.listTeams() ?? [];
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.players.stateChanged, { players, teams });
}
}
function emitPlayerUpsertProgress(p: { percent: number; stage: string; detail?: string }): void {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.players.upsertProgress, p);
}
}
function emitSceneNpcTokensSessionState(): void {
const state = sceneNpcTokensSessionStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.sceneNpcTokensSession.stateChanged, { state });
}
}
function emitScenePlayerTokensSessionState(): void {
const state = scenePlayerTokensSessionStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.scenePlayerTokensSession.stateChanged, { state });
}
}
function syncSceneDarknessForProject(project: Project): void { function syncSceneDarknessForProject(project: Project): void {
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null; const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined; const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined;
@@ -376,11 +402,13 @@ async function main() {
const licenseService = new LicenseService(app.getPath('userData')); const licenseService = new LicenseService(app.getPath('userData'));
tokensStore = new TokensStore(app.getPath('userData')); tokensStore = new TokensStore(app.getPath('userData'));
await tokensStore.ensureLoaded(); await tokensStore.ensureLoaded();
playersStore = new PlayersStore(app.getPath('userData'));
await playersStore.ensureLoaded();
setLicenseAssert(() => { setLicenseAssert(() => {
licenseService.assertForIpc(); licenseService.assertForIpc();
}); });
installAppMenuForSession(); installAppMenuForSession();
registerDndAssetProtocol(projectStore, tokensStore); registerDndAssetProtocol(projectStore, tokensStore, playersStore);
registerHandler(ipcChannels.app.quit, () => { registerHandler(ipcChannels.app.quit, () => {
markAppQuitting(); markAppQuitting();
app.quit(); app.quit();
@@ -395,10 +423,16 @@ async function main() {
registerHandler(ipcChannels.license.setToken, async ({ token }) => licenseService.setToken(token)); registerHandler(ipcChannels.license.setToken, async ({ token }) => licenseService.setToken(token));
registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken()); registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken());
registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version)); registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version));
registerHandler(ipcChannels.windows.openMultiWindow, () => { registerHandler(ipcChannels.windows.openMultiWindow, (req) => {
sceneDarknessStore.resetSession(); sceneDarknessStore.resetSession();
sceneTrapsStore.resetSession(); sceneTrapsStore.resetSession();
sceneTokensSessionStore.reset(); sceneTokensSessionStore.reset();
sceneNpcTokensSessionStore.reset();
scenePlayerTokensSessionStore.reset();
const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : [];
if (playerIds.length > 0) {
scenePlayerTokensSessionStore.dispatch({ kind: 'setSelection', playerIds });
}
effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() }); effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() });
openMultiWindow(); openMultiWindow();
const project = projectStore.getOpenProject(); const project = projectStore.getOpenProject();
@@ -409,6 +443,8 @@ async function main() {
emitSceneDarknessState(); emitSceneDarknessState();
emitSceneTrapsState(); emitSceneTrapsState();
emitSceneTokensSessionState(); emitSceneTokensSessionState();
emitSceneNpcTokensSessionState();
emitScenePlayerTokensSessionState();
emitEffectsState(); emitEffectsState();
return { ok: true }; return { ok: true };
}); });
@@ -513,8 +549,12 @@ async function main() {
const project = await projectStore.openProjectById(projectId); const project = await projectStore.openProjectById(projectId);
sceneViewStore.reset(); sceneViewStore.reset();
sceneTokensSessionStore.reset(); sceneTokensSessionStore.reset();
sceneNpcTokensSessionStore.reset();
scenePlayerTokensSessionStore.reset();
emitSceneViewState(); emitSceneViewState();
emitSceneTokensSessionState(); emitSceneTokensSessionState();
emitSceneNpcTokensSessionState();
emitScenePlayerTokensSessionState();
emitSessionState(); emitSessionState();
warmNpcsEditorWindow(); warmNpcsEditorWindow();
return { project }; return { project };
@@ -529,6 +569,8 @@ async function main() {
sceneTrapsStore.resetSession(); sceneTrapsStore.resetSession();
sceneViewStore.reset(); sceneViewStore.reset();
sceneTokensSessionStore.reset(); sceneTokensSessionStore.reset();
sceneNpcTokensSessionStore.reset();
scenePlayerTokensSessionStore.reset();
emitEffectsState(); emitEffectsState();
emitMaterialsOverlayState(); emitMaterialsOverlayState();
emitNpcsOverlayState(); emitNpcsOverlayState();
@@ -536,6 +578,8 @@ async function main() {
emitSceneTrapsState(); emitSceneTrapsState();
emitSceneViewState(); emitSceneViewState();
emitSceneTokensSessionState(); emitSceneTokensSessionState();
emitSceneNpcTokensSessionState();
emitScenePlayerTokensSessionState();
emitSessionState(); emitSessionState();
return { ok: true }; return { ok: true };
}); });
@@ -552,6 +596,7 @@ async function main() {
materialsOverlayStore.clear(); materialsOverlayStore.clear();
npcsOverlayStore.clear(); npcsOverlayStore.clear();
sceneViewStore.reset(); sceneViewStore.reset();
scenePlayerTokensSessionStore.clearPlacements();
// Token moves persist for the whole play session (reset only on project open/close). // Token moves persist for the whole play session (reset only on project open/close).
const project = projectStore.getOpenProject(); const project = projectStore.getOpenProject();
if (project) { if (project) {
@@ -565,6 +610,7 @@ async function main() {
emitSceneTrapsState(); emitSceneTrapsState();
emitSceneViewState(); emitSceneViewState();
emitSceneTokensSessionState(); emitSceneTokensSessionState();
emitScenePlayerTokensSessionState();
emitSessionState(); emitSessionState();
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null }; return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
}); });
@@ -581,6 +627,7 @@ async function main() {
materialsOverlayStore.clear(); materialsOverlayStore.clear();
npcsOverlayStore.clear(); npcsOverlayStore.clear();
sceneViewStore.reset(); sceneViewStore.reset();
scenePlayerTokensSessionStore.clearPlacements();
// Token moves persist for the whole play session (reset only on project open/close). // Token moves persist for the whole play session (reset only on project open/close).
const project = projectStore.getOpenProject(); const project = projectStore.getOpenProject();
if (project) { if (project) {
@@ -594,6 +641,7 @@ async function main() {
emitSceneTrapsState(); emitSceneTrapsState();
emitSceneViewState(); emitSceneViewState();
emitSceneTokensSessionState(); emitSceneTokensSessionState();
emitScenePlayerTokensSessionState();
emitSessionState(); emitSessionState();
const p = projectStore.getOpenProject(); const p = projectStore.getOpenProject();
return { return {
@@ -671,7 +719,9 @@ async function main() {
emitSessionState(); emitSessionState();
return { project }; return { project };
}); });
registerHandler(ipcChannels.project.upsertMaterial, async ({ materialId, name, filePath: pathFromDrop }) => { registerHandler(
ipcChannels.project.upsertMaterial,
async ({ materialId, name, filePath: pathFromDrop }) => {
let filePath = pathFromDrop; let filePath = pathFromDrop;
if (!filePath && !materialId) { if (!filePath && !materialId) {
const { canceled, filePaths } = await dialog.showOpenDialog({ const { canceled, filePaths } = await dialog.showOpenDialog({
@@ -700,7 +750,8 @@ async function main() {
emitMaterialsOverlayState(); emitMaterialsOverlayState();
emitSessionState(); emitSessionState();
return { project }; return { project };
}); },
);
registerHandler(ipcChannels.project.deleteMaterial, async ({ materialId }) => { registerHandler(ipcChannels.project.deleteMaterial, async ({ materialId }) => {
const project = await projectStore.deleteMaterial(materialId); const project = await projectStore.deleteMaterial(materialId);
syncMaterialsOverlayWithProject(project); syncMaterialsOverlayWithProject(project);
@@ -737,14 +788,13 @@ async function main() {
const filePath = filePaths[0]!; const filePath = filePaths[0]!;
const buf = await fs.readFile(filePath); const buf = await fs.readFile(filePath);
const ext = path.extname(filePath).toLowerCase(); const ext = path.extname(filePath).toLowerCase();
const mime = const mime = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`; const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
return { canceled: false as const, filePath, previewDataUrl }; return { canceled: false as const, filePath, previewDataUrl };
}); });
registerHandler( registerHandler(
ipcChannels.project.upsertNpc, ipcChannels.project.upsertNpc,
async ({ npcId, name, description, filePath: pathFromDrop, groupId }) => { async ({ npcId, name, description, filePath: pathFromDrop, groupId, ringColor, disposition, imageOffset }) => {
let filePath = pathFromDrop; let filePath = pathFromDrop;
if (!filePath && !npcId) { if (!filePath && !npcId) {
const { canceled, filePaths } = await dialog.showOpenDialog({ const { canceled, filePaths } = await dialog.showOpenDialog({
@@ -768,6 +818,9 @@ async function main() {
...(typeof description === 'string' ? { description } : {}), ...(typeof description === 'string' ? { description } : {}),
...(filePath ? { filePath } : {}), ...(filePath ? { filePath } : {}),
...(groupId !== undefined ? { groupId } : {}), ...(groupId !== undefined ? { groupId } : {}),
...(ringColor !== undefined ? { ringColor } : {}),
...(disposition !== undefined ? { disposition } : {}),
...(imageOffset !== undefined ? { imageOffset } : {}),
}, },
(p) => emitNpcUpsertProgress(p), (p) => emitNpcUpsertProgress(p),
); );
@@ -779,11 +832,15 @@ async function main() {
); );
registerHandler( registerHandler(
ipcChannels.project.updateNpcFields, ipcChannels.project.updateNpcFields,
async ({ npcId, name, description, groupId }) => { async ({ npcId, name, description, groupId, ringColor, disposition, imageOffset, imageScale }) => {
const project = await projectStore.updateNpcFields(npcId, { const project = await projectStore.updateNpcFields(npcId, {
...(typeof name === 'string' ? { name } : {}), ...(typeof name === 'string' ? { name } : {}),
...(typeof description === 'string' ? { description } : {}), ...(typeof description === 'string' ? { description } : {}),
...(groupId !== undefined ? { groupId } : {}), ...(groupId !== undefined ? { groupId } : {}),
...(ringColor !== undefined ? { ringColor } : {}),
...(disposition !== undefined ? { disposition } : {}),
...(imageOffset !== undefined ? { imageOffset } : {}),
...(imageScale !== undefined ? { imageScale } : {}),
}); });
emitSessionState(); emitSessionState();
return { project }; return { project };
@@ -820,8 +877,7 @@ async function main() {
const filePath = filePaths[0]!; const filePath = filePaths[0]!;
const buf = await fs.readFile(filePath); const buf = await fs.readFile(filePath);
const ext = path.extname(filePath).toLowerCase(); const ext = path.extname(filePath).toLowerCase();
const mime = const mime = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`; const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
return { canceled: false as const, filePath, previewDataUrl }; return { canceled: false as const, filePath, previewDataUrl };
}); });
@@ -843,9 +899,7 @@ async function main() {
emitSessionState(); emitSessionState();
return { project }; return { project };
}); });
registerHandler( registerHandler(ipcChannels.project.upsertNpcGroup, async ({ groupId, name, color, parentId }) => {
ipcChannels.project.upsertNpcGroup,
async ({ groupId, name, color, parentId }) => {
const project = await projectStore.upsertNpcGroup({ const project = await projectStore.upsertNpcGroup({
...(groupId ? { groupId } : {}), ...(groupId ? { groupId } : {}),
name, name,
@@ -854,8 +908,7 @@ async function main() {
}); });
emitSessionState(); emitSessionState();
return { project }; return { project };
}, });
);
registerHandler(ipcChannels.project.deleteNpcGroup, async ({ groupId }) => { registerHandler(ipcChannels.project.deleteNpcGroup, async ({ groupId }) => {
const project = await projectStore.deleteNpcGroup(groupId); const project = await projectStore.deleteNpcGroup(groupId);
syncNpcsOverlayWithProject(project); syncNpcsOverlayWithProject(project);
@@ -1029,9 +1082,12 @@ async function main() {
registerHandler(ipcChannels.project.peekImportZipPath, async ({ filePath, labels, targetHasMainStart }) => { registerHandler(ipcChannels.project.peekImportZipPath, async ({ filePath, labels, targetHasMainStart }) => {
return projectStore.peekImportFromZipPath(filePath, labels, targetHasMainStart); return projectStore.peekImportFromZipPath(filePath, labels, targetHasMainStart);
}); });
registerHandler(ipcChannels.project.peekImportFromProject, async ({ sourceProjectId, labels, targetHasMainStart }) => { registerHandler(
ipcChannels.project.peekImportFromProject,
async ({ sourceProjectId, labels, targetHasMainStart }) => {
return projectStore.peekImportFromProjectId(sourceProjectId, labels, targetHasMainStart); return projectStore.peekImportFromProjectId(sourceProjectId, labels, targetHasMainStart);
}); },
);
registerHandler( registerHandler(
ipcChannels.project.mergeImportZip, ipcChannels.project.mergeImportZip,
async ({ filePath, storylineSelections, sceneResolutions, npcResolutions }) => { async ({ filePath, storylineSelections, sceneResolutions, npcResolutions }) => {
@@ -1130,7 +1186,8 @@ async function main() {
const project = await projectStore.importProjectFromFoundry(sourcePath, (p) => { const project = await projectStore.importProjectFromFoundry(sourcePath, (p) => {
emitZipProgress({ emitZipProgress({
kind: 'import', kind: 'import',
stage: p.stage === 'unzip' ? 'unzip' : p.stage === 'zip' ? 'zip' : p.stage === 'done' ? 'done' : 'copy', stage:
p.stage === 'unzip' ? 'unzip' : p.stage === 'zip' ? 'zip' : p.stage === 'done' ? 'done' : 'copy',
percent: p.percent, percent: p.percent,
...(p.detail ? { detail: p.detail } : null), ...(p.detail ? { detail: p.detail } : null),
}); });
@@ -1143,7 +1200,9 @@ async function main() {
throw e; throw e;
} }
}); });
registerHandler(ipcChannels.project.exportZip, async ({ projectId, storylineSelections, npcIds, labels }) => { registerHandler(
ipcChannels.project.exportZip,
async ({ projectId, storylineSelections, npcIds, labels }) => {
const list = await projectStore.listProjects(); const list = await projectStore.listProjects();
const entry = list.find((p) => p.id === projectId); const entry = list.find((p) => p.id === projectId);
if (!entry) { if (!entry) {
@@ -1189,7 +1248,8 @@ async function main() {
emitZipProgress({ kind: 'export', stage: 'error', percent: 0, detail }); emitZipProgress({ kind: 'export', stage: 'error', percent: 0, detail });
throw err; throw err;
} }
}); },
);
registerHandler(ipcChannels.project.deleteProject, async ({ projectId }) => { registerHandler(ipcChannels.project.deleteProject, async ({ projectId }) => {
await projectStore.deleteProjectById(projectId); await projectStore.deleteProjectById(projectId);
emitSessionState(); emitSessionState();
@@ -1240,7 +1300,11 @@ async function main() {
return { tokens: tokensStore!.list() }; return { tokens: tokensStore!.list() };
}); });
registerHandler(ipcChannels.tokens.upsert, async ({ id, name, filePath }) => { registerHandler(ipcChannels.tokens.upsert, async ({ id, name, filePath }) => {
const token = await tokensStore!.upsert({ id, name, filePath }); const token = await tokensStore!.upsert({
name,
...(id !== undefined ? { id } : {}),
...(filePath !== undefined ? { filePath } : {}),
});
emitTokensState(); emitTokensState();
return { token }; return { token };
}); });
@@ -1275,8 +1339,7 @@ async function main() {
const filePath = filePaths[0]!; const filePath = filePaths[0]!;
const buf = await fs.readFile(filePath); const buf = await fs.readFile(filePath);
const ext = path.extname(filePath).toLowerCase(); const ext = path.extname(filePath).toLowerCase();
const mime = const mime = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`; const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
return { canceled: false as const, filePath, previewDataUrl }; return { canceled: false as const, filePath, previewDataUrl };
}); });
@@ -1292,6 +1355,101 @@ async function main() {
return { ok: true }; return { ok: true };
}); });
registerHandler(ipcChannels.players.list, async () => {
await playersStore!.ensureLoaded();
return { players: playersStore!.listPlayers(), teams: playersStore!.listTeams() };
});
registerHandler(
ipcChannels.players.upsert,
async ({ id, name, filePath, teamId, ringColor, imageOffset, imageScale }) => {
const player = await playersStore!.upsert(
{
name,
...(id !== undefined ? { id } : {}),
...(filePath !== undefined ? { filePath } : {}),
...(teamId !== undefined ? { teamId } : {}),
...(ringColor !== undefined ? { ringColor } : {}),
...(imageOffset !== undefined ? { imageOffset } : {}),
...(imageScale !== undefined ? { imageScale } : {}),
},
(p) => emitPlayerUpsertProgress(p),
);
emitPlayersState();
return { player };
},
);
registerHandler(ipcChannels.players.delete, async ({ id }) => {
await playersStore!.delete(id);
emitPlayersState();
return { ok: true };
});
registerHandler(ipcChannels.players.setOrder, async ({ playerIds }) => {
const players = await playersStore!.setPlayersOrder(playerIds);
emitPlayersState();
return { players };
});
registerHandler(ipcChannels.players.upsertTeam, async ({ id, name, color }) => {
const team = await playersStore!.upsertTeam({
name,
...(id !== undefined ? { id } : {}),
...(color !== undefined ? { color } : {}),
});
emitPlayersState();
return { team };
});
registerHandler(ipcChannels.players.deleteTeam, async ({ id }) => {
await playersStore!.deleteTeam(id);
emitPlayersState();
return { ok: true };
});
registerHandler(ipcChannels.players.setTeamsOrder, async ({ teamIds }) => {
const teams = await playersStore!.setTeamsOrder(teamIds);
emitPlayersState();
return { teams };
});
registerHandler(ipcChannels.players.assignTeam, async ({ playerId, teamId }) => {
const player = await playersStore!.assignPlayerTeam(playerId, teamId);
emitPlayersState();
return { player };
});
registerHandler(ipcChannels.players.pickImage, async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('images', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
if (canceled || filePaths.length === 0) return { canceled: true as const };
const filePath = filePaths[0]!;
const buf = await fs.readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
const mime = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
return { canceled: false as const, filePath, previewDataUrl };
});
registerHandler(ipcChannels.players.imageUrl, ({ id }) => {
return { url: playersStore!.getImageUrl(id) };
});
registerHandler(ipcChannels.sceneNpcTokensSession.getState, () => {
return { state: sceneNpcTokensSessionStore.getState() };
});
registerHandler(ipcChannels.sceneNpcTokensSession.dispatch, ({ event }) => {
sceneNpcTokensSessionStore.dispatch(event);
emitSceneNpcTokensSessionState();
return { ok: true };
});
registerHandler(ipcChannels.scenePlayerTokensSession.getState, () => {
return { state: scenePlayerTokensSessionStore.getState() };
});
registerHandler(ipcChannels.scenePlayerTokensSession.dispatch, ({ event }) => {
scenePlayerTokensSessionStore.dispatch(event);
emitScenePlayerTokensSessionState();
return { ok: true };
});
registerHandler(ipcChannels.video.getState, () => { registerHandler(ipcChannels.video.getState, () => {
return { state: videoStore.getState() }; return { state: videoStore.getState() };
}); });
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { PlayersStore } from './playersStore';
import { asPlayerId } from '../../shared/types/ids';
async function withTempStore(run: (store: PlayersStore, root: string) => Promise<void>) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-players-'));
const store = new PlayersStore(root);
try {
await run(store, root);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
}
void test('PlayersStore upsert list delete and teams', async () => {
await withTempStore(async (store, root) => {
const png = path.join(root, 'sample.png');
// minimal 1x1 png
const buf = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
await fs.writeFile(png, buf);
const progress: number[] = [];
const player = await store.upsert(
{ name: 'Ada', filePath: png, ringColor: '#112233', imageOffset: { x: 0.1, y: -0.1 } },
(p) => progress.push(p.percent),
);
assert.equal(player.name, 'Ada');
assert.equal(player.ringColor, '#112233');
assert.ok(progress.length > 0);
assert.equal(store.listPlayers().length, 1);
const team = await store.upsertTeam({ name: 'Party', color: '#abcdef' });
assert.equal(team.name, 'Party');
const assigned = await store.assignPlayerTeam(player.id, team.id);
assert.equal(assigned?.teamId, team.id);
await store.deleteTeam(team.id);
assert.equal(store.listTeams().length, 0);
assert.equal(store.getById(player.id)?.teamId, null);
await store.delete(player.id);
assert.equal(store.listPlayers().length, 0);
});
});
void test('PlayersStore persists across reload', async () => {
await withTempStore(async (store, root) => {
const png = path.join(root, 'sample.png');
await fs.writeFile(
png,
Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
),
);
const created = await store.upsert({ name: 'Bob', filePath: png });
const store2 = new PlayersStore(root);
await store2.ensureLoaded();
assert.equal(store2.listPlayers().length, 1);
assert.equal(store2.getById(asPlayerId(created.id))?.name, 'Bob');
});
});
+295
View File
@@ -0,0 +1,295 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import {
assignPlayerToTeam,
createPlayerTeamDraft,
deletePlayerTeam,
normalizeAppPlayerTeams,
uniquePlayerTeamName,
} from '../../shared/players/playerTeams';
import type {
AppPlayer,
AppPlayerTeam,
PlayerId,
PlayerImageOffset,
PlayersUpsertProgressEvent,
PlayerTeamId,
} from '../../shared/types/appPlayers';
import {
clampPlayerImageOffset,
clampPlayerImageScale,
DEFAULT_PLAYER_IMAGE_OFFSET,
DEFAULT_PLAYER_IMAGE_SCALE,
DEFAULT_PLAYER_RING_COLOR,
normalizeAppPlayer,
} from '../../shared/types/appPlayers';
import { asPlayerId } from '../../shared/types/ids';
import { normalizeHexColor } from '../../shared/npcs/npcGroups';
import { optimizeImageBufferVisuallyLossless } from '../project/optimizeImageImport.lib.mjs';
type PlayersManifest = {
players: AppPlayer[];
teams: AppPlayerTeam[];
};
function mimeFromExt(ext: string): string {
const e = ext.toLowerCase();
if (e === '.png') return 'image/png';
if (e === '.jpg' || e === '.jpeg') return 'image/jpeg';
if (e === '.webp') return 'image/webp';
if (e === '.gif') return 'image/gif';
return 'application/octet-stream';
}
function safeFileBase(name: string): string {
const base = name.replace(/[^\w.\-]+/gu, '_').slice(0, 48);
return base || 'player';
}
function randomPlayerId(): PlayerId {
return asPlayerId(`player_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
}
export class PlayersStore {
private readonly rootDir: string;
private readonly filesDir: string;
private readonly manifestPath: string;
private players: AppPlayer[] = [];
private teams: AppPlayerTeam[] = [];
private loaded = false;
constructor(userData: string) {
this.rootDir = path.join(userData, 'players');
this.filesDir = path.join(this.rootDir, 'files');
this.manifestPath = path.join(this.rootDir, 'players.json');
}
async ensureLoaded(): Promise<void> {
if (this.loaded) return;
await fs.mkdir(this.filesDir, { recursive: true });
try {
const raw = await fs.readFile(this.manifestPath, 'utf8');
const parsed = JSON.parse(raw) as PlayersManifest;
this.teams = normalizeAppPlayerTeams(parsed.teams);
const teamIds = new Set(this.teams.map((t) => t.id));
this.players = (Array.isArray(parsed.players) ? parsed.players : [])
.map((p) => normalizeAppPlayer(p, teamIds))
.filter((p): p is AppPlayer => Boolean(p));
} catch {
this.players = [];
this.teams = [];
await this.persist();
}
this.loaded = true;
}
listPlayers(): AppPlayer[] {
return [...this.players];
}
listTeams(): AppPlayerTeam[] {
return [...this.teams];
}
getById(id: PlayerId): AppPlayer | null {
return this.players.find((p) => p.id === id) ?? null;
}
getImageReadInfo(id: PlayerId): { absPath: string; mime: string } | null {
const player = this.getById(id);
if (!player) return null;
const absPath = path.join(this.rootDir, player.imageRelPath);
return { absPath, mime: mimeFromExt(path.extname(player.imageRelPath)) };
}
getImageUrl(id: PlayerId): string | null {
if (!this.getImageReadInfo(id)) return null;
return `dnd://player?id=${encodeURIComponent(id)}`;
}
async upsert(
input: {
id?: PlayerId | null;
name: string;
filePath?: string | null;
teamId?: PlayerTeamId | null;
ringColor?: string;
imageOffset?: PlayerImageOffset;
imageScale?: number;
},
onProgress?: (p: PlayersUpsertProgressEvent) => void,
): Promise<AppPlayer> {
await this.ensureLoaded();
const name = input.name.trim();
if (!name) throw new Error('Player name is required');
const existing = input.id ? this.getById(input.id) : null;
if (input.id && !existing) throw new Error('Player not found');
if (!existing && !input.filePath) throw new Error('Player image is required');
const emit = (percent: number, stage: string, detail?: string) => {
onProgress?.({ percent, stage, ...(detail ? { detail } : {}) });
};
emit(5, 'prepare', 'Подготовка…');
let imageRelPath = existing?.imageRelPath ?? '';
let sha256 = existing?.sha256 ?? '';
const id = existing?.id ?? randomPlayerId();
if (input.filePath) {
emit(15, 'read', 'Чтение изображения…');
let buf = await fs.readFile(input.filePath);
emit(40, 'optimize', 'Оптимизация…');
try {
const opt = await optimizeImageBufferVisuallyLossless(buf);
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
} catch {
/* keep original */
}
sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const ext = path.extname(input.filePath) || '.png';
const fileName = `${id}_${safeFileBase(name)}${ext.toLowerCase()}`;
imageRelPath = path.join('files', fileName).replace(/\\/gu, '/');
const abs = path.join(this.rootDir, imageRelPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
emit(75, 'write', 'Сохранение…');
await fs.writeFile(abs, buf);
if (existing && existing.imageRelPath !== imageRelPath) {
try {
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
} catch {
/* ignore */
}
}
}
const teamIds = new Set(this.teams.map((t) => t.id));
let teamId: PlayerTeamId | null =
input.teamId !== undefined ? input.teamId : (existing?.teamId ?? null);
if (teamId && !teamIds.has(teamId)) teamId = null;
const player: AppPlayer = {
id,
name,
imageRelPath,
sha256,
teamId,
ringColor: normalizeHexColor(
input.ringColor ?? existing?.ringColor,
DEFAULT_PLAYER_RING_COLOR,
),
imageOffset: clampPlayerImageOffset(
input.imageOffset ?? existing?.imageOffset ?? DEFAULT_PLAYER_IMAGE_OFFSET,
),
imageScale: clampPlayerImageScale(
input.imageScale ?? existing?.imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE,
),
};
if (existing) {
this.players = this.players.map((p) => (p.id === player.id ? player : p));
} else {
this.players = [...this.players, player];
}
emit(95, 'persist', 'Запись…');
await this.persist();
emit(100, 'done', 'Готово');
return player;
}
async delete(id: PlayerId): Promise<void> {
await this.ensureLoaded();
const existing = this.getById(id);
if (!existing) return;
this.players = this.players.filter((p) => p.id !== id);
await this.persist();
try {
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
} catch {
/* ignore */
}
}
async setPlayersOrder(playerIds: PlayerId[]): Promise<AppPlayer[]> {
await this.ensureLoaded();
const byId = new Map(this.players.map((p) => [p.id, p]));
const next: AppPlayer[] = [];
for (const id of playerIds) {
const p = byId.get(id);
if (p) {
next.push(p);
byId.delete(id);
}
}
for (const p of byId.values()) next.push(p);
this.players = next;
await this.persist();
return this.listPlayers();
}
async upsertTeam(input: {
id?: PlayerTeamId | null;
name: string;
color?: string;
}): Promise<AppPlayerTeam> {
await this.ensureLoaded();
const existing = input.id ? this.teams.find((t) => t.id === input.id) : null;
if (input.id && !existing) throw new Error('Team not found');
if (existing) {
const team: AppPlayerTeam = {
id: existing.id,
name: uniquePlayerTeamName(input.name, this.teams, existing.id),
color: normalizeHexColor(input.color ?? existing.color, DEFAULT_PLAYER_RING_COLOR),
};
this.teams = this.teams.map((t) => (t.id === team.id ? team : t));
await this.persist();
return team;
}
const team = createPlayerTeamDraft(input.name, input.color, this.teams);
this.teams = [...this.teams, team];
await this.persist();
return team;
}
async deleteTeam(id: PlayerTeamId): Promise<void> {
await this.ensureLoaded();
const next = deletePlayerTeam(this.teams, this.players, id);
this.teams = next.teams;
this.players = next.players;
await this.persist();
}
async setTeamsOrder(teamIds: PlayerTeamId[]): Promise<AppPlayerTeam[]> {
await this.ensureLoaded();
const byId = new Map(this.teams.map((t) => [t.id, t]));
const next: AppPlayerTeam[] = [];
for (const id of teamIds) {
const t = byId.get(id);
if (t) {
next.push(t);
byId.delete(id);
}
}
for (const t of byId.values()) next.push(t);
this.teams = next;
await this.persist();
return this.listTeams();
}
async assignPlayerTeam(playerId: PlayerId, teamId: PlayerTeamId | null): Promise<AppPlayer | null> {
await this.ensureLoaded();
const teamIds = new Set(this.teams.map((t) => t.id as string));
this.players = assignPlayerToTeam(this.players, playerId, teamId, teamIds);
await this.persist();
return this.getById(playerId);
}
private async persist(): Promise<void> {
await fs.mkdir(this.rootDir, { recursive: true });
const payload: PlayersManifest = { players: this.players, teams: this.teams };
await fs.writeFile(this.manifestPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
}
}
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../shared/types/appPlayers';
import { SceneNpcTokensSessionStore } from './sceneNpcTokensSessionStore';
void test('SceneNpcTokensSessionStore move and reset', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.3 });
assert.equal(s1.byPlacementId.a?.nx, 0.2);
assert.equal(s1.revision, 2);
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.3 });
assert.equal(s2.revision, s1.revision); // no-op
const s3 = store.reset();
assert.deepEqual(s3.byPlacementId, {});
assert.equal(s3.scale, DEFAULT_NPC_TOKEN_SESSION_SCALE);
assert.ok(s3.revision > s1.revision);
});
void test('SceneNpcTokensSessionStore setScale', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({ kind: 'setScale', scale: 1.5 });
assert.equal(s1.scale, 1.5);
store.dispatch({ kind: 'move', placementId: 'a', nx: 0.1, ny: 0.2 });
const s2 = store.dispatch({ kind: 'setScale', scale: 0.1 });
assert.equal(s2.scale, 0.4); // clamped min
assert.ok(s2.byPlacementId.a);
const s3 = store.reset();
assert.equal(s3.scale, DEFAULT_NPC_TOKEN_SESSION_SCALE);
assert.deepEqual(s3.byPlacementId, {});
});
void test('SceneNpcTokensSessionStore setDisposition without inventing position', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({
kind: 'setDisposition',
placementId: 'a',
disposition: 'hostile',
});
assert.equal(s1.byPlacementId.a?.disposition, 'hostile');
assert.equal(s1.byPlacementId.a?.nx, undefined);
assert.equal(s1.byPlacementId.a?.ny, undefined);
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.4, ny: 0.6 });
assert.equal(s2.byPlacementId.a?.nx, 0.4);
assert.equal(s2.byPlacementId.a?.disposition, 'hostile');
});
void test('SceneNpcTokensSessionStore setInactive is session flag', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: true });
assert.equal(s1.byPlacementId.a?.inactive, true);
const s2 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: false });
assert.deepEqual(s2.byPlacementId, {});
store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.3 });
store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: true });
const s3 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: false });
assert.equal(s3.byPlacementId.a?.nx, 0.2);
assert.equal(s3.byPlacementId.a?.inactive, undefined);
});
@@ -0,0 +1,153 @@
import {
clampNpcTokenSessionScale,
DEFAULT_NPC_TOKEN_SESSION_SCALE,
type SceneNpcTokensSessionEvent,
type SceneNpcTokensSessionPlacement,
type SceneNpcTokensSessionState,
} from '../../shared/types/appPlayers';
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
function emptyState(revision = 1): SceneNpcTokensSessionState {
return {
revision,
byPlacementId: {},
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
};
}
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
return p.nx === undefined && p.ny === undefined && !p.disposition && !p.inactive;
}
function withMove(
prev: SceneNpcTokensSessionPlacement | undefined,
nx: number,
ny: number,
): SceneNpcTokensSessionPlacement {
return {
nx,
ny,
...(prev?.disposition ? { disposition: prev.disposition } : {}),
...(prev?.inactive ? { inactive: true } : {}),
};
}
function withoutPlacement(
state: SceneNpcTokensSessionState,
placementId: string,
): SceneNpcTokensSessionState {
if (!(placementId in state.byPlacementId)) return state;
const { [placementId]: _removed, ...rest } = state.byPlacementId;
return {
revision: state.revision + 1,
byPlacementId: rest,
scale: state.scale,
};
}
export class SceneNpcTokensSessionStore {
private state: SceneNpcTokensSessionState = emptyState();
getState(): SceneNpcTokensSessionState {
return this.state;
}
reset(): SceneNpcTokensSessionState {
if (
Object.keys(this.state.byPlacementId).length === 0 &&
this.state.scale === DEFAULT_NPC_TOKEN_SESSION_SCALE
) {
return this.state;
}
this.state = emptyState(this.state.revision + 1);
return this.state;
}
dispatch(event: SceneNpcTokensSessionEvent): SceneNpcTokensSessionState {
switch (event.kind) {
case 'clear':
return this.reset();
case 'setScale': {
const scale = clampNpcTokenSessionScale(event.scale);
if (this.state.scale === scale) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
scale,
};
return this.state;
}
case 'move': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const nx = Math.max(0, Math.min(1, event.nx));
const ny = Math.max(0, Math.min(1, event.ny));
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
const prev = this.state.byPlacementId[placementId];
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: withMove(prev, nx, ny),
},
scale: this.state.scale,
};
return this.state;
}
case 'setDisposition': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const disposition = normalizeNpcDisposition(event.disposition);
const prev = this.state.byPlacementId[placementId];
if (prev?.disposition === disposition) return this.state;
const next: SceneNpcTokensSessionPlacement = {
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
disposition,
...(prev?.inactive ? { inactive: true } : {}),
};
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: next,
},
scale: this.state.scale,
};
return this.state;
}
case 'setInactive': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const inactive = Boolean(event.inactive);
const prev = this.state.byPlacementId[placementId];
if (Boolean(prev?.inactive) === inactive) return this.state;
const next: SceneNpcTokensSessionPlacement = {
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
...(prev?.disposition ? { disposition: prev.disposition } : {}),
...(inactive ? { inactive: true } : {}),
};
if (isEmptyPlacement(next)) {
this.state = withoutPlacement(this.state, placementId);
return this.state;
}
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: next,
},
scale: this.state.scale,
};
return this.state;
}
default: {
const _exhaustive: never = event;
void _exhaustive;
return this.state;
}
}
}
}
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ScenePlayerTokensSessionStore } from './scenePlayerTokensSessionStore';
void test('ScenePlayerTokensSessionStore selection and show/hide', () => {
const store = new ScenePlayerTokensSessionStore();
store.dispatch({ kind: 'setSelection', playerIds: ['a', 'b', 'a'] });
assert.deepEqual(store.getState().selectedPlayerIds, ['a', 'b']);
assert.equal(store.getState().visible, false);
store.dispatch({ kind: 'show', sizeN: 0.08 });
assert.equal(store.getState().visible, true);
assert.equal(Object.keys(store.getState().byPlayerId).length, 2);
assert.ok(store.getState().byPlayerId.a);
assert.equal(store.getState().byPlayerId.a?.ny, 0.88);
store.dispatch({ kind: 'setVisible', visible: false });
assert.equal(store.getState().visible, false);
assert.ok(store.getState().byPlayerId.a);
store.dispatch({ kind: 'clearPlacements' });
assert.equal(store.getState().visible, false);
assert.deepEqual(store.getState().byPlayerId, {});
assert.deepEqual(store.getState().selectedPlayerIds, ['a', 'b']);
});
void test('ScenePlayerTokensSessionStore move and reset', () => {
const store = new ScenePlayerTokensSessionStore();
store.dispatch({ kind: 'setSelection', playerIds: ['p1'] });
store.dispatch({ kind: 'show', sizeN: 0.1 });
const s1 = store.dispatch({ kind: 'move', playerId: 'p1', nx: 0.3, ny: 0.4 });
assert.equal(s1.byPlayerId.p1?.nx, 0.3);
assert.equal(s1.byPlayerId.p1?.ny, 0.4);
const s2 = store.reset();
assert.deepEqual(s2.selectedPlayerIds, []);
assert.equal(s2.visible, false);
assert.deepEqual(s2.byPlayerId, {});
});
@@ -0,0 +1,143 @@
import {
clampSceneNpcTokenSizeN,
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
layoutPlayerTokensBottom,
type ScenePlayerTokensSessionEvent,
type ScenePlayerTokensSessionState,
} from '../../shared/types/appPlayers';
function emptyState(revision = 1): ScenePlayerTokensSessionState {
return {
revision,
selectedPlayerIds: [],
visible: false,
byPlayerId: {},
};
}
function normalizePlayerIds(raw: readonly string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const id of raw) {
const s = String(id ?? '').trim();
if (!s || seen.has(s)) continue;
seen.add(s);
out.push(s);
}
return out;
}
export class ScenePlayerTokensSessionStore {
private state: ScenePlayerTokensSessionState = emptyState();
getState(): ScenePlayerTokensSessionState {
return this.state;
}
reset(): ScenePlayerTokensSessionState {
if (
this.state.selectedPlayerIds.length === 0 &&
!this.state.visible &&
Object.keys(this.state.byPlayerId).length === 0
) {
return this.state;
}
this.state = emptyState(this.state.revision + 1);
return this.state;
}
/** Смена сцены: спрятать токены, сбросить позиции; выбор игроков сохранить. */
clearPlacements(): ScenePlayerTokensSessionState {
if (!this.state.visible && Object.keys(this.state.byPlayerId).length === 0) {
return this.state;
}
this.state = {
...this.state,
revision: this.state.revision + 1,
visible: false,
byPlayerId: {},
};
return this.state;
}
dispatch(event: ScenePlayerTokensSessionEvent): ScenePlayerTokensSessionState {
switch (event.kind) {
case 'clear':
return this.reset();
case 'clearPlacements':
return this.clearPlacements();
case 'setSelection': {
const selectedPlayerIds = normalizePlayerIds(event.playerIds);
const same =
selectedPlayerIds.length === this.state.selectedPlayerIds.length &&
selectedPlayerIds.every((id, i) => id === this.state.selectedPlayerIds[i]);
if (same) return this.state;
this.state = {
revision: this.state.revision + 1,
selectedPlayerIds,
visible: false,
byPlayerId: {},
};
return this.state;
}
case 'setVisible': {
const visible = Boolean(event.visible);
if (this.state.visible === visible) return this.state;
if (visible && this.state.selectedPlayerIds.length === 0) return this.state;
this.state = { ...this.state, revision: this.state.revision + 1, visible };
return this.state;
}
case 'show': {
if (this.state.selectedPlayerIds.length === 0) return this.state;
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
const byPlayerId =
Object.keys(this.state.byPlayerId).length > 0
? this.state.byPlayerId
: layoutPlayerTokensBottom(this.state.selectedPlayerIds, sizeN);
if (this.state.visible && byPlayerId === this.state.byPlayerId) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
visible: true,
byPlayerId,
};
return this.state;
}
case 'seedBottom': {
if (this.state.selectedPlayerIds.length === 0) return this.state;
if (Object.keys(this.state.byPlayerId).length > 0) return this.state;
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
this.state = {
...this.state,
revision: this.state.revision + 1,
byPlayerId: layoutPlayerTokensBottom(this.state.selectedPlayerIds, sizeN),
};
return this.state;
}
case 'move': {
const playerId = String(event.playerId ?? '');
if (!playerId || !this.state.selectedPlayerIds.includes(playerId)) return this.state;
const nx = Math.max(0, Math.min(1, event.nx));
const ny = Math.max(0, Math.min(1, event.ny));
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
const prev = this.state.byPlayerId[playerId];
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
const sizeN = clampSceneNpcTokenSizeN(prev?.sizeN ?? DEFAULT_SCENE_NPC_TOKEN_SIZE_N);
this.state = {
...this.state,
revision: this.state.revision + 1,
byPlayerId: {
...this.state.byPlayerId,
[playerId]: { nx, ny, sizeN },
},
};
return this.state;
}
default: {
const _exhaustive: never = event;
void _exhaustive;
return this.state;
}
}
}
}
+137 -47
View File
@@ -53,9 +53,29 @@ import type {
import { PROJECT_SCHEMA_VERSION } from '../../shared/types'; import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
import { normalizeMaterialLegend } from '../../shared/types/materialLegend'; import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens'; import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens';
import {
clampPlayerImageOffset,
clampPlayerImageScale,
DEFAULT_PLAYER_RING_COLOR,
normalizeSceneNpcToken,
type SceneNpcToken,
} from '../../shared/types/appPlayers';
import {
DEFAULT_NPC_DISPOSITION,
normalizeNpcDisposition,
npcDispositionRingColor,
type NpcDisposition,
} from '../../shared/types/npcDisposition';
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid'; import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
import { normalizeSceneTrap } from '../../shared/types/sceneTraps'; import { normalizeSceneTrap } from '../../shared/types/sceneTraps';
import type { AssetId, GraphNodeId, MaterialId, NpcGroupId, NpcId, NpcRelationId } from '../../shared/types/ids'; import type {
AssetId,
GraphNodeId,
MaterialId,
NpcGroupId,
NpcId,
NpcRelationId,
} from '../../shared/types/ids';
import { import {
asAssetId, asAssetId,
asGraphNodeId, asGraphNodeId,
@@ -614,6 +634,7 @@ export class ZipProjectStore {
darkenScene: false, darkenScene: false,
traps: [], traps: [],
tokens: [], tokens: [],
npcTokens: [],
grid: { ...DEFAULT_SCENE_GRID }, grid: { ...DEFAULT_SCENE_GRID },
} satisfies Scene); } satisfies Scene);
@@ -621,6 +642,7 @@ export class ZipProjectStore {
...base, ...base,
traps: base.traps ?? [], traps: base.traps ?? [],
tokens: base.tokens ?? [], tokens: base.tokens ?? [],
npcTokens: base.npcTokens ?? [],
grid: base.grid ?? { ...DEFAULT_SCENE_GRID }, grid: base.grid ?? { ...DEFAULT_SCENE_GRID },
...(patch.title !== undefined ? { title: patch.title } : null), ...(patch.title !== undefined ? { title: patch.title } : null),
...(patch.description !== undefined ? { description: patch.description } : null), ...(patch.description !== undefined ? { description: patch.description } : null),
@@ -636,9 +658,7 @@ export class ZipProjectStore {
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null), ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
...(patch.traps !== undefined ...(patch.traps !== undefined
? { ? {
traps: patch.traps traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)),
.map((t) => normalizeSceneTrap(t))
.filter((t): t is SceneTrap => Boolean(t)),
} }
: null), : null),
...(patch.tokens !== undefined ...(patch.tokens !== undefined
@@ -648,6 +668,13 @@ export class ZipProjectStore {
.filter((t): t is SceneToken => Boolean(t)), .filter((t): t is SceneToken => Boolean(t)),
} }
: null), : null),
...(patch.npcTokens !== undefined
? {
npcTokens: patch.npcTokens
.map((t) => normalizeSceneNpcToken(t))
.filter((t): t is SceneNpcToken => Boolean(t)),
}
: null),
...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null), ...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null),
...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null), ...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
...(patch.media ? { media: { ...base.media, ...patch.media } } : null), ...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
@@ -798,7 +825,10 @@ export class ZipProjectStore {
const node = open.project.sceneGraphNodes.find((n) => n.id === graphNodeId); const node = open.project.sceneGraphNodes.find((n) => n.id === graphNodeId);
if (!node) throw new Error('Graph node not found'); if (!node) throw new Error('Graph node not found');
const enabling = !node.isSideStoryStart; const enabling = !node.isSideStoryStart;
if (enabling && !canSetSideStoryStart(open.project.sceneGraphNodes, open.project.sceneGraphEdges, graphNodeId)) { if (
enabling &&
!canSetSideStoryStart(open.project.sceneGraphNodes, open.project.sceneGraphEdges, graphNodeId)
) {
return open.project; return open.project;
} }
await this.updateProject((p) => { await this.updateProject((p) => {
@@ -1158,16 +1188,11 @@ export class ZipProjectStore {
return latest; return latest;
} }
async setMaterialRotation( async setMaterialRotation(materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270): Promise<Project> {
materialId: MaterialId,
rotationDeg: 0 | 90 | 180 | 270,
): Promise<Project> {
const open = this.openProject; const open = this.openProject;
if (!open) throw new Error('No open project'); if (!open) throw new Error('No open project');
await this.updateProject((p) => { await this.updateProject((p) => {
const materials = (p.materials ?? []).map((m) => const materials = (p.materials ?? []).map((m) => (m.id === materialId ? { ...m, rotationDeg } : m));
m.id === materialId ? { ...m, rotationDeg } : m,
);
return { ...p, materials }; return { ...p, materials };
}); });
const latest = this.getOpenProject(); const latest = this.getOpenProject();
@@ -1243,6 +1268,10 @@ export class ZipProjectStore {
description?: string; description?: string;
filePath?: string; filePath?: string;
groupId?: NpcGroupId | null; groupId?: NpcGroupId | null;
ringColor?: string;
disposition?: NpcDisposition;
imageOffset?: { x: number; y: number };
imageScale?: number;
}, },
onProgress?: (p: { percent: number; stage: string; detail?: string }) => void, onProgress?: (p: { percent: number; stage: string; detail?: string }) => void,
): Promise<Project> { ): Promise<Project> {
@@ -1300,7 +1329,10 @@ export class ZipProjectStore {
if (stagedAsset) assets[stagedAsset.id] = stagedAsset; if (stagedAsset) assets[stagedAsset.id] = stagedAsset;
const groupIds = new Set((p.npcGroups ?? []).map((g) => g.id)); const groupIds = new Set((p.npcGroups ?? []).map((g) => g.id));
const resolveGroup = (raw: NpcGroupId | null | undefined, prev: NpcGroupId | null): NpcGroupId | null => { const resolveGroup = (
raw: NpcGroupId | null | undefined,
prev: NpcGroupId | null,
): NpcGroupId | null => {
if (raw === undefined) return prev; if (raw === undefined) return prev;
if (raw === null) return null; if (raw === null) return null;
return groupIds.has(raw) ? raw : null; return groupIds.has(raw) ? raw : null;
@@ -1314,13 +1346,28 @@ export class ZipProjectStore {
...prev, ...prev,
name, name,
avatarAssetId: nextAssetId ?? prev.avatarAssetId, avatarAssetId: nextAssetId ?? prev.avatarAssetId,
description: description: typeof input.description === 'string' ? input.description : prev.description,
typeof input.description === 'string' ? input.description : prev.description,
groupId: resolveGroup(input.groupId, prev.groupId), groupId: resolveGroup(input.groupId, prev.groupId),
...(input.ringColor !== undefined
? { ringColor: normalizeHexColor(input.ringColor, DEFAULT_PLAYER_RING_COLOR) }
: {}),
...(input.disposition !== undefined
? {
disposition: normalizeNpcDisposition(input.disposition),
ringColor: npcDispositionRingColor(normalizeNpcDisposition(input.disposition)),
}
: {}),
...(input.imageOffset !== undefined
? { imageOffset: clampPlayerImageOffset(input.imageOffset) }
: {}),
...(input.imageScale !== undefined
? { imageScale: clampPlayerImageScale(input.imageScale) }
: {}),
}; };
} else { } else {
if (!nextAssetId) throw new Error('NPC avatar is required'); if (!nextAssetId) throw new Error('NPC avatar is required');
const count = npcs.length; const count = npcs.length;
const disposition = normalizeNpcDisposition(input.disposition ?? DEFAULT_NPC_DISPOSITION);
npcs.push({ npcs.push({
id: asNpcId(`npc_${this.randomId()}`), id: asNpcId(`npc_${this.randomId()}`),
name, name,
@@ -1329,6 +1376,10 @@ export class ZipProjectStore {
x: 80 + (count % 4) * 220, x: 80 + (count % 4) * 220,
y: 80 + Math.floor(count / 4) * 200, y: 80 + Math.floor(count / 4) * 200,
groupId: resolveGroup(input.groupId, null), groupId: resolveGroup(input.groupId, null),
disposition,
ringColor: npcDispositionRingColor(disposition),
imageOffset: clampPlayerImageOffset(input.imageOffset),
imageScale: clampPlayerImageScale(input.imageScale),
}); });
} }
return { ...p, assets, npcs }; return { ...p, assets, npcs };
@@ -1346,20 +1397,19 @@ export class ZipProjectStore {
name?: string; name?: string;
description?: string; description?: string;
groupId?: NpcGroupId | null; groupId?: NpcGroupId | null;
ringColor?: string;
disposition?: NpcDisposition;
imageOffset?: { x: number; y: number };
imageScale?: number;
}, },
): Promise<Project> { ): Promise<Project> {
const open = this.openProject; const open = this.openProject;
if (!open) throw new Error('No open project'); if (!open) throw new Error('No open project');
const name = const name = typeof patch.name === 'string' ? patch.name.trim() : undefined;
typeof patch.name === 'string' ? patch.name.trim() : undefined;
if (name !== undefined) { if (name !== undefined) {
if (name.length < 1) throw new Error('NPC name is required'); if (name.length < 1) throw new Error('NPC name is required');
const nameKey = name.toLowerCase(); const nameKey = name.toLowerCase();
if ( if ((open.project.npcs ?? []).some((n) => n.id !== npcId && n.name.trim().toLowerCase() === nameKey)) {
(open.project.npcs ?? []).some(
(n) => n.id !== npcId && n.name.trim().toLowerCase() === nameKey,
)
) {
throw new Error('NPC name already exists'); throw new Error('NPC name already exists');
} }
} }
@@ -1369,14 +1419,30 @@ export class ZipProjectStore {
if (n.id !== npcId) return n; if (n.id !== npcId) return n;
let groupId = n.groupId; let groupId = n.groupId;
if (patch.groupId !== undefined) { if (patch.groupId !== undefined) {
groupId = groupId = patch.groupId === null ? null : groupIds.has(patch.groupId) ? patch.groupId : null;
patch.groupId === null ? null : groupIds.has(patch.groupId) ? patch.groupId : null;
} }
const disposition =
patch.disposition !== undefined
? normalizeNpcDisposition(patch.disposition)
: normalizeNpcDisposition(n.disposition);
return { return {
...n, ...n,
...(name !== undefined ? { name } : {}), ...(name !== undefined ? { name } : {}),
...(typeof patch.description === 'string' ? { description: patch.description } : {}), ...(typeof patch.description === 'string' ? { description: patch.description } : {}),
groupId, groupId,
disposition,
ringColor:
patch.disposition !== undefined
? npcDispositionRingColor(disposition)
: patch.ringColor !== undefined
? normalizeHexColor(patch.ringColor, DEFAULT_PLAYER_RING_COLOR)
: n.ringColor || npcDispositionRingColor(disposition),
...(patch.imageOffset !== undefined
? { imageOffset: clampPlayerImageOffset(patch.imageOffset) }
: {}),
...(patch.imageScale !== undefined
? { imageScale: clampPlayerImageScale(patch.imageScale) }
: {}),
}; };
}); });
return { ...p, npcs }; return { ...p, npcs };
@@ -1401,13 +1467,25 @@ export class ZipProjectStore {
async deleteNpc(npcId: NpcId): Promise<Project> { async deleteNpc(npcId: NpcId): Promise<Project> {
const open = this.openProject; const open = this.openProject;
if (!open) throw new Error('No open project'); if (!open) throw new Error('No open project');
await this.updateProject((p) => ({ await this.updateProject((p) => {
const scenes: Record<SceneId, Scene> = { ...p.scenes };
for (const sid of Object.keys(scenes) as SceneId[]) {
const sc = scenes[sid];
if (!sc) continue;
const npcTokens = (sc.npcTokens ?? []).filter((t) => t.npcId !== npcId);
if (npcTokens.length !== (sc.npcTokens ?? []).length) {
scenes[sid] = { ...sc, npcTokens };
}
}
return {
...p, ...p,
scenes,
npcs: (p.npcs ?? []).filter((n) => n.id !== npcId), npcs: (p.npcs ?? []).filter((n) => n.id !== npcId),
npcRelations: (p.npcRelations ?? []).filter( npcRelations: (p.npcRelations ?? []).filter(
(r) => r.sourceNpcId !== npcId && r.targetNpcId !== npcId, (r) => r.sourceNpcId !== npcId && r.targetNpcId !== npcId,
), ),
})); };
});
const latest = this.getOpenProject(); const latest = this.getOpenProject();
if (!latest) throw new Error('No open project'); if (!latest) throw new Error('No open project');
return latest; return latest;
@@ -1462,10 +1540,7 @@ export class ZipProjectStore {
} }
const nameKey = name.toLowerCase(); const nameKey = name.toLowerCase();
const siblingConflict = groups.some( const siblingConflict = groups.some(
(g) => (g) => g.id !== editingId && g.parentId === parentId && g.name.trim().toLowerCase() === nameKey,
g.id !== editingId &&
g.parentId === parentId &&
g.name.trim().toLowerCase() === nameKey,
); );
if (siblingConflict) throw new Error('Group name already exists'); if (siblingConflict) throw new Error('Group name already exists');
@@ -1498,9 +1573,7 @@ export class ZipProjectStore {
const nextGroups = groups const nextGroups = groups
.filter((g) => g.id !== groupId) .filter((g) => g.id !== groupId)
.map((g) => (g.parentId === groupId ? { ...g, parentId: parentOfDeleted } : g)); .map((g) => (g.parentId === groupId ? { ...g, parentId: parentOfDeleted } : g));
const npcs = (p.npcs ?? []).map((n) => const npcs = (p.npcs ?? []).map((n) => (n.groupId === groupId ? { ...n, groupId: null } : n));
n.groupId === groupId ? { ...n, groupId: null } : n,
);
return { ...p, npcGroups: nextGroups, npcs }; return { ...p, npcGroups: nextGroups, npcs };
}); });
const latest = this.getOpenProject(); const latest = this.getOpenProject();
@@ -1541,10 +1614,7 @@ export class ZipProjectStore {
if (label.length < 1) throw new Error('Relation label is required'); if (label.length < 1) throw new Error('Relation label is required');
if (input.sourceNpcId === input.targetNpcId) throw new Error('Cannot relate NPC to itself'); if (input.sourceNpcId === input.targetNpcId) throw new Error('Cannot relate NPC to itself');
const npcs = open.project.npcs ?? []; const npcs = open.project.npcs ?? [];
if ( if (!npcs.some((n) => n.id === input.sourceNpcId) || !npcs.some((n) => n.id === input.targetNpcId)) {
!npcs.some((n) => n.id === input.sourceNpcId) ||
!npcs.some((n) => n.id === input.targetNpcId)
) {
throw new Error('NPC not found'); throw new Error('NPC not found');
} }
await this.updateProject((p) => { await this.updateProject((p) => {
@@ -2089,16 +2159,14 @@ export class ZipProjectStore {
sourceForMerge = remapProjectSceneTokenIds(source, remap); sourceForMerge = remapProjectSceneTokenIds(source, remap);
} }
const offsetX = computeGraphImportOffsetX(this.openProject.project); const offsetX = computeGraphImportOffsetX(this.openProject.project);
const { project: merged, report, assetCopies } = mergeStorylinesIntoProject( const {
this.openProject.project, project: merged,
sourceForMerge, report,
selections, assetCopies,
sceneResolutions, } = mergeStorylinesIntoProject(this.openProject.project, sourceForMerge, selections, sceneResolutions, {
{
graphOffsetX: offsetX, graphOffsetX: offsetX,
...(npcResolutions ? { npcResolutions } : {}), ...(npcResolutions ? { npcResolutions } : {}),
}, });
);
const targetCache = this.openProject.cacheDir; const targetCache = this.openProject.cacheDir;
await fs.mkdir(path.join(targetCache, 'assets'), { recursive: true }); await fs.mkdir(path.join(targetCache, 'assets'), { recursive: true });
@@ -2307,6 +2375,10 @@ function normalizeScene(s: Scene): Scene {
const tokens = (Array.isArray(rawTokens) ? rawTokens : []) const tokens = (Array.isArray(rawTokens) ? rawTokens : [])
.map((t) => normalizeSceneToken(t)) .map((t) => normalizeSceneToken(t))
.filter((t): t is SceneToken => Boolean(t)); .filter((t): t is SceneToken => Boolean(t));
const rawNpcTokens = (s as unknown as { npcTokens?: unknown[] }).npcTokens;
const npcTokens = (Array.isArray(rawNpcTokens) ? rawNpcTokens : [])
.map((t) => normalizeSceneNpcToken(t))
.filter((t): t is SceneNpcToken => Boolean(t));
const grid = normalizeSceneGrid((s as unknown as { grid?: unknown }).grid); const grid = normalizeSceneGrid((s as unknown as { grid?: unknown }).grid);
const rawAudios = Array.isArray(raw.audios) ? raw.audios : []; const rawAudios = Array.isArray(raw.audios) ? raw.audios : [];
@@ -2338,6 +2410,7 @@ function normalizeScene(s: Scene): Scene {
darkenScene, darkenScene,
traps, traps,
tokens, tokens,
npcTokens,
grid, grid,
layout: layoutIn ?? { x: 0, y: 0 }, layout: layoutIn ?? { x: 0, y: 0 },
media: { media: {
@@ -2429,6 +2502,8 @@ function normalizeProject(p: Project): Project {
x?: number; x?: number;
y?: number; y?: number;
groupId?: string | null; groupId?: string | null;
ringColor?: string;
imageOffset?: unknown;
}; };
if (!obj.id || !obj.avatarAssetId || typeof obj.name !== 'string') return null; if (!obj.id || !obj.avatarAssetId || typeof obj.name !== 'string') return null;
const name = obj.name.trim(); const name = obj.name.trim();
@@ -2444,6 +2519,12 @@ function normalizeProject(p: Project): Project {
x, x,
y, y,
groupId: resolveNpcGroupId(obj.groupId, groupIdSet), groupId: resolveNpcGroupId(obj.groupId, groupIdSet),
disposition: normalizeNpcDisposition((obj as { disposition?: unknown }).disposition),
ringColor: npcDispositionRingColor(
normalizeNpcDisposition((obj as { disposition?: unknown }).disposition),
),
imageOffset: clampPlayerImageOffset(obj.imageOffset),
imageScale: clampPlayerImageScale((obj as { imageScale?: unknown }).imageScale),
}; };
}) })
.filter((x): x is ProjectNpc => Boolean(x)); .filter((x): x is ProjectNpc => Boolean(x));
@@ -2481,6 +2562,15 @@ function normalizeProject(p: Project): Project {
if (a && a.length > 0) return a; if (a && a.length > 0) return a;
return '0.0.0'; return '0.0.0';
})(); })();
const scenesPruned: Record<SceneId, Scene> = {};
for (const sid of Object.keys(scenes) as SceneId[]) {
const sc = scenes[sid];
if (!sc) continue;
scenesPruned[sid] = {
...sc,
npcTokens: (sc.npcTokens ?? []).filter((t) => npcIdSet.has(t.npcId)),
};
}
return { return {
...p, ...p,
meta: { meta: {
@@ -2491,7 +2581,7 @@ function normalizeProject(p: Project): Project {
createdWithAppVersion, createdWithAppVersion,
schemaVersion: PROJECT_SCHEMA_VERSION, schemaVersion: PROJECT_SCHEMA_VERSION,
}, },
scenes, scenes: scenesPruned,
campaignAudios, campaignAudios,
materials, materials,
npcs, npcs,
@@ -2501,7 +2591,7 @@ function normalizeProject(p: Project): Project {
sceneGraphEdges, sceneGraphEdges,
currentGraphNodeId, currentGraphNodeId,
sceneListOrder: reconcileSceneListOrder( sceneListOrder: reconcileSceneListOrder(
scenes, scenesPruned,
(p as { sceneListOrder?: SceneId[] }).sceneListOrder, (p as { sceneListOrder?: SceneId[] }).sceneListOrder,
), ),
}; };
+6 -2
View File
@@ -2,7 +2,8 @@ import fs from 'node:fs/promises';
import { session } from 'electron'; import { session } from 'electron';
import { asAssetId, asTokenId } from '../../shared/types/ids'; import { asAssetId, asPlayerId, asTokenId } from '../../shared/types/ids';
import type { PlayersStore } from '../players/playersStore';
import type { ZipProjectStore } from '../project/zipStore'; import type { ZipProjectStore } from '../project/zipStore';
import type { TokensStore } from '../tokens/tokensStore'; import type { TokensStore } from '../tokens/tokensStore';
@@ -75,11 +76,12 @@ async function serveFile(info: ReadInfo, request: Request): Promise<Response> {
} }
/** /**
* Обслуживает `dnd://asset?...` и `dnd://token?...`. * Обслуживает `dnd://asset?...`, `dnd://token?...` и `dnd://player?...`.
*/ */
export function registerDndAssetProtocol( export function registerDndAssetProtocol(
projectStore: ZipProjectStore, projectStore: ZipProjectStore,
tokensStore: TokensStore, tokensStore: TokensStore,
playersStore: PlayersStore,
): void { ): void {
session.defaultSession.protocol.handle('dnd', async (request) => { session.defaultSession.protocol.handle('dnd', async (request) => {
const url = new URL(request.url); const url = new URL(request.url);
@@ -92,6 +94,8 @@ export function registerDndAssetProtocol(
info = projectStore.getAssetReadInfo(asAssetId(id)); info = projectStore.getAssetReadInfo(asAssetId(id));
} else if (url.hostname === 'token') { } else if (url.hostname === 'token') {
info = tokensStore.getImageReadInfo(asTokenId(id)); info = tokensStore.getImageReadInfo(asTokenId(id));
} else if (url.hostname === 'player') {
info = playersStore.getImageReadInfo(asPlayerId(id));
} }
if (!info) { if (!info) {
return new Response(null, { status: 404 }); return new Response(null, { status: 404 });
+3 -8
View File
@@ -92,11 +92,7 @@ export class TokensStore {
return path.join(this.rootDir, relPath); return path.join(this.rootDir, relPath);
} }
async upsert(input: { async upsert(input: { id?: TokenId | null; name: string; filePath?: string | null }): Promise<AppToken> {
id?: TokenId | null;
name: string;
filePath?: string | null;
}): Promise<AppToken> {
await this.ensureLoaded(); await this.ensureLoaded();
const name = input.name.trim(); const name = input.name.trim();
if (!name) throw new Error('Token name is required'); if (!name) throw new Error('Token name is required');
@@ -166,8 +162,7 @@ export class TokensStore {
}): Promise<{ token: AppToken; remappedFrom: TokenId }> { }): Promise<{ token: AppToken; remappedFrom: TokenId }> {
await this.ensureLoaded(); await this.ensureLoaded();
let buf = await fs.readFile(input.absFilePath); let buf = await fs.readFile(input.absFilePath);
const sha256 = const sha256 = input.sha256 ?? crypto.createHash('sha256').update(buf).digest('hex');
input.sha256 ?? crypto.createHash('sha256').update(buf).digest('hex');
const existingByHash = this.findBySha256(sha256); const existingByHash = this.findBySha256(sha256);
if (existingByHash) { if (existingByHash) {
return { token: existingByHash, remappedFrom: input.preferredId }; return { token: existingByHash, remappedFrom: input.preferredId };
@@ -262,7 +257,7 @@ export class TokensStore {
preferredId: asTokenId(t.id), preferredId: asTokenId(t.id),
name: typeof t.name === 'string' ? t.name : 'Token', name: typeof t.name === 'string' ? t.name : 'Token',
absFilePath: abs, absFilePath: abs,
sha256: typeof t.sha256 === 'string' ? t.sha256 : undefined, ...(typeof t.sha256 === 'string' ? { sha256: t.sha256 } : {}),
}); });
remap.set(remappedFrom, token.id); remap.set(remappedFrom, token.id);
} }
@@ -303,9 +303,29 @@
.previewActions { .previewActions {
display: flex; display: flex;
align-items: center;
gap: 10px; gap: 10px;
} }
.npcTokenScale {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.npcTokenScaleLabel {
color: var(--text2);
font-size: var(--text-xs);
font-weight: 700;
white-space: nowrap;
}
.npcTokenScaleRange {
width: min(140px, 22vw);
accent-color: var(--color-accent, #c9a227);
}
.videoHint { .videoHint {
color: var(--text2); color: var(--text2);
font-size: var(--text-xs); font-size: var(--text-xs);
@@ -579,3 +599,43 @@
.audioScrubDefault { .audioScrubDefault {
cursor: default; cursor: default;
} }
.ctxMenuBackdrop {
position: fixed;
inset: 0;
z-index: 40;
border: none;
padding: 0;
margin: 0;
background: transparent;
cursor: default;
}
.ctxMenu {
position: fixed;
z-index: 41;
min-width: 200px;
padding: 6px;
border-radius: 8px;
border: 1px solid var(--stroke, #2a2f3a);
background: var(--color-surface-menu, #1a1e28);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
display: grid;
gap: 2px;
}
.ctxItem {
text-align: left;
padding: 8px 10px;
border-radius: 6px;
border: none;
background: transparent;
color: var(--text1, #e8eaef);
font-size: 13px;
cursor: pointer;
width: 100%;
}
.ctxItem:hover {
background: rgba(255, 255, 255, 0.06);
}
+201 -31
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { pickEraseTargetId } from '../../shared/effectEraserHitTest'; import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
import { fitAspectRect } from '../../shared/geometry/fitAspectRect'; import { fitAspectRect } from '../../shared/geometry/fitAspectRect';
@@ -9,21 +10,36 @@ import {
isNodeInSideStoryline, isNodeInSideStoryline,
listSideStoryStarts, listSideStoryStarts,
} from '../../shared/graph/sceneGraphLineage'; } from '../../shared/graph/sceneGraphLineage';
import type { GraphNodeId, MaterialId, Scene, SceneId, SceneViewCamera } from '../../shared/types'; import type {
GraphNodeId,
MaterialId,
NpcDisposition,
Scene,
SceneId,
SceneViewCamera,
} from '../../shared/types';
import { import {
DEFAULT_SCENE_VIEW_CAMERA, DEFAULT_NPC_TOKEN_SESSION_SCALE,
sceneViewPanBy, DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
sceneViewZoomAt, NPC_TOKEN_SESSION_SCALE_MAX,
} from '../../shared/types/sceneView'; NPC_TOKEN_SESSION_SCALE_MIN,
} from '../../shared/types/appPlayers';
import { otherNpcDispositions } from '../../shared/types/npcDisposition';
import { DEFAULT_SCENE_VIEW_CAMERA, sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml'; import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml';
import { getDndApi } from '../shared/dndApi'; import { getDndApi } from '../shared/dndApi';
import { RotatedImage } from '../shared/RotatedImage'; import { RotatedImage } from '../shared/RotatedImage';
import { ExplosionVideoOverlay } from '../shared/effects/ExplosionVideoOverlay';
import { import {
PixiEffectsOverlay, resolveNpcTokenDisposition,
type PixiEffectsOverlayHandle, SceneNpcTokensOverlay,
} from '../shared/effects/PxiEffectsOverlay'; } from '../shared/playerToken/SceneNpcTokensOverlay';
import { ScenePlayerTokensOverlay } from '../shared/playerToken/ScenePlayerTokensOverlay';
import { useAppPlayers } from '../shared/playerToken/useAppPlayers';
import { useSceneNpcTokensSession } from '../shared/playerToken/useSceneNpcTokensSession';
import { useScenePlayerTokensSession } from '../shared/playerToken/useScenePlayerTokensSession';
import { ExplosionVideoOverlay } from '../shared/effects/ExplosionVideoOverlay';
import { PixiEffectsOverlay, type PixiEffectsOverlayHandle } from '../shared/effects/PxiEffectsOverlay';
import type { EffectInstance, EffectToolType, ExplosionInstance } from '../../shared/types/effects'; import type { EffectInstance, EffectToolType, ExplosionInstance } from '../../shared/types/effects';
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay'; import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
import { useEffectsState } from '../shared/effects/useEffectsState'; import { useEffectsState } from '../shared/effects/useEffectsState';
@@ -62,6 +78,12 @@ import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx';
/** Длительность молнии: быстрый удар + акцент в точке попадания. */ /** Длительность молнии: быстрый удар + акцент в точке попадания. */
const LIGHTNING_EFFECT_MS = 840; const LIGHTNING_EFFECT_MS = 840;
function dispositionMakeKey(d: NpcDisposition): string {
if (d === 'hostile') return 'npcs.makeHostile';
if (d === 'friendly') return 'npcs.makeFriendly';
return 'npcs.makeNeutral';
}
function clampAudioGain(v: number): number { function clampAudioGain(v: number): number {
if (!Number.isFinite(v)) return 1; if (!Number.isFinite(v)) return 1;
return Math.max(0, Math.min(1, v)); return Math.max(0, Math.min(1, v));
@@ -72,12 +94,7 @@ function readAudioGain(gains: Map<string, number>, assetId: string): number {
} }
/** Применяет пользовательскую громкость; `factor` — для fade in/out (0…1). */ /** Применяет пользовательскую громкость; `factor` — для fade in/out (0…1). */
function applyAudioGain( function applyAudioGain(el: HTMLAudioElement, gains: Map<string, number>, assetId: string, factor = 1): void {
el: HTMLAudioElement,
gains: Map<string, number>,
assetId: string,
factor = 1,
): void {
el.volume = clampAudioGain(readAudioGain(gains, assetId) * factor); el.volume = clampAudioGain(readAudioGain(gains, assetId) * factor);
} }
@@ -134,6 +151,14 @@ export function ControlApp() {
const [sceneTraps, sceneTrapsApi] = useSceneTrapsState(); const [sceneTraps, sceneTrapsApi] = useSceneTrapsState();
const appTokens = useAppTokens(); const appTokens = useAppTokens();
const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession(); const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession();
const [sceneNpcTokensSession, sceneNpcTokensApi] = useSceneNpcTokensSession();
const [scenePlayerTokensSession, scenePlayerTokensApi] = useScenePlayerTokensSession();
const { players: appPlayers } = useAppPlayers();
const [npcSessionCtxMenu, setNpcSessionCtxMenu] = 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();
@@ -722,10 +747,9 @@ export function ControlApp() {
return () => ro.disconnect(); return () => ro.disconnect();
}, []); }, []);
const activeSceneViewCamera: SceneViewCamera = sceneViewDraft ?? const activeSceneViewCamera: SceneViewCamera =
(sceneView sceneViewDraft ??
? { scale: sceneView.scale, ox: sceneView.ox, oy: sceneView.oy } (sceneView ? { scale: sceneView.scale, ox: sceneView.ox, oy: sceneView.oy } : DEFAULT_SCENE_VIEW_CAMERA);
: DEFAULT_SCENE_VIEW_CAMERA);
sceneViewRef.current = activeSceneViewCamera; sceneViewRef.current = activeSceneViewCamera;
useEffect(() => { useEffect(() => {
@@ -1432,7 +1456,9 @@ export function ControlApp() {
title={hasSceneDescription ? t('control.descriptionTool') : t('control.descriptionMissing')} title={hasSceneDescription ? t('control.descriptionTool') : t('control.descriptionMissing')}
ariaLabel={hasSceneDescription ? t('control.descriptionTool') : t('control.descriptionMissing')} ariaLabel={hasSceneDescription ? t('control.descriptionTool') : t('control.descriptionMissing')}
onClick={() => { onClick={() => {
void api.invoke(ipcChannels.windows.openSceneDescription, { html: sceneDescription }).catch((err) => { void api
.invoke(ipcChannels.windows.openSceneDescription, { html: sceneDescription })
.catch((err) => {
console.error('[control] openSceneDescription failed', err); console.error('[control] openSceneDescription failed', err);
}); });
}} }}
@@ -1752,6 +1778,45 @@ export function ControlApp() {
<div className={styles.previewHeader}> <div className={styles.previewHeader}>
<div className={styles.previewTitle}>{t('control.screenPreview')}</div> <div className={styles.previewTitle}>{t('control.screenPreview')}</div>
<div className={styles.previewActions}> <div className={styles.previewActions}>
<label className={styles.npcTokenScale}>
<span className={styles.npcTokenScaleLabel}>{t('control.npcTokenScale')}</span>
<input
type="range"
className={styles.npcTokenScaleRange}
min={NPC_TOKEN_SESSION_SCALE_MIN}
max={NPC_TOKEN_SESSION_SCALE_MAX}
step={0.05}
value={sceneNpcTokensSession.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE}
aria-label={t('control.npcTokenScale')}
onChange={(e) => {
sceneNpcTokensApi.dispatch({
kind: 'setScale',
scale: Number(e.currentTarget.value),
});
}}
/>
</label>
{scenePlayerTokensSession.selectedPlayerIds.length > 0 ? (
<Button
data-testid="toggle-session-players"
onClick={() => {
if (scenePlayerTokensSession.visible) {
scenePlayerTokensApi.dispatch({ kind: 'setVisible', visible: false });
return;
}
const grid = currentScene?.grid;
const sizeN =
grid?.enabled && Number.isFinite(grid.sizeN)
? grid.sizeN
: DEFAULT_SCENE_NPC_TOKEN_SIZE_N;
scenePlayerTokensApi.dispatch({ kind: 'show', sizeN });
}}
>
{scenePlayerTokensSession.visible
? t('control.hidePlayers')
: t('control.showPlayers')}
</Button>
) : null}
<Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}> <Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}>
{t('control.stopPresentation')} {t('control.stopPresentation')}
</Button> </Button>
@@ -1764,9 +1829,7 @@ export function ControlApp() {
ref={previewFrameRef} ref={previewFrameRef}
className={styles.previewFrame} className={styles.previewFrame}
title={ title={
isVideoPreviewScene isVideoPreviewScene ? undefined : 'Колесо — зум; перетаскивание СКМ/ПКМ или Space+ЛКМ — пан'
? undefined
: 'Колесо — зум; перетаскивание СКМ/ПКМ или Space+ЛКМ — пан'
} }
> >
<div ref={previewHostRef} className={styles.previewHost}> <div ref={previewHostRef} className={styles.previewHost}>
@@ -1882,9 +1945,7 @@ export function ControlApp() {
const dy = e.clientY - pan.lastY; const dy = e.clientY - pan.lastY;
pan.lastX = e.clientX; pan.lastX = e.clientX;
pan.lastY = e.clientY; pan.lastY = e.clientY;
publishSceneViewCamera( publishSceneViewCamera(sceneViewPanBy(cam, { containW, containH, dx, dy }));
sceneViewPanBy(cam, { containW, containH, dx, dy }),
);
return; return;
} }
const p = toNPoint(e); const p = toNPoint(e);
@@ -1965,6 +2026,41 @@ export function ControlApp() {
}} }}
/> />
) : null} ) : null}
{previewContentRect ? (
<SceneNpcTokensOverlay
placements={currentScene?.npcTokens ?? []}
library={session?.project?.npcs ?? []}
session={sceneNpcTokensSession}
viewport={previewContentRect}
grid={currentScene?.grid ?? null}
editable
onMove={(placementId, nx, ny) => {
sceneNpcTokensApi.dispatch({ kind: 'move', placementId, nx, ny });
}}
onContextMenu={(e, placement) => {
setNpcSessionCtxMenu({
x: e.clientX,
y: e.clientY,
placementId: String(placement.id),
});
}}
/>
) : null}
{previewContentRect ? (
<ScenePlayerTokensOverlay
library={appPlayers}
session={scenePlayerTokensSession}
displayScale={
sceneNpcTokensSession.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE
}
viewport={previewContentRect}
grid={currentScene?.grid ?? null}
editable
onMove={(playerId, nx, ny) => {
scenePlayerTokensApi.dispatch({ kind: 'move', playerId, nx, ny });
}}
/>
) : null}
{previewContentRect ? ( {previewContentRect ? (
<SceneTrapsOverlay <SceneTrapsOverlay
traps={currentScene?.traps ?? []} traps={currentScene?.traps ?? []}
@@ -2286,8 +2382,7 @@ export function ControlApp() {
} }
void el.play().catch(() => { void el.play().catch(() => {
const mm = const mm =
sceneAudioMetaRef.current.get(ref.assetId) ?? sceneAudioMetaRef.current.get(ref.assetId) ?? ({ lastPlayError: null } as const);
({ lastPlayError: null } as const);
sceneAudioMetaRef.current.set(ref.assetId, { sceneAudioMetaRef.current.set(ref.assetId, {
...mm, ...mm,
lastPlayError: t('control.playFailed'), lastPlayError: t('control.playFailed'),
@@ -2333,7 +2428,9 @@ export function ControlApp() {
{...(!allowCampaignAudio {...(!allowCampaignAudio
? { ? {
extraBadge: ( extraBadge: (
<div title={t('control.pauseSceneMusicTitle')}>{t('control.pauseSceneMusic')}</div> <div title={t('control.pauseSceneMusicTitle')}>
{t('control.pauseSceneMusic')}
</div>
), ),
} }
: {})} : {})}
@@ -2367,8 +2464,7 @@ export function ControlApp() {
} }
void el.play().catch(() => { void el.play().catch(() => {
const mm = const mm =
campaignAudioMetaRef.current.get(ref.assetId) ?? campaignAudioMetaRef.current.get(ref.assetId) ?? ({ lastPlayError: null } as const);
({ lastPlayError: null } as const);
campaignAudioMetaRef.current.set(ref.assetId, { campaignAudioMetaRef.current.set(ref.assetId, {
...mm, ...mm,
lastPlayError: t('control.playFailed'), lastPlayError: t('control.playFailed'),
@@ -2412,6 +2508,80 @@ export function ControlApp() {
</Surface> </Surface>
) : null} ) : null}
</div> </div>
{npcSessionCtxMenu
? createPortal(
<>
<button
type="button"
className={styles.ctxMenuBackdrop}
aria-label={t('common.close')}
onClick={() => setNpcSessionCtxMenu(null)}
/>
<div
className={styles.ctxMenu}
style={{ left: npcSessionCtxMenu.x, top: npcSessionCtxMenu.y }}
role="menu"
>
{(() => {
const placement = (currentScene?.npcTokens ?? []).find(
(item) => String(item.id) === npcSessionCtxMenu.placementId,
);
const npc = placement
? (session?.project?.npcs ?? []).find((item) => item.id === placement.npcId)
: undefined;
if (!placement || !npc) return null;
const override =
sceneNpcTokensSession.byPlacementId[npcSessionCtxMenu.placementId];
const current = resolveNpcTokenDisposition(
placement,
npc,
override?.disposition,
);
const inactive = Boolean(override?.inactive);
return (
<>
{otherNpcDispositions(current).map((d) => (
<button
key={d}
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
sceneNpcTokensApi.dispatch({
kind: 'setDisposition',
placementId: npcSessionCtxMenu.placementId,
disposition: d,
});
setNpcSessionCtxMenu(null);
}}
>
{t(dispositionMakeKey(d))}
</button>
))}
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
sceneNpcTokensApi.dispatch({
kind: 'setInactive',
placementId: npcSessionCtxMenu.placementId,
inactive: !inactive,
});
setNpcSessionCtxMenu(null);
}}
>
{inactive ? t('npcs.makeActive') : t('npcs.inactive')}
</button>
</>
);
})()}
</div>
</>,
document.body,
)
: null}
</div> </div>
); );
} }
+62
View File
@@ -59,6 +59,68 @@
gap: 10px; gap: 10px;
} }
.splitRun {
display: inline-flex;
align-items: stretch;
height: 34px;
border-radius: var(--radius-sm);
border: 1px solid var(--accent-border);
background: var(--accent-fill-solid);
overflow: hidden;
}
.splitRun:disabled,
.splitRun[aria-disabled='true'] {
opacity: 0.45;
pointer-events: none;
}
.splitRunMain,
.splitRunChevron {
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.95);
cursor: pointer;
font: inherit;
font-weight: 600;
}
.splitRunMain {
padding: 0 14px;
}
.splitRunMain:hover:not(:disabled),
.splitRunChevron:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent-fill-solid) 72%, white);
}
.splitRunMain:active:not(:disabled),
.splitRunChevron:active:not(:disabled),
.splitRunChevronOpen {
background: color-mix(in srgb, var(--accent-fill-solid) 78%, black);
}
.splitRunDivider {
width: 1px;
align-self: stretch;
background: rgba(0, 0, 0, 0.28);
flex: 0 0 auto;
}
.splitRunChevron {
width: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.splitRunChevronSvg {
width: 12px;
height: 12px;
display: block;
}
.editorSidebar { .editorSidebar {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
+135 -45
View File
@@ -55,6 +55,8 @@ import type { HelpSectionId } from './help/helpSections';
import { useEditorI18n } from './i18n/EditorI18nContext'; import { useEditorI18n } from './i18n/EditorI18nContext';
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals'; import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals'; import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
import { PlayersManagerModal } from './PlayersModals';
import { LaunchPlayersModal } from './LaunchPlayersModal';
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml'; import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
import { SceneDescriptionModal } from './SceneDescriptionModal'; import { SceneDescriptionModal } from './SceneDescriptionModal';
import type { ProjectNoticeCode } from './state/projectState'; import type { ProjectNoticeCode } from './state/projectState';
@@ -134,9 +136,9 @@ export function EditorApp() {
const [importConflictsOpen, setImportConflictsOpen] = useState(false); const [importConflictsOpen, setImportConflictsOpen] = useState(false);
const [importConflicts, setImportConflicts] = useState<ReturnType<typeof computeImportConflicts>>([]); const [importConflicts, setImportConflicts] = useState<ReturnType<typeof computeImportConflicts>>([]);
const [importNpcConflictsOpen, setImportNpcConflictsOpen] = useState(false); const [importNpcConflictsOpen, setImportNpcConflictsOpen] = useState(false);
const [importNpcConflicts, setImportNpcConflicts] = useState< const [importNpcConflicts, setImportNpcConflicts] = useState<ReturnType<typeof computeNpcImportConflicts>>(
ReturnType<typeof computeNpcImportConflicts> [],
>([]); );
const [pendingImportSelections, setPendingImportSelections] = useState<StorylineSelection[]>([]); const [pendingImportSelections, setPendingImportSelections] = useState<StorylineSelection[]>([]);
const [pendingSceneResolutions, setPendingSceneResolutions] = useState<SceneImportResolution[]>([]); const [pendingSceneResolutions, setPendingSceneResolutions] = useState<SceneImportResolution[]>([]);
const [importReportOpen, setImportReportOpen] = useState(false); const [importReportOpen, setImportReportOpen] = useState(false);
@@ -154,6 +156,11 @@ export function EditorApp() {
const licenseActive = licenseSnap?.active === true; const licenseActive = licenseSnap?.active === true;
const [appNotice, setAppNotice] = useState<{ title?: string; message: string } | null>(null); const [appNotice, setAppNotice] = useState<{ title?: string; message: string } | null>(null);
const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false); const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
const [playersManagerOpen, setPlayersManagerOpen] = useState(false);
const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false);
const [runMenuOpen, setRunMenuOpen] = useState(false);
const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | null>(null);
const runSplitRef = useRef<HTMLDivElement | null>(null);
const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(null); const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(null);
const onProjectNotice = useCallback( const onProjectNotice = useCallback(
(code: ProjectNoticeCode) => { (code: ProjectNoticeCode) => {
@@ -351,13 +358,17 @@ export function EditorApp() {
const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene'); const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene');
const launchFromGraphNode = useCallback( const launchFromGraphNode = useCallback(
(graphNodeId: GraphNodeId) => { (graphNodeId: GraphNodeId, playerIds?: string[]) => {
if (!licenseActive || launching) return; if (!licenseActive || launching) return;
setLaunching(true); setLaunching(true);
setRunMenuOpen(false);
setLaunchPlayersOpen(false);
void (async () => { void (async () => {
try { try {
await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId }); await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId });
await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {}); await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {
...(playerIds && playerIds.length > 0 ? { playerIds } : {}),
});
} catch { } catch {
setLaunching(false); setLaunching(false);
} }
@@ -461,6 +472,23 @@ export function EditorApp() {
return () => window.removeEventListener('mousedown', onDown); return () => window.removeEventListener('mousedown', onDown);
}, [aboutMenuOpen]); }, [aboutMenuOpen]);
useEffect(() => {
if (!runMenuOpen) return;
const r = runSplitRef.current?.getBoundingClientRect() ?? null;
queueMicrotask(() => {
if (r) setRunMenuPos({ left: r.right, top: r.bottom + 8 });
else setRunMenuPos(null);
});
const onDown = (e: MouseEvent) => {
const t = e.target as HTMLElement | null;
if (!t) return;
if (t.closest('[data-runmenu-root="1"]')) return;
setRunMenuOpen(false);
};
window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown);
}, [runMenuOpen]);
useEffect(() => { useEffect(() => {
let off: (() => void) | null = null; let off: (() => void) | null = null;
void (async () => { void (async () => {
@@ -546,12 +574,7 @@ export function EditorApp() {
sceneResolutions, sceneResolutions,
npcResolutions, npcResolutions,
) )
: await actions.mergeImportZip( : await actions.mergeImportZip(importPeek.filePath!, selections, sceneResolutions, npcResolutions);
importPeek.filePath!,
selections,
sceneResolutions,
npcResolutions,
);
setImportReport(report); setImportReport(report);
setImportReportOpen(true); setImportReportOpen(true);
clearImportFlow(); clearImportFlow();
@@ -571,12 +594,7 @@ export function EditorApp() {
setImportNpcConflictsOpen(true); setImportNpcConflictsOpen(true);
return; return;
} }
const npcResolutions = buildNpcResolutionsForImport( const npcResolutions = buildNpcResolutionsForImport(state.project, importPeek.sourceProject, [], []);
state.project,
importPeek.sourceProject,
[],
[],
);
void runStorylineMerge(selections, sceneResolutions, npcResolutions); void runStorylineMerge(selections, sceneResolutions, npcResolutions);
}, },
[importPeek, runStorylineMerge, state.project], [importPeek, runStorylineMerge, state.project],
@@ -724,9 +742,7 @@ export function EditorApp() {
{t('scenes.batchProgress') {t('scenes.batchProgress')
.replace('{current}', String(state.sceneBatchImport.current)) .replace('{current}', String(state.sceneBatchImport.current))
.replace('{total}', String(state.sceneBatchImport.total))} .replace('{total}', String(state.sceneBatchImport.total))}
{state.sceneBatchImport.fileName {state.sceneBatchImport.fileName ? `: ${state.sceneBatchImport.fileName}` : ''}
? `: ${state.sceneBatchImport.fileName}`
: ''}
</div> </div>
<div> <div>
{Math.round( {Math.round(
@@ -825,6 +841,23 @@ export function EditorApp() {
{t('top.file')} {t('top.file')}
</button> </button>
) : null} ) : null}
<button
type="button"
data-testid="players-header-btn"
className={styles.fileMenuTrigger}
disabled={!licenseActive}
title={!licenseActive ? t('top.afterLicense') : undefined}
onClick={() => {
if (!licenseActive) return;
setProjectMenuOpen(false);
setSettingsMenuOpen(false);
setAboutMenuOpen(false);
setFileMenuOpen(false);
setPlayersManagerOpen(true);
}}
>
{t('top.players')}
</button>
</div> </div>
<div className={styles.flex1} /> <div className={styles.flex1} />
{appVersionText ? ( {appVersionText ? (
@@ -835,16 +868,55 @@ export function EditorApp() {
<div className={styles.headerActions}> <div className={styles.headerActions}>
{state.project ? ( {state.project ? (
<> <>
<Button <div
variant="primary" ref={runSplitRef}
className={styles.splitRun}
data-runmenu-root="1"
aria-disabled={runDisabled || launching ? true : undefined}
>
<button
type="button"
className={styles.splitRunMain}
disabled={runDisabled || launching} disabled={runDisabled || launching}
data-testid="run-main-btn"
onClick={() => { onClick={() => {
if (!licenseActive || !graphStartGraphNodeId || launching) return; if (!licenseActive || !graphStartGraphNodeId || launching) return;
launchFromGraphNode(graphStartGraphNodeId); launchFromGraphNode(graphStartGraphNodeId);
}} }}
> >
{t('top.run')} {t('top.run')}
</Button> </button>
<span className={styles.splitRunDivider} aria-hidden />
<button
type="button"
className={[
styles.splitRunChevron,
runMenuOpen ? styles.splitRunChevronOpen : '',
]
.filter(Boolean)
.join(' ')}
disabled={runDisabled || launching}
aria-label={t('top.runMenuAria')}
aria-haspopup="menu"
aria-expanded={runMenuOpen}
data-testid="run-menu-btn"
onClick={() => {
if (runDisabled || launching) return;
setRunMenuOpen((v) => !v);
}}
>
<svg className={styles.splitRunChevronSvg} viewBox="0 0 12 12" aria-hidden>
<path
d="M2.5 4.25 L6 7.75 L9.5 4.25"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
{runDisabled ? ( {runDisabled ? (
<Button <Button
variant="ghost" variant="ghost"
@@ -900,9 +972,7 @@ export function EditorApp() {
scene={s} scene={s}
reorderEnabled={sceneListReorderEnabled} reorderEnabled={sceneListReorderEnabled}
listDragActive={draggingListSceneId !== null} listDragActive={draggingListSceneId !== null}
dropPlace={ dropPlace={sceneListDrop?.targetId === s.id ? sceneListDrop.place : null}
sceneListDrop?.targetId === s.id ? sceneListDrop.place : null
}
isDragging={draggingListSceneId === s.id} isDragging={draggingListSceneId === s.id}
onSelect={() => { onSelect={() => {
setSelectedGraphNodeId(null); setSelectedGraphNodeId(null);
@@ -925,9 +995,7 @@ export function EditorApp() {
return; return;
} }
setSceneListDrop((cur) => setSceneListDrop((cur) =>
cur?.targetId === targetId && cur.place === place cur?.targetId === targetId && cur.place === place ? cur : { targetId, place },
? cur
: { targetId, place },
); );
}} }}
onDropListReorder={(draggedId, targetId, place) => { onDropListReorder={(draggedId, targetId, place) => {
@@ -1552,6 +1620,39 @@ export function EditorApp() {
}} }}
/> />
<CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} /> <CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} />
<PlayersManagerModal open={playersManagerOpen} onClose={() => setPlayersManagerOpen(false)} />
<LaunchPlayersModal
open={launchPlayersOpen}
onClose={() => setLaunchPlayersOpen(false)}
onConfirm={(playerIds) => {
if (!graphStartGraphNodeId) return;
launchFromGraphNode(graphStartGraphNodeId, playerIds);
}}
/>
{runMenuOpen && runMenuPos
? createPortal(
<div
data-runmenu-root="1"
className={styles.fileMenu}
style={{ left: runMenuPos.left, top: runMenuPos.top, transform: 'translateX(-100%)' }}
role="menu"
>
<button
type="button"
className={styles.fileMenuItem}
role="menuitem"
data-testid="run-with-players-item"
onClick={() => {
setRunMenuOpen(false);
setLaunchPlayersOpen(true);
}}
>
{t('top.runWithPlayers')}
</button>
</div>,
document.body,
)
: null}
<MaterialsManagerModal <MaterialsManagerModal
open={materialsManagerOpen} open={materialsManagerOpen}
materials={state.project?.materials ?? []} materials={state.project?.materials ?? []}
@@ -2379,9 +2480,7 @@ function CampaignInspector({
onDragOver={audioDrop.onDragOver} onDragOver={audioDrop.onDragOver}
onDrop={audioDrop.onDrop} onDrop={audioDrop.onDrop}
> >
{audioDrop.dragOver ? ( {audioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
<div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div>
) : null}
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? ( {mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
<div className={styles.audioDropEmpty}> <div className={styles.audioDropEmpty}>
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div> <div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
@@ -2548,10 +2647,7 @@ function SceneInspector({
{sideStoryStartNodes.map((gn) => ( {sideStoryStartNodes.map((gn) => (
<div key={gn.id}> <div key={gn.id}>
<div className={styles.labelSm}>{t('scene.sideStoryLineTitle')}</div> <div className={styles.labelSm}>{t('scene.sideStoryLineTitle')}</div>
<Input <Input value={gn.sideStoryLineTitle} onChange={(v) => onSideStoryLineTitleChange(gn.id, v)} />
value={gn.sideStoryLineTitle}
onChange={(v) => onSideStoryLineTitleChange(gn.id, v)}
/>
<div className={styles.spacer8} /> <div className={styles.spacer8} />
</div> </div>
))} ))}
@@ -2567,9 +2663,7 @@ function SceneInspector({
onDragOver={previewDrop.onDragOver} onDragOver={previewDrop.onDragOver}
onDrop={previewDrop.onDrop} onDrop={previewDrop.onDrop}
> >
{previewDrop.dragOver ? ( {previewDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintPreview')}</div> : null}
<div className={styles.dropHintOverlay}>{t('drop.hintPreview')}</div>
) : null}
{previewUrl && previewAssetType === 'image' ? ( {previewUrl && previewAssetType === 'image' ? (
<div className={styles.previewFill}> <div className={styles.previewFill}>
<RotatedImage <RotatedImage
@@ -2668,9 +2762,7 @@ function SceneInspector({
onDragOver={sceneAudioDrop.onDragOver} onDragOver={sceneAudioDrop.onDragOver}
onDrop={sceneAudioDrop.onDrop} onDrop={sceneAudioDrop.onDrop}
> >
{sceneAudioDrop.dragOver ? ( {sceneAudioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
<div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div>
) : null}
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? ( {mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
<div className={styles.audioDropEmpty}> <div className={styles.audioDropEmpty}>
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div> <div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
@@ -2983,9 +3075,7 @@ function SceneListCard({
</button> </button>
</div> </div>
<div className={styles.fieldGrid}> <div className={styles.fieldGrid}>
<div className={styles.muted}> <div className={styles.muted}>{t('confirmDeleteScene.body', { name: scene.title })}</div>
{t('confirmDeleteScene.body', { name: scene.title })}
</div>
</div> </div>
<div className={styles.modalFooter}> <div className={styles.modalFooter}>
<Button onClick={() => setPendingDelete(false)}>{t('common.cancel')}</Button> <Button onClick={() => setPendingDelete(false)}>{t('common.cancel')}</Button>
@@ -0,0 +1,63 @@
.dialog {
width: min(440px, calc(100vw - 48px));
}
.hint {
margin: 0 0 12px;
color: var(--text2);
font-size: var(--text-sm);
line-height: 1.4;
}
.section {
display: grid;
gap: 8px;
margin-bottom: 14px;
}
.sectionTitle {
font-size: 11px;
font-weight: 700;
color: var(--text2);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.list {
display: grid;
gap: 4px;
max-height: 220px;
overflow: auto;
padding: 8px;
border: 1px solid var(--stroke);
border-radius: var(--radius-sm);
background: var(--color-overlay-dark-3);
}
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
color: var(--text);
font-size: var(--text-sm);
}
.row:hover {
background: var(--accent-fill-soft);
}
.teamDot {
width: 10px;
height: 10px;
border-radius: 50%;
flex: 0 0 auto;
}
.empty {
padding: 10px 8px;
color: var(--text2);
font-size: var(--text-sm);
}
+165
View File
@@ -0,0 +1,165 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import type { AppPlayer, AppPlayerTeam, PlayerId, PlayerTeamId } from '../../shared/types';
import { resolveLaunchPlayerIds } from '../../shared/players/resolveLaunchPlayerIds';
import { useAppPlayers } from '../shared/playerToken/useAppPlayers';
import { Button } from '../shared/ui/controls';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
import launchStyles from './LaunchPlayersModal.module.css';
export { resolveLaunchPlayerIds } from '../../shared/players/resolveLaunchPlayerIds';
export function LaunchPlayersModal({
open,
onClose,
onConfirm,
}: {
open: boolean;
onClose: () => void;
onConfirm: (playerIds: string[]) => void;
}) {
const { t } = useEditorI18n();
const { players, teams } = useAppPlayers();
const [selectedPlayers, setSelectedPlayers] = useState<Set<string>>(() => new Set());
const [selectedTeams, setSelectedTeams] = useState<Set<string>>(() => new Set());
useEffect(() => {
if (!open) return;
setSelectedPlayers(new Set());
setSelectedTeams(new Set());
}, [open]);
const resolvedIds = useMemo(
() => resolveLaunchPlayerIds(players, selectedPlayers, selectedTeams),
[players, selectedPlayers, selectedTeams],
);
const standalonePlayers = useMemo(
() =>
players.filter((p) => !(p.teamId && selectedTeams.has(String(p.teamId)))),
[players, selectedTeams],
);
if (!open) return null;
const togglePlayer = (id: PlayerId) => {
setSelectedPlayers((prev) => {
const next = new Set(prev);
const key = String(id);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const toggleTeam = (id: PlayerTeamId) => {
const key = String(id);
setSelectedTeams((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
// Участники выбранной команды не показываются в «Игроки» — снимаем их индивидуальный выбор.
setSelectedPlayers((prev) => {
if (prev.size === 0) return prev;
const next = new Set(prev);
let changed = false;
for (const p of players) {
if (String(p.teamId) !== key) continue;
if (next.delete(String(p.id))) changed = true;
}
return changed ? next : prev;
});
};
return createPortal(
<>
<div className={styles.modalBackdrop} aria-hidden onClick={onClose} />
<div
className={[styles.modalDialog, launchStyles.dialog].join(' ')}
role="dialog"
aria-modal="true"
data-testid="launch-players-modal"
>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('top.runWithPlayersTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div>
<p className={launchStyles.hint}>{t('top.runWithPlayersHint')}</p>
{teams.length > 0 ? (
<section className={launchStyles.section}>
<div className={launchStyles.sectionTitle}>{t('top.runWithPlayersTeams')}</div>
<div className={launchStyles.list}>
{teams.map((team: AppPlayerTeam) => {
const count = players.filter((p) => p.teamId === team.id).length;
return (
<label key={team.id} className={launchStyles.row}>
<input
type="checkbox"
checked={selectedTeams.has(String(team.id))}
onChange={() => toggleTeam(team.id)}
data-testid={`launch-team-${team.id}`}
/>
<span className={launchStyles.teamDot} style={{ background: team.color }} />
<span>
{team.name} ({count})
</span>
</label>
);
})}
</div>
</section>
) : null}
{standalonePlayers.length > 0 ? (
<section className={launchStyles.section}>
<div className={launchStyles.sectionTitle}>{t('top.runWithPlayersPlayers')}</div>
<div className={launchStyles.list}>
{standalonePlayers.map((player: AppPlayer) => (
<label key={player.id} className={launchStyles.row}>
<input
type="checkbox"
checked={selectedPlayers.has(String(player.id))}
onChange={() => togglePlayer(player.id)}
data-testid={`launch-player-${player.id}`}
/>
<span>{player.name}</span>
</label>
))}
</div>
</section>
) : players.length === 0 ? (
<section className={launchStyles.section}>
<div className={launchStyles.empty}>{t('top.runWithPlayersEmpty')}</div>
</section>
) : null}
</div>
<div className={styles.modalFooter}>
<Button variant="ghost" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={resolvedIds.length === 0}
data-testid="launch-players-confirm"
onClick={() => onConfirm(resolvedIds)}
>
{t('top.runWithPlayersConfirm')}
</Button>
</div>
</div>
</>,
document.body,
);
}
@@ -0,0 +1,243 @@
.dialog {
width: min(1080px, calc(100vw - 48px));
max-width: 1080px;
}
.body {
display: grid;
grid-template-columns: 330px minmax(0, 1fr);
gap: 16px;
min-height: 560px;
max-height: min(78vh, 760px);
}
.sidebar,
.editor {
min-height: 0;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 8px;
}
.sidebarActions {
display: flex;
gap: 8px;
}
.sidebarActions > * {
flex: 1 1 0;
min-width: 0;
}
.sidebarActions button {
width: 100%;
}
.teamForm {
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid var(--stroke);
border-radius: 10px;
background: var(--color-overlay-dark-3);
}
.teamFormField {
display: grid;
gap: 4px;
color: var(--text2);
font-size: 11px;
font-weight: 700;
}
.teamFormField input[type='color'] {
width: 100%;
height: 34px;
padding: 0;
border: 1px solid var(--stroke);
border-radius: 8px;
background: transparent;
}
.teamFormActions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.teamFormActions > * {
flex: 1 1 0;
min-width: 0;
}
.teamFormActions button {
width: 100%;
}
.list {
display: grid;
gap: 10px;
overflow: auto;
padding-right: 3px;
}
.team {
border: 1px solid var(--stroke);
border-radius: 10px;
background: var(--color-overlay-dark-2);
overflow: visible;
}
.teamHeader {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 7px;
min-height: 34px;
padding: 4px 8px;
border-bottom: 1px solid var(--stroke);
}
.teamDot {
width: 11px;
height: 11px;
border-radius: 50%;
}
.teamName {
overflow: hidden;
font-size: 12px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.teamMenu {
position: relative;
}
.teamMenu summary {
cursor: pointer;
list-style: none;
padding: 4px 7px;
}
.teamMenuPopup {
position: absolute;
z-index: 10;
top: 100%;
right: 0;
min-width: 130px;
padding: 5px;
border: 1px solid var(--stroke);
border-radius: 8px;
background: var(--bg1, #1a1d24);
box-shadow: 0 10px 24px rgb(0 0 0 / 45%);
}
.teamMenuPopup button {
width: 100%;
padding: 7px 9px;
border: 0;
border-radius: 5px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.teamMenuPopup button:hover {
background: rgb(255 255 255 / 8%);
}
.teamBody {
display: grid;
gap: 5px;
min-height: 28px;
padding: 6px;
}
.playerRow {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
align-items: center;
gap: 9px;
width: 100%;
padding: 5px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: inherit;
text-align: left;
cursor: grab;
}
.playerRow:hover,
.playerRowSelected {
border-color: var(--color-accent, #c9a227);
background: rgb(255 255 255 / 5%);
}
.playerRow > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropTarget {
padding: 8px;
color: var(--text2);
font-size: 11px;
text-align: center;
}
.editor {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
overflow: auto;
padding: 22px;
border: 1px solid var(--stroke);
border-radius: 12px;
background: var(--color-overlay-dark-3);
}
.editorDropOver {
border-color: var(--color-accent, #c9a227);
}
.field {
display: grid;
gap: 6px;
width: min(420px, 100%);
color: var(--text2);
font-size: 12px;
font-weight: 700;
}
.field input[type='color'] {
width: 100%;
height: 38px;
padding: 0;
border: 1px solid var(--stroke);
border-radius: 8px;
background: transparent;
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
@media (max-width: 760px) {
.body {
grid-template-columns: 1fr;
}
}
+665
View File
@@ -0,0 +1,665 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal, flushSync } from 'react-dom';
import { ipcChannels } from '../../shared/ipc/contracts';
import type { AppPlayer, AppPlayerTeam, PlayerId, PlayerTeamId } from '../../shared/types';
import {
DEFAULT_PLAYER_IMAGE_OFFSET,
DEFAULT_PLAYER_IMAGE_SCALE,
DEFAULT_PLAYER_RING_COLOR,
} from '../../shared/types/appPlayers';
import { getDndApi } from '../shared/dndApi';
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
import { useAppPlayers } from '../shared/playerToken/useAppPlayers';
import { usePlayerImageUrl } from '../shared/playerToken/usePlayerImageUrl';
import { Button, Input } from '../shared/ui/controls';
import styles from './EditorApp.module.css';
import {
filterMaterialImagePaths,
getDroppedFileEntries,
pickFirstMaterialImagePath,
useFileDropZone,
} from './fileDrop';
import { useEditorI18n } from './i18n/EditorI18nContext';
import playerStyles from './PlayersModals.module.css';
const PLAYER_DND_MIME = 'application/x-dnd-player-id';
function PlayerRow({
player,
selected,
onSelect,
}: {
player: AppPlayer;
selected: boolean;
onSelect: () => void;
}) {
const url = usePlayerImageUrl(player.id);
return (
<button
type="button"
data-testid={`player-row-${player.id}`}
className={[playerStyles.playerRow, selected ? playerStyles.playerRowSelected : '']
.filter(Boolean)
.join(' ')}
draggable
onDragStart={(e) => {
e.dataTransfer.setData(PLAYER_DND_MIME, player.id);
e.dataTransfer.effectAllowed = 'move';
}}
onClick={onSelect}
>
<PlayerTokenView
name={player.name}
imageUrl={url}
ringColor={player.ringColor}
imageOffset={player.imageOffset}
imageScale={player.imageScale}
sizePx={48}
hideName
/>
<span>{player.name}</span>
</button>
);
}
function TeamSection({
team,
players,
selectedId,
onSelect,
onAssign,
onEdit,
onDelete,
}: {
team: AppPlayerTeam | null;
players: AppPlayer[];
selectedId: PlayerId | null;
onSelect: (id: PlayerId) => void;
onAssign: (id: PlayerId, teamId: PlayerTeamId | null) => void;
onEdit?: () => void;
onDelete?: () => void;
}) {
const { t } = useEditorI18n();
return (
<section
className={playerStyles.team}
onDragOver={(e) => {
if (e.dataTransfer.types.includes(PLAYER_DND_MIME)) e.preventDefault();
}}
onDrop={(e) => {
e.preventDefault();
const id = e.dataTransfer.getData(PLAYER_DND_MIME);
if (id) onAssign(id as PlayerId, team?.id ?? null);
}}
>
<div className={playerStyles.teamHeader}>
{team ? <span className={playerStyles.teamDot} style={{ background: team.color }} /> : null}
<span className={playerStyles.teamName}>{team?.name ?? t('players.ungrouped')}</span>
{team ? (
<details className={playerStyles.teamMenu}>
<summary aria-label={t('players.teamMenu')}></summary>
<div className={playerStyles.teamMenuPopup}>
<button type="button" onClick={onEdit}>
{t('common.edit')}
</button>
<button type="button" onClick={onDelete}>
{t('common.delete')}
</button>
</div>
</details>
) : null}
</div>
<div className={playerStyles.teamBody}>
{players.map((player) => (
<PlayerRow
key={player.id}
player={player}
selected={player.id === selectedId}
onSelect={() => onSelect(player.id)}
/>
))}
{players.length === 0 ? <div className={playerStyles.dropTarget}>{t('players.dropHere')}</div> : null}
</div>
</section>
);
}
export function PlayersManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useEditorI18n();
const api = getDndApi();
const { players, teams } = useAppPlayers();
const [query, setQuery] = useState('');
const [selectedId, setSelectedId] = useState<PlayerId | null>(null);
const [name, setName] = useState('');
const [ringColor, setRingColor] = useState(DEFAULT_PLAYER_RING_COLOR);
const [imageOffset, setImageOffset] = useState(DEFAULT_PLAYER_IMAGE_OFFSET);
const [imageScale, setImageScale] = useState(DEFAULT_PLAYER_IMAGE_SCALE);
const [filePath, setFilePath] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [saving, setSaving] = useState(false);
const [progress, setProgress] = useState({ percent: 0, detail: '' });
/** Electron не поддерживает window.prompt — форма команды в сайдбаре. */
const [teamForm, setTeamForm] = useState<{
id: PlayerTeamId | null;
name: string;
color: string;
} | null>(null);
const [pendingDeletePlayer, setPendingDeletePlayer] = useState<AppPlayer | null>(null);
const [pendingDeleteTeam, setPendingDeleteTeam] = useState<AppPlayerTeam | null>(null);
const appearanceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const selected = players.find((p) => p.id === selectedId) ?? null;
const selectedUrl = usePlayerImageUrl(selected?.id);
const resetModalState = useCallback(() => {
if (appearanceTimerRef.current) {
clearTimeout(appearanceTimerRef.current);
appearanceTimerRef.current = null;
}
setQuery('');
setSelectedId(null);
setName('');
setRingColor(DEFAULT_PLAYER_RING_COLOR);
setImageOffset({ ...DEFAULT_PLAYER_IMAGE_OFFSET });
setImageScale(DEFAULT_PLAYER_IMAGE_SCALE);
setFilePath(null);
setPreviewUrl((prev) => {
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
return null;
});
setAdding(false);
setSaving(false);
setProgress({ percent: 0, detail: '' });
setTeamForm(null);
setPendingDeletePlayer(null);
setPendingDeleteTeam(null);
}, []);
const handleClose = useCallback(() => {
if (saving) return;
resetModalState();
onClose();
}, [onClose, resetModalState, saving]);
useEffect(() => {
if (!open) return;
setSelectedId((current) =>
current && players.some((p) => p.id === current) ? current : (players[0]?.id ?? null),
);
}, [open, players]);
// Синхронизировать форму только при смене выбранного игрока — не при каждом
// players.stateChanged (иначе сбрасывается несохранённый zoom/pan).
useEffect(() => {
if (adding) return;
if (!selectedId) return;
const p = players.find((item) => item.id === selectedId);
if (!p) return;
setName(p.name);
setRingColor(p.ringColor);
setImageOffset(p.imageOffset);
setImageScale(p.imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE);
setFilePath(null);
setPreviewUrl(null);
// eslint-disable-next-line react-hooks/exhaustive-deps -- только selectedId / adding
}, [adding, selectedId]);
useEffect(() => {
if (!open) return;
return api.on(ipcChannels.players.upsertProgress, (event) => {
setProgress({
percent: Math.max(0, Math.min(100, Math.round(event.percent))),
detail: event.detail?.trim() ?? t('players.savingWait'),
});
});
}, [api, open, t]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape' || saving) return;
if (pendingDeletePlayer) {
setPendingDeletePlayer(null);
return;
}
if (pendingDeleteTeam) {
setPendingDeleteTeam(null);
return;
}
if (teamForm) {
setTeamForm(null);
return;
}
handleClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [handleClose, open, pendingDeletePlayer, pendingDeleteTeam, saving, teamForm]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return q ? players.filter((p) => p.name.toLowerCase().includes(q)) : players;
}, [players, query]);
const beginAdd = () => {
setAdding(true);
setSelectedId(null);
setName('');
setRingColor(DEFAULT_PLAYER_RING_COLOR);
setImageOffset({ ...DEFAULT_PLAYER_IMAGE_OFFSET });
setImageScale(DEFAULT_PLAYER_IMAGE_SCALE);
setFilePath(null);
setPreviewUrl(null);
};
const setPreviewFromPathAndUrl = (path: string, url: string) => {
setFilePath(path);
setPreviewUrl((prev) => {
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
return url || null;
});
};
const pickImage = async () => {
const result = await api.invoke(ipcChannels.players.pickImage, {});
if (result.canceled) return;
setPreviewFromPathAndUrl(result.filePath, result.previewDataUrl);
};
const applyDroppedImage = (path: string, previewFromFile: string) => {
if (!adding && !selected) beginAdd();
setPreviewFromPathAndUrl(path, previewFromFile);
};
const drop = useFileDropZone({
disabled: saving,
filterPaths: filterMaterialImagePaths,
onDropPaths: (paths) => {
const picked = pickFirstMaterialImagePath(paths);
if (!picked) return;
applyDroppedImage(picked, '');
},
});
const appearanceLatestRef = useRef({
selected,
adding,
saving,
ringColor,
imageOffset,
imageScale,
});
appearanceLatestRef.current = { selected, adding, saving, ringColor, imageOffset, imageScale };
const persistAppearance = (patch: {
ringColor?: string;
imageOffset?: typeof imageOffset;
imageScale?: number;
}) => {
const snap = appearanceLatestRef.current;
if (!snap.selected || snap.adding || snap.saving) return;
const payload = {
id: snap.selected.id,
teamId: snap.selected.teamId,
name: snap.selected.name,
ringColor: patch.ringColor ?? snap.ringColor,
imageOffset: patch.imageOffset ?? snap.imageOffset,
imageScale: patch.imageScale ?? snap.imageScale,
};
if (appearanceTimerRef.current) clearTimeout(appearanceTimerRef.current);
appearanceTimerRef.current = setTimeout(() => {
appearanceTimerRef.current = null;
void api.invoke(ipcChannels.players.upsert, payload);
}, 180);
};
useEffect(() => {
return () => {
if (appearanceTimerRef.current) clearTimeout(appearanceTimerRef.current);
};
}, []);
const save = async () => {
const trimmed = name.trim();
if (!trimmed || (selected === null && filePath === null)) return;
if (appearanceTimerRef.current) {
clearTimeout(appearanceTimerRef.current);
appearanceTimerRef.current = null;
}
flushSync(() => {
setSaving(true);
setProgress({ percent: 0, detail: t('players.savingWait') });
});
try {
const result = await api.invoke(ipcChannels.players.upsert, {
...(selected ? { id: selected.id, teamId: selected.teamId } : {}),
name: trimmed,
...(filePath ? { filePath } : {}),
ringColor,
imageOffset,
imageScale,
});
setAdding(false);
setSelectedId(result.player.id);
} finally {
setSaving(false);
}
};
if (!open) return null;
const activeUrl = previewUrl ?? selectedUrl;
return createPortal(
<>
<div className={styles.modalBackdrop} aria-hidden />
<div
role="dialog"
aria-modal="true"
data-testid="players-modal"
className={[styles.modalDialog, playerStyles.dialog].join(' ')}
>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('players.managerTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={handleClose}
className={styles.modalClose}
disabled={saving}
>
×
</button>
</div>
<div className={playerStyles.body}>
<aside className={playerStyles.sidebar}>
<Input value={query} onChange={setQuery} placeholder={t('players.search')} />
<div className={playerStyles.sidebarActions}>
<Button variant="primary" data-testid="players-add" onClick={beginAdd}>
{t('players.add')}
</Button>
<Button
data-testid="players-add-team"
onClick={() =>
setTeamForm({
id: null,
name: '',
color: DEFAULT_PLAYER_RING_COLOR,
})
}
>
{t('players.addTeam')}
</Button>
</div>
{teamForm ? (
<div className={playerStyles.teamForm} data-testid="players-team-form">
<label className={playerStyles.teamFormField}>
<span>{t('players.teamName')}</span>
<Input
value={teamForm.name}
onChange={(v) => setTeamForm((prev) => (prev ? { ...prev, name: v } : prev))}
placeholder={t('players.teamName')}
autoFocus
/>
</label>
<label className={playerStyles.teamFormField}>
<span>{t('players.teamColor')}</span>
<input
type="color"
value={teamForm.color}
onChange={(e) =>
setTeamForm((prev) =>
prev ? { ...prev, color: e.currentTarget.value } : prev,
)
}
/>
</label>
<div className={playerStyles.teamFormActions}>
<Button
variant="primary"
disabled={!teamForm.name.trim()}
onClick={() => {
const trimmed = teamForm.name.trim();
if (!trimmed) return;
void api
.invoke(ipcChannels.players.upsertTeam, {
...(teamForm.id ? { id: teamForm.id } : {}),
name: trimmed,
color: teamForm.color,
})
.then(() => setTeamForm(null));
}}
>
{t('common.save')}
</Button>
<Button onClick={() => setTeamForm(null)}>{t('common.cancel')}</Button>
</div>
</div>
) : null}
<div className={playerStyles.list}>
{teams.map((team) => (
<TeamSection
key={team.id}
team={team}
players={filtered.filter((p) => p.teamId === team.id)}
selectedId={selectedId}
onSelect={(id) => {
setAdding(false);
setSelectedId(id);
}}
onAssign={(playerId, teamId) =>
void api.invoke(ipcChannels.players.assignTeam, { playerId, teamId })
}
onEdit={() =>
setTeamForm({
id: team.id,
name: team.name,
color: team.color,
})
}
onDelete={() => setPendingDeleteTeam(team)}
/>
))}
<TeamSection
team={null}
players={filtered.filter((p) => p.teamId === null)}
selectedId={selectedId}
onSelect={(id) => {
setAdding(false);
setSelectedId(id);
}}
onAssign={(playerId, teamId) =>
void api.invoke(ipcChannels.players.assignTeam, { playerId, teamId })
}
/>
</div>
</aside>
<main
className={[playerStyles.editor, drop.dragOver ? playerStyles.editorDropOver : '']
.filter(Boolean)
.join(' ')}
onDragEnter={drop.onDragEnter}
onDragLeave={drop.onDragLeave}
onDragOver={drop.onDragOver}
onDrop={(e) => {
drop.onDrop(e);
const entries = getDroppedFileEntries(e);
const files = e.dataTransfer?.files;
for (let i = 0; i < entries.length; i += 1) {
const entry = entries[i]!;
if (!pickFirstMaterialImagePath([entry.path])) continue;
const file = files?.[i];
applyDroppedImage(entry.path, file ? URL.createObjectURL(file) : '');
return;
}
}}
>
{drop.dragOver ? <div className={styles.dropHintOverlay}>{t('players.dropHint')}</div> : null}
{adding || selected ? (
<>
<PlayerTokenView
data-testid="player-token-preview"
name={name}
imageUrl={activeUrl}
ringColor={ringColor}
imageOffset={imageOffset}
imageScale={imageScale}
panEnabled
onImageOffsetChange={(next) => {
setImageOffset(next);
persistAppearance({ imageOffset: next });
}}
onImageScaleChange={(next) => {
setImageScale(next);
persistAppearance({ imageScale: next });
}}
sizePx={220}
/>
<label className={playerStyles.field}>
<span>{t('players.name')}</span>
<Input value={name} onChange={setName} placeholder={t('players.namePlaceholder')} />
</label>
<label className={playerStyles.field}>
<span>{t('players.ringColor')}</span>
<input
type="color"
value={ringColor}
onChange={(e) => {
const next = e.currentTarget.value;
setRingColor(next);
persistAppearance({ ringColor: next });
}}
/>
</label>
<div className={playerStyles.actions}>
<Button data-testid="players-choose-image" onClick={() => void pickImage()}>
{t('players.chooseImage')}
</Button>
<Button
variant="primary"
data-testid="players-save"
disabled={!name.trim() || (!selected && !filePath) || saving}
onClick={() => void save()}
>
{saving ? t('common.saving') : t('common.save')}
</Button>
{selected ? (
<Button onClick={() => setPendingDeletePlayer(selected)}>{t('common.delete')}</Button>
) : null}
</div>
</>
) : (
<div className={styles.muted}>{t('players.selectPrompt')}</div>
)}
</main>
</div>
</div>
{pendingDeletePlayer
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalBackdrop}
onClick={() => setPendingDeletePlayer(null)}
/>
<div
role="dialog"
aria-modal="true"
data-testid="players-delete-confirm"
className={styles.modalDialog}
>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('players.deleteTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalClose}
onClick={() => setPendingDeletePlayer(null)}
>
×
</button>
</div>
<div className={styles.muted}>
{t('players.deleteConfirm', { name: pendingDeletePlayer.name })}
</div>
<div className={styles.modalFooter}>
<Button onClick={() => setPendingDeletePlayer(null)}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const id = pendingDeletePlayer.id;
setPendingDeletePlayer(null);
void api.invoke(ipcChannels.players.delete, { id });
}}
>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
{pendingDeleteTeam
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalBackdrop}
onClick={() => setPendingDeleteTeam(null)}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('players.deleteTeamTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalClose}
onClick={() => setPendingDeleteTeam(null)}
>
×
</button>
</div>
<div className={styles.muted}>
{t('players.deleteTeamConfirm', { name: pendingDeleteTeam.name })}
</div>
<div className={styles.modalFooter}>
<Button onClick={() => setPendingDeleteTeam(null)}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const id = pendingDeleteTeam.id;
setPendingDeleteTeam(null);
void api.invoke(ipcChannels.players.deleteTeam, { id });
}}
>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
{saving ? (
<div className={styles.progressOverlay} role="dialog" aria-busy data-testid="players-save-progress">
<div className={styles.progressModal}>
<div className={styles.progressTitle}>{t('players.savingTitle')}</div>
<div className={styles.previewSpinner} aria-hidden />
<div className={styles.progressBar}>
<div className={styles.progressFill} style={{ width: `${String(progress.percent)}%` }} />
</div>
<div className={styles.progressMeta}>
<div>{progress.detail}</div>
<div>{progress.percent}%</div>
</div>
</div>
</div>
) : null}
</>,
document.body,
);
}
@@ -170,21 +170,21 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
<div className={modalStyles.toolbarGroup}> <div className={modalStyles.toolbarGroup}>
<ToolButton <ToolButton
title={t('scene.descriptionBold')} title={t('scene.descriptionBold')}
active={toolbarState.bold} active={toolbarState?.bold ?? false}
onClick={() => editor.chain().focus().toggleBold().run()} onClick={() => editor.chain().focus().toggleBold().run()}
> >
<strong>B</strong> <strong>B</strong>
</ToolButton> </ToolButton>
<ToolButton <ToolButton
title={t('scene.descriptionItalic')} title={t('scene.descriptionItalic')}
active={toolbarState.italic} active={toolbarState?.italic ?? false}
onClick={() => editor.chain().focus().toggleItalic().run()} onClick={() => editor.chain().focus().toggleItalic().run()}
> >
<em>I</em> <em>I</em>
</ToolButton> </ToolButton>
<ToolButton <ToolButton
title={t('scene.descriptionUnderline')} title={t('scene.descriptionUnderline')}
active={toolbarState.underline} active={toolbarState?.underline ?? false}
onClick={() => editor.chain().focus().toggleUnderline().run()} onClick={() => editor.chain().focus().toggleUnderline().run()}
> >
<span style={{ textDecoration: 'underline' }}>U</span> <span style={{ textDecoration: 'underline' }}>U</span>
@@ -194,21 +194,21 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
<div className={modalStyles.toolbarGroup}> <div className={modalStyles.toolbarGroup}>
<ToolButton <ToolButton
title={t('scene.descriptionHeading2')} title={t('scene.descriptionHeading2')}
active={toolbarState.h2} active={toolbarState?.h2 ?? false}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
> >
H2 H2
</ToolButton> </ToolButton>
<ToolButton <ToolButton
title={t('scene.descriptionHeading3')} title={t('scene.descriptionHeading3')}
active={toolbarState.h3} active={toolbarState?.h3 ?? false}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
> >
H3 H3
</ToolButton> </ToolButton>
<ToolButton <ToolButton
title={t('scene.descriptionQuote')} title={t('scene.descriptionQuote')}
active={toolbarState.blockquote} active={toolbarState?.blockquote ?? false}
onClick={() => editor.chain().focus().toggleBlockquote().run()} onClick={() => editor.chain().focus().toggleBlockquote().run()}
> >
<ToolbarIcon path="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" /> <ToolbarIcon path="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
@@ -218,14 +218,14 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
<div className={modalStyles.toolbarGroup}> <div className={modalStyles.toolbarGroup}>
<ToolButton <ToolButton
title={t('scene.descriptionBulletList')} title={t('scene.descriptionBulletList')}
active={toolbarState.bulletList} active={toolbarState?.bulletList ?? false}
onClick={() => editor.chain().focus().toggleBulletList().run()} onClick={() => editor.chain().focus().toggleBulletList().run()}
> >
<ToolbarIcon path="M4 6h2v2H4V6zm0 5h2v2H4v-2zm0 5h2v2H4v-2zm4-10h12v2H8V6zm0 5h12v2H8v-2zm0 5h12v2H8v-2z" /> <ToolbarIcon path="M4 6h2v2H4V6zm0 5h2v2H4v-2zm0 5h2v2H4v-2zm4-10h12v2H8V6zm0 5h12v2H8v-2zm0 5h12v2H8v-2z" />
</ToolButton> </ToolButton>
<ToolButton <ToolButton
title={t('scene.descriptionOrderedList')} title={t('scene.descriptionOrderedList')}
active={toolbarState.orderedList} active={toolbarState?.orderedList ?? false}
onClick={() => editor.chain().focus().toggleOrderedList().run()} onClick={() => editor.chain().focus().toggleOrderedList().run()}
> >
<ToolbarIcon path="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1V14h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" /> <ToolbarIcon path="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1V14h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" />
+2 -2
View File
@@ -18,6 +18,7 @@ const RU_TITLES: Record<HelpSectionId, string> = {
tokens: 'Неигровые токены', tokens: 'Неигровые токены',
campaignAudio: 'Аудио игры', campaignAudio: 'Аудио игры',
materials: 'Материалы', materials: 'Материалы',
players: 'Игроки',
npcs: 'НПС', npcs: 'НПС',
session: 'Запуск сессии', session: 'Запуск сессии',
controlPanel: 'Пульт управления', controlPanel: 'Пульт управления',
@@ -31,8 +32,7 @@ const RU_TITLES: Record<HelpSectionId, string> = {
void test('findHelpLinkRanges: «Ловушки» и алиас «Эффекты»', () => { void test('findHelpLinkRanges: «Ловушки» и алиас «Эффекты»', () => {
const catalog = buildHelpLinkCatalog((id) => RU_TITLES[id]); const catalog = buildHelpLinkCatalog((id) => RU_TITLES[id]);
const text = const text = 'см. «Ловушки» и кистью (см. «Эффекты»). Также разделы «Генератор сетки», «Неигровые токены».';
'см. «Ловушки» и кистью (см. «Эффекты»). Также разделы «Генератор сетки», «Неигровые токены».';
const ranges = findHelpLinkRanges(text, catalog); const ranges = findHelpLinkRanges(text, catalog);
assert.deepEqual( assert.deepEqual(
ranges.map((r) => r.id), ranges.map((r) => r.id),
+1
View File
@@ -9,6 +9,7 @@ export const HELP_SECTION_LINK_ALIASES: Partial<Record<HelpSectionId, readonly s
presentation: ['Презентация', 'Presentation'], presentation: ['Презентация', 'Presentation'],
grid: ['Сетка', 'Grid'], grid: ['Сетка', 'Grid'],
controlPanel: ['Пульт'], controlPanel: ['Пульт'],
players: ['Игроки', 'Players'],
}; };
export type HelpLinkCatalogEntry = { export type HelpLinkCatalogEntry = {
+1
View File
@@ -13,6 +13,7 @@ export const HELP_SECTION_IDS = [
'tokens', 'tokens',
'campaignAudio', 'campaignAudio',
'materials', 'materials',
'players',
'npcs', 'npcs',
'session', 'session',
'controlPanel', 'controlPanel',
+105 -9
View File
@@ -110,9 +110,18 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'top.settings': 'Настройки', 'top.settings': 'Настройки',
'top.project': 'Проект', 'top.project': 'Проект',
'top.file': 'Файл', 'top.file': 'Файл',
'top.players': 'Игроки',
'top.backToProjects': 'К списку проектов', 'top.backToProjects': 'К списку проектов',
'top.appVersion': 'Версия приложения', 'top.appVersion': 'Версия приложения',
'top.run': 'Запустить', 'top.run': 'Запустить',
'top.runMenuAria': 'Дополнительные варианты запуска',
'top.runWithPlayers': 'Запустить с игроками',
'top.runWithPlayersTitle': 'Запуск с игроками',
'top.runWithPlayersHint': 'Выберите игроков и/или команды. Выбор сохранится на время сессии.',
'top.runWithPlayersTeams': 'Команды',
'top.runWithPlayersPlayers': 'Игроки',
'top.runWithPlayersEmpty': 'Нет игроков в библиотеке.',
'top.runWithPlayersConfirm': 'Запустить',
'top.launching': 'Запуск…', 'top.launching': 'Запуск…',
'top.afterLicense': 'Доступно после активации лицензии', 'top.afterLicense': 'Доступно после активации лицензии',
'top.setStartScene': 'Назначьте начальную сцену на графе (ПКМ по узлу)', 'top.setStartScene': 'Назначьте начальную сцену на графе (ПКМ по узлу)',
@@ -175,7 +184,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.sceneEditor.title': 'Редактор сцены', 'help.section.sceneEditor.title': 'Редактор сцены',
'help.section.sceneEditor.body': 'help.section.sceneEditor.body':
'«Редактор сцены» — отдельное окно для подготовки карты: сетка боя, ловушки и неигровые токены. Доступен только для сцен с изображением (не с видео).\n\nОткрыть:\n\n1) Выберите сцену в списке слева.\n\n2) В «Свойствах сцены» загрузите картинку, если её ещё нет.\n\n3) Нажмите «Редактор сцены».\n\nСлева — аккордеоны «Сетка», «Неигровые токены» и «Ловушки»; справа — карта сцены.\n\nНавигация по карте: колесо мыши — зум; средняя кнопка мыши или Space+ЛКМ — сдвиг вида. Delete / Backspace убирает выделенный маркер на карте.\n\nПод аккордеонами кнопка «Очистить сцену» убирает с текущей карты все ловушки и токены (пул токенов приложения не трогает).\n\nПодробнее: разделы «Генератор сетки», «Ловушки» и «Неигровые токены».', '«Редактор сцены» — отдельное окно для подготовки карты: сетка боя, ловушки, неигровые токены и круглые токены НПС. Доступен только для сцен с изображением (не с видео).\n\nОткрыть:\n\n1) Выберите сцену в списке слева.\n\n2) В «Свойствах сцены» загрузите картинку, если её ещё нет.\n\n3) Нажмите «Редактор сцены».\n\nСлева — аккордеоны «Сетка», «Неигровые токены», «НПС» и «Ловушки»; справа — карта сцены. Перетащите НПС на карту, чтобы добавить его круглый токен.\n\nНавигация по карте: колесо мыши — зум; средняя кнопка мыши или Space+ЛКМ — сдвиг вида. Delete / Backspace убирает выделенный маркер на карте.\n\nПод аккордеонами кнопка «Очистить сцену» убирает с текущей карты все ловушки и токены (пул токенов приложения не трогает).\n\nПодробнее: разделы «Генератор сетки», «Ловушки» и «Неигровые токены».',
'help.section.grid.title': 'Генератор сетки', 'help.section.grid.title': 'Генератор сетки',
'help.section.grid.body': 'help.section.grid.body':
@@ -196,14 +205,17 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.materials.title': 'Материалы', 'help.section.materials.title': 'Материалы',
'help.section.materials.body': 'help.section.materials.body':
'Материалы — изображения кампании (карты, записки, чертежи), которые можно показать игрокам поверх сцены во время игры. Они общие для проекта и не привязаны к одной сцене.\n\nВ редакторе:\n\n1) В блоке «Свойства игры» нажмите «Материалы».\n\n2) «Добавить» — укажите уникальное название и изображение (PNG, JPG или WebP): кнопка выбора или перетаскивание файла.\n\n3) В списке можно искать, менять порядок перетаскиванием, править или удалять через меню «⋮» (перед удалением будет подтверждение).\n\n4) Под большим превью — «Повернуть»: поворот на 90° (учитывается и в плитке, и при показе на экране).\n\nВо время сессии:\n\n1) На пульте в «Инструменты» нажмите кнопку материалов (иконка карты сокровищ) — откроется отдельное окно со списком.\n\n2) Клик по плитке показывает материал поверх сцены на пульте и на презентации; повторный клик по той же плитке скрывает его.\n\n3) На предпросмотре пульта материал можно перетаскивать и менять размер за углы; крестик закрывает показ.\n\n4) В окне материалов лупы «+» / «−» — инструменты масштаба: выберите лупу, затем кликните по материалу в предпросмотре пульта.\n\nПри смене сцены показ материала сбрасывается. Описание сцены и эффекты поля с материалами не связаны.', 'Материалы — изображения кампании (карты, записки, чертежи), которые можно показать игрокам поверх сцены во время игры. Они общие для проекта и не привязаны к одной сцене.\n\nВ редакторе:\n\n1) В блоке «Свойства игры» нажмите «Материалы».\n\n2) «Добавить» — укажите уникальное название и изображение (PNG, JPG или WebP): кнопка выбора или перетаскивание файла.\n\n3) В списке можно искать, менять порядок перетаскиванием, править или удалять через меню «⋮» (перед удалением будет подтверждение).\n\n4) Под большим превью — «Повернуть»: поворот на 90° (учитывается и в плитке, и при показе на экране).\n\nВо время сессии:\n\n1) На пульте в «Инструменты» нажмите кнопку материалов (иконка карты сокровищ) — откроется отдельное окно со списком.\n\n2) Клик по плитке показывает материал поверх сцены на пульте и на презентации; повторный клик по той же плитке скрывает его.\n\n3) На предпросмотре пульта материал можно перетаскивать и менять размер за углы; крестик закрывает показ.\n\n4) В окне материалов лупы «+» / «−» — инструменты масштаба: выберите лупу, затем кликните по материалу в предпросмотре пульта.\n\nПри смене сцены показ материала сбрасывается. Описание сцены и эффекты поля с материалами не связаны.',
'help.section.players.title': 'Игроки',
'help.section.players.body':
'Раздел «Игроки» в шапке хранит локальную библиотеку живых игроков на этом компьютере (не внутри файла проекта).\n\n1) Откройте «Игроки» в шапке.\n\n2) «Добавить игрока» — имя и изображение обязательны. Пока идёт сохранение, видно окно с прогрессом.\n\n3) Справа — превью игрового токена: круг с цветной рамкой, аватар внутри (перетащите мышью, чтобы отцентровать; колесо мыши — увеличить/уменьшить), имя на тёмной подложке и выбор цвета рамки.\n\n4) Команды — плоские группы без вложенности: создайте команду, перетащите игрока в неё или в «Без команды».\n\nКампанийных НПС на сцену ставят отдельно: в «Редактор сцены» аккордеон «НПС» — плитки персонажей проекта, на карте они выглядят как такой же круглый токен.',
'help.section.npcs.title': 'НПС', 'help.section.npcs.title': 'НПС',
'help.section.npcs.body': 'help.section.npcs.body':
'НПС — персонажи кампании с аватаром, описанием и однонаправленными связями между собой. Они общие для проекта.\n\nВ редакторе:\n\n1) В блоке «Свойства игры» нажмите «НПС» — откроется отдельное окно редактора персонажей.\n\n2) «Добавить» — укажите уникальное имя и обязательный аватар (PNG, JPG или WebP): кнопка выбора или перетаскивание файла. При необходимости сразу заполните описание.\n\n3) Слева — список персонажей: поиск, порядок перетаскиванием; меню «⋮» — только удаление (с подтверждением; все связи с этим персонажем тоже удаляются).\n\n4) В центре — граф связей: протяните стрелку от одного персонажа к другому и укажите обязательное название связи. Связь однонаправленная (А → Б и Б → А — разные). Несколько связей в одном направлении рисуются параллельными дугами. Клик по связи или её подписи выбирает исходного персонажа и подсвечивает его исходящие связи.\n\n5) Справа — карточка выбранного персонажа: аватар, имя, описание (форматированный текст) и список «Отношения» — только исходящие связи («название» + имя цели).\n\n6) Правый клик по связи на графе — «Редактировать» название или «Удалить» (с подтверждением).\n\nВо время сессии:\n\n1) На пульте в «Инструменты» рядом с материалами нажмите кнопку НПС (цветная иконка человека) — откроется отдельное окно.\n\n2) Справа — список персонажей; клик по плитке показывает аватар поверх сцены на пульте и на презентации; повторный клик по той же плитке скрывает его. Слева — описание и исходящие отношения выбранного персонажа (их видите только вы).\n\n3) На предпросмотре пульта аватар можно перетаскивать и менять размер за углы; крестик закрывает показ.\n\n4) В окне НПС лупы «+» / «−» — инструменты масштаба: выберите лупу, затем кликните по аватару в предпросмотре пульта.\n\nПри смене сцены показ НПС сбрасывается. Игроки на презентации видят только аватар.', 'НПС — персонажи кампании с аватаром, описанием и однонаправленными связями между собой. Они общие для проекта.\n\nВ редакторе:\n\n1) В блоке «Свойства игры» нажмите «НПС» — откроется отдельное окно редактора персонажей.\n\n2) «Добавить» — укажите уникальное имя и обязательный аватар (PNG, JPG или WebP): кнопка выбора или перетаскивание файла. При необходимости сразу заполните описание.\n\n3) Слева — список персонажей: поиск, порядок перетаскиванием; меню «⋮» — только удаление (с подтверждением; все связи с этим персонажем тоже удаляются).\n\n4) В центре — граф связей: протяните стрелку от одного персонажа к другому и укажите обязательное название связи.\n\n5) Справа — карточка выбранного персонажа: настройте круглый токен, цвет рамки и положение аватара (перетаскивание и колесо мыши для масштаба), а также имя, описание и отношения.\n\n6) В «Редакторе сцены» раскройте «НПС» и перетащите персонажа на карту. Круглый токен можно двигать и менять его размер; во время сессии он доступен на пульте и только отображается в презентации.',
'help.section.session.title': 'Запуск сессии', 'help.section.session.title': 'Запуск сессии',
'help.section.session.body': 'help.section.session.body':
'Когда кампания готова, можно начать игру.\n\nОбычный запуск:\n\n1) На карте связей щёлкните правой кнопкой по карточке старта → «Начальная сцена».\n\n2) Нажмите «Запустить» в шапке редактора.\n\nБыстрый запуск с любой карточки: правый клик по нужной карточке на карте → «Запустить с этой сцены». Презентация и пульт откроются сразу с выбранного места.\n\nОткроются «Презентация» (для игроков) и «Пульт управления» (для вас). Редактор на время показа затемняется — так и должно быть.\n\nВернуться к подготовке:\n\n1) На пульте нажмите «Выключить демонстрацию» или «Завершить показ» (если дальше некуда переходить).\n\n2) Дождитесь закрытия обоих окон.\n\nОкно «Презентация» перенесите на второй монитор, проектор или ТВ и разверните на весь экран (F11). Игроки увидят только картинку, видео и эффекты — без ваших кнопок.', 'Когда кампания готова, можно начать игру.\n\nОбычный запуск:\n\n1) На карте связей щёлкните правой кнопкой по карточке старта → «Начальная сцена».\n\n2) Нажмите «Запустить» в шапке редактора (левая часть кнопки).\n\nЗапуск с игроками: стрелка справа на кнопке «Запустить» → «Запустить с игроками» — выберите игроков и/или команды. Выбор сохранится на время сессии; на пульте появится «Показать игроков» / «Скрыть игроков». При смене сцены токены игроков скрываются.\n\nБыстрый запуск с любой карточки: правый клик по нужной карточке на карте → «Запустить с этой сцены». Презентация и пульт откроются сразу с выбранного места.\n\nОткроются «Презентация» (для игроков) и «Пульт управления» (для вас). Редактор на время показа затемняется — так и должно быть.\n\nВернуться к подготовке:\n\n1) На пульте нажмите «Выключить» или «Завершить показ» (если дальше некуда переходить).\n\n2) Дождитесь закрытия обоих окон.\n\nОкно «Презентация» перенесите на второй монитор, проектор или ТВ и разверните на весь экран (F11). Игроки увидят только картинку, видео и эффекты — без ваших кнопок.',
'help.section.controlPanel.title': 'Пульт управления', 'help.section.controlPanel.title': 'Пульт управления',
'help.section.controlPanel.body': 'help.section.controlPanel.body':
@@ -416,6 +428,28 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'materials.zoomOutHint': 'Кликните по материалу в предпросмотре пульта, чтобы уменьшить.', 'materials.zoomOutHint': 'Кликните по материалу в предпросмотре пульта, чтобы уменьшить.',
'materials.zoomIdleHint': 'Выберите лупу, затем кликните по материалу в предпросмотре пульта.', 'materials.zoomIdleHint': 'Выберите лупу, затем кликните по материалу в предпросмотре пульта.',
'players.managerTitle': 'Игроки',
'players.add': 'Добавить игрока',
'players.search': 'Поиск игроков…',
'players.selectPrompt': 'Выберите игрока или добавьте нового.',
'players.name': 'ИМЯ',
'players.namePlaceholder': 'Имя игрока…',
'players.ringColor': 'ЦВЕТ РАМКИ',
'players.chooseImage': 'Выбрать изображение',
'players.dropHint': 'Перетащите изображение (PNG, JPG, WebP)',
'players.savingTitle': 'Сохранение игрока',
'players.savingWait': 'Подождите…',
'players.ungrouped': 'Без команды',
'players.addTeam': 'Новая команда',
'players.teamName': 'Название команды',
'players.teamColor': 'Цвет команды',
'players.teamMenu': 'Меню команды',
'players.dropHere': 'Перетащите игрока сюда',
'players.deleteTitle': 'Удаление игрока',
'players.deleteConfirm': 'Вы уверены, что хотите удалить игрока «{name}»?',
'players.deleteTeamTitle': 'Удаление команды',
'players.deleteTeamConfirm': 'Вы уверены, что хотите удалить команду «{name}»? Игроки останутся без команды.',
'npcs.open': 'НПС', 'npcs.open': 'НПС',
'npcs.editorTitle': 'НПС', 'npcs.editorTitle': 'НПС',
'npcs.add': 'Добавить', 'npcs.add': 'Добавить',
@@ -440,6 +474,16 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.avatarRequired': 'Выберите аватар.', 'npcs.avatarRequired': 'Выберите аватар.',
'npcs.chooseAvatar': 'Выбрать аватар', 'npcs.chooseAvatar': 'Выбрать аватар',
'npcs.dropHint': 'Перетащите изображение (PNG, JPG, WebP)', 'npcs.dropHint': 'Перетащите изображение (PNG, JPG, WebP)',
'npcs.disposition': 'ТИП',
'npcs.disposition.hostile': 'Враждебный',
'npcs.disposition.neutral': 'Нейтральный',
'npcs.disposition.friendly': 'Дружественный',
'npcs.makeHostile': 'Сделать враждебным',
'npcs.makeNeutral': 'Сделать нейтральным',
'npcs.makeFriendly': 'Сделать дружественным',
'npcs.inactive': 'Неактивен',
'npcs.makeActive': 'Сделать активным',
'npcs.ringColor': 'ЦВЕТ РАМКИ ТОКЕНА',
'npcs.description': 'ОПИСАНИЕ', 'npcs.description': 'ОПИСАНИЕ',
'npcs.descriptionPlaceholder': 'Описание персонажа…', 'npcs.descriptionPlaceholder': 'Описание персонажа…',
'npcs.descriptionEmpty': 'Описание отсутствует', 'npcs.descriptionEmpty': 'Описание отсутствует',
@@ -578,7 +622,10 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.passed': 'Пройдено', 'control.passed': 'Пройдено',
'control.noActiveScene': 'Нет активной сцены.', 'control.noActiveScene': 'Нет активной сцены.',
'control.screenPreview': 'Предпросмотр экрана', 'control.screenPreview': 'Предпросмотр экрана',
'control.stopPresentation': 'Выключить демонстрацию', 'control.npcTokenScale': 'Размеры игр. токенов',
'control.stopPresentation': 'Выключить',
'control.showPlayers': 'Показать игроков',
'control.hidePlayers': 'Скрыть игроков',
'control.videoBrushHint': 'control.videoBrushHint':
'Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для изображения).', 'Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для изображения).',
'control.branches': 'Варианты ветвления', 'control.branches': 'Варианты ветвления',
@@ -680,9 +727,18 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'top.settings': 'Settings', 'top.settings': 'Settings',
'top.project': 'Project', 'top.project': 'Project',
'top.file': 'File', 'top.file': 'File',
'top.players': 'Players',
'top.backToProjects': 'Back to projects', 'top.backToProjects': 'Back to projects',
'top.appVersion': 'App version', 'top.appVersion': 'App version',
'top.run': 'Run', 'top.run': 'Run',
'top.runMenuAria': 'More launch options',
'top.runWithPlayers': 'Run with players',
'top.runWithPlayersTitle': 'Launch with players',
'top.runWithPlayersHint': 'Select players and/or teams. The selection is kept for this session.',
'top.runWithPlayersTeams': 'Teams',
'top.runWithPlayersPlayers': 'Players',
'top.runWithPlayersEmpty': 'No players in the library.',
'top.runWithPlayersConfirm': 'Launch',
'top.launching': 'Starting…', 'top.launching': 'Starting…',
'top.afterLicense': 'Available after license activation', 'top.afterLicense': 'Available after license activation',
'top.setStartScene': 'Set a start scene on the graph (rightclick a node)', 'top.setStartScene': 'Set a start scene on the graph (rightclick a node)',
@@ -745,7 +801,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.sceneEditor.title': 'Scene editor', 'help.section.sceneEditor.title': 'Scene editor',
'help.section.sceneEditor.body': 'help.section.sceneEditor.body':
'Scene editor is a separate window for preparing the map: battle grid, traps, and non-player tokens. It is available only for image scenes (not video).\n\nOpen it:\n\n1) Select a scene in the left list.\n\n2) In Scene properties, upload an image if the scene has none yet.\n\n3) Click Scene editor.\n\nOn the left are the Grid, Non-player tokens, and Traps accordions; on the right is the scene map.\n\nMap navigation: mouse wheel zooms; middle mouse button or Space+left-drag pans the view. Delete / Backspace removes the selected marker on the map.\n\nUnder the accordions, Clear scene removes every trap and token from the current map (it does not delete tokens from the app library).\n\nFor details, see Grid generator, Traps, and Non-player tokens.', 'Scene editor is a separate window for preparing the map: battle grid, traps, non-player tokens, and circular NPC tokens. It is available only for image scenes (not video).\n\nOpen it:\n\n1) Select a scene in the left list.\n\n2) In Scene properties, upload an image if the scene has none yet.\n\n3) Click Scene editor.\n\nOn the left are the Grid, Non-player tokens, NPCs, and Traps accordions; on the right is the scene map. Drag an NPC onto the map to add its circular token.\n\nMap navigation: mouse wheel zooms; middle mouse button or Space+left-drag pans the view. Delete / Backspace removes the selected marker on the map.\n\nUnder the accordions, Clear scene removes every trap and token from the current map (it does not delete tokens from the app library).\n\nFor details, see Grid generator, Traps, and Non-player tokens.',
'help.section.grid.title': 'Grid generator', 'help.section.grid.title': 'Grid generator',
'help.section.grid.body': 'help.section.grid.body':
@@ -766,14 +822,17 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.materials.title': 'Materials', 'help.section.materials.title': 'Materials',
'help.section.materials.body': 'help.section.materials.body':
'Materials are campaign images (maps, notes, sketches) you can show players on top of the scene during play. They belong to the project and are not tied to one scene.\n\nIn the editor:\n\n1) Under Game properties, click Materials.\n\n2) Add — enter a unique name and an image (PNG, JPG, or WebP) via Choose image or by dropping a file.\n\n3) In the list you can search, reorder by drag-and-drop, and edit or delete via the ⋮ menu (delete asks for confirmation).\n\n4) Under the large preview, Rotate turns the image by 90° (applied in the tile and when shown on screen).\n\nDuring a session:\n\n1) On the control panel under Tools, click the materials button (treasure-map icon) to open a separate window with the list.\n\n2) Click a tile to show the material over the scene on the control preview and presentation; click the same tile again to hide it.\n\n3) On the control preview you can drag the material and resize it from the corners; the × button closes the overlay.\n\n4) In the materials window, the + / magnifiers are zoom tools: pick one, then click the material on the control preview.\n\nChanging scenes clears the material overlay. Scene description and field effects are separate from materials.', 'Materials are campaign images (maps, notes, sketches) you can show players on top of the scene during play. They belong to the project and are not tied to one scene.\n\nIn the editor:\n\n1) Under Game properties, click Materials.\n\n2) Add — enter a unique name and an image (PNG, JPG, or WebP) via Choose image or by dropping a file.\n\n3) In the list you can search, reorder by drag-and-drop, and edit or delete via the ⋮ menu (delete asks for confirmation).\n\n4) Under the large preview, Rotate turns the image by 90° (applied in the tile and when shown on screen).\n\nDuring a session:\n\n1) On the control panel under Tools, click the materials button (treasure-map icon) to open a separate window with the list.\n\n2) Click a tile to show the material over the scene on the control preview and presentation; click the same tile again to hide it.\n\n3) On the control preview you can drag the material and resize it from the corners; the × button closes the overlay.\n\n4) In the materials window, the + / magnifiers are zoom tools: pick one, then click the material on the control preview.\n\nChanging scenes clears the material overlay. Scene description and field effects are separate from materials.',
'help.section.players.title': 'Players',
'help.section.players.body':
'The Players item in the header stores a local library of live players on this computer (not inside the project file).\n\n1) Open Players in the header.\n\n2) Add player — name and image are required. A progress dialog appears while saving.\n\n3) On the right — player token preview: a circle with a colored ring, avatar inside (drag to recenter; mouse wheel to zoom), name on a dark plate, and a ring color picker.\n\n4) Teams are flat groups with no nesting: create a team and drag a player into it or into No team.\n\nCampaign NPCs are placed separately: in Scene editor open the NPCs accordion and drag project characters onto the map — they appear as the same circular token.',
'help.section.npcs.title': 'NPCs', 'help.section.npcs.title': 'NPCs',
'help.section.npcs.body': 'help.section.npcs.body':
'NPCs are campaign characters with an avatar, description, and one-way relations between them. They belong to the project.\n\nIn the editor:\n\n1) Under Game properties, click NPCs — a separate character editor window opens.\n\n2) Add — enter a unique name and a required avatar (PNG, JPG, or WebP) via Choose avatar or by dropping a file. You can fill in the description right away if you want.\n\n3) Left: character list with search and drag reorder; the ⋮ menu is delete only (with confirmation; all relations involving that character are removed too).\n\n4) Center: relationship graph — drag an arrow from one character to another and enter a required relation name. Relations are one-way (A → B and B → A are different). Multiple relations in the same direction are drawn as parallel curves. Click a relation or its label to select the source character and highlight their outgoing links.\n\n5) Right: the selected characters card — avatar, name, description (rich text), and a Relations list of outgoing links only (“name” + target name).\n\n6) Right-click a relation on the graph to Edit the name or Delete (with confirmation).\n\nDuring a session:\n\n1) On the control panel under Tools, next to materials, click the NPCs button (colored person icon) to open a separate window.\n\n2) Right: character list; click a tile to show the avatar over the scene on the control preview and presentation; click the same tile again to hide it. Left: description and outgoing relations for the selected character (visible only to you).\n\n3) On the control preview you can drag the avatar and resize it from the corners; the × button closes the overlay.\n\n4) In the NPCs window, the + / magnifiers are zoom tools: pick one, then click the avatar on the control preview.\n\nChanging scenes clears the NPC overlay. Players on presentation see only the avatar.', 'NPCs are campaign characters with an avatar, description, and one-way relations between them. They belong to the project.\n\nIn the NPC editor, select a character and use the right inspector to configure their circular token: ring color, avatar position (drag), and zoom (mouse wheel) can be adjusted directly in the preview. Name, description, group, and relations remain available there as well.\n\nIn Scene editor, expand NPCs and drag a character onto the map. The circular marker can be moved and resized. During a session it remains movable on the control preview and is read-only on presentation.',
'help.section.session.title': 'Starting a session', 'help.section.session.title': 'Starting a session',
'help.section.session.body': 'help.section.session.body':
'When your campaign is ready, you can start playing.\n\nStandard start:\n\n1) On the story map, right-click the starting card → Start scene.\n\n2) Click Run in the editor header.\n\nQuick start from any card: right-click the card on the map → Start from this scene. Presentation and the control panel open at that spot right away.\n\nPresentation (for players) and the Control panel (for you) open. The editor dims while the show runs — that is expected.\n\nReturn to prep:\n\n1) On the control panel, click Stop presentation or End presentation (when there is nowhere left to go).\n\n2) Wait until both windows close.\n\nMove the Presentation window to a second monitor, projector, or TV and go fullscreen (F11). Players see only the image, video, and effects — not your buttons.', 'When your campaign is ready, you can start playing.\n\nStandard start:\n\n1) On the story map, right-click the starting card → Start scene.\n\n2) Click Run in the editor header (left part of the button).\n\nRun with players: use the chevron on the right of Run → Run with players — pick players and/or teams. The selection lasts for the session; the control panel shows Show players / Hide players. Changing scenes hides player tokens.\n\nQuick start from any card: right-click the card on the map → Start from this scene. Presentation and the control panel open at that spot right away.\n\nPresentation (for players) and the Control panel (for you) open. The editor dims while the show runs — that is expected.\n\nReturn to prep:\n\n1) On the control panel, click Turn off or End presentation (when there is nowhere left to go).\n\n2) Wait until both windows close.\n\nMove the Presentation window to a second monitor, projector, or TV and go fullscreen (F11). Players see only the image, video, and effects — not your buttons.',
'help.section.controlPanel.title': 'Control panel', 'help.section.controlPanel.title': 'Control panel',
'help.section.controlPanel.body': 'help.section.controlPanel.body':
@@ -987,6 +1046,29 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'materials.zoomOutHint': 'Click the material on the control preview to zoom out.', 'materials.zoomOutHint': 'Click the material on the control preview to zoom out.',
'materials.zoomIdleHint': 'Pick a magnifier, then click the material on the control preview.', 'materials.zoomIdleHint': 'Pick a magnifier, then click the material on the control preview.',
'players.managerTitle': 'Players',
'players.add': 'Add player',
'players.search': 'Search players…',
'players.selectPrompt': 'Select a player or add a new one.',
'players.name': 'NAME',
'players.namePlaceholder': 'Player name…',
'players.ringColor': 'RING COLOR',
'players.chooseImage': 'Choose image',
'players.dropHint': 'Drop an image (PNG, JPG, WebP)',
'players.savingTitle': 'Saving player',
'players.savingWait': 'Please wait…',
'players.ungrouped': 'No team',
'players.addTeam': 'New team',
'players.teamName': 'Team name',
'players.teamColor': 'Team color',
'players.teamMenu': 'Team menu',
'players.dropHere': 'Drop a player here',
'players.deleteTitle': 'Delete player',
'players.deleteConfirm': 'Are you sure you want to delete player “{name}”?',
'players.deleteTeamTitle': 'Delete team',
'players.deleteTeamConfirm':
'Are you sure you want to delete team “{name}”? Players will become ungrouped.',
'npcs.open': 'NPCs', 'npcs.open': 'NPCs',
'npcs.editorTitle': 'NPCs', 'npcs.editorTitle': 'NPCs',
'npcs.add': 'Add', 'npcs.add': 'Add',
@@ -1011,13 +1093,24 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.avatarRequired': 'Choose an avatar.', 'npcs.avatarRequired': 'Choose an avatar.',
'npcs.chooseAvatar': 'Choose avatar', 'npcs.chooseAvatar': 'Choose avatar',
'npcs.dropHint': 'Drop an image (PNG, JPG, WebP)', 'npcs.dropHint': 'Drop an image (PNG, JPG, WebP)',
'npcs.disposition': 'TYPE',
'npcs.disposition.hostile': 'Hostile',
'npcs.disposition.neutral': 'Neutral',
'npcs.disposition.friendly': 'Friendly',
'npcs.makeHostile': 'Make Hostile',
'npcs.makeNeutral': 'Make Neutral',
'npcs.makeFriendly': 'Make Friendly',
'npcs.inactive': 'Inactive',
'npcs.makeActive': 'Make Active',
'npcs.ringColor': 'TOKEN RING COLOR',
'npcs.description': 'DESCRIPTION', 'npcs.description': 'DESCRIPTION',
'npcs.descriptionPlaceholder': 'Character description…', 'npcs.descriptionPlaceholder': 'Character description…',
'npcs.descriptionEmpty': 'No description', 'npcs.descriptionEmpty': 'No description',
'npcs.relations': 'Relations', 'npcs.relations': 'Relations',
'npcs.untitled': 'Untitled', 'npcs.untitled': 'Untitled',
'npcs.deleteTitle': 'Delete NPC', 'npcs.deleteTitle': 'Delete NPC',
'npcs.deleteConfirm': 'Are you sure you want to delete NPC “{name}”? All of their relations will be removed.', 'npcs.deleteConfirm':
'Are you sure you want to delete NPC “{name}”? All of their relations will be removed.',
'npcs.relationCreateTitle': 'Relation name', 'npcs.relationCreateTitle': 'Relation name',
'npcs.relationEditTitle': 'Relation name', 'npcs.relationEditTitle': 'Relation name',
'npcs.relationLabel': 'NAME', 'npcs.relationLabel': 'NAME',
@@ -1148,7 +1241,10 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.passed': 'Visited', 'control.passed': 'Visited',
'control.noActiveScene': 'No active scene.', 'control.noActiveScene': 'No active scene.',
'control.screenPreview': 'Screen preview', 'control.screenPreview': 'Screen preview',
'control.stopPresentation': 'Stop presentation', 'control.npcTokenScale': 'Play token size',
'control.stopPresentation': 'Turn off',
'control.showPlayers': 'Show players',
'control.hidePlayers': 'Hide players',
'control.videoBrushHint': 'control.videoBrushHint':
'Video preview: effect brush is disabled (like on the presentation screen — overlay is for images only).', 'Video preview: effect brush is disabled (like on the presentation screen — overlay is for images only).',
'control.branches': 'Branch options', 'control.branches': 'Branch options',
+12 -15
View File
@@ -50,11 +50,7 @@ type Actions = {
importCampaignAudio: () => Promise<void>; importCampaignAudio: () => Promise<void>;
importCampaignAudioFromPaths: (filePaths: string[]) => Promise<void>; importCampaignAudioFromPaths: (filePaths: string[]) => Promise<void>;
updateCampaignAudios: (next: Project['campaignAudios']) => Promise<void>; updateCampaignAudios: (next: Project['campaignAudios']) => Promise<void>;
upsertMaterial: (input: { upsertMaterial: (input: { materialId?: MaterialId; name: string; filePath?: string }) => Promise<void>;
materialId?: MaterialId;
name: string;
filePath?: string;
}) => Promise<void>;
deleteMaterial: (materialId: MaterialId) => Promise<void>; deleteMaterial: (materialId: MaterialId) => Promise<void>;
setMaterialsOrder: (materialIds: MaterialId[]) => Promise<void>; setMaterialsOrder: (materialIds: MaterialId[]) => Promise<void>;
setMaterialRotation: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => Promise<void>; setMaterialRotation: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => Promise<void>;
@@ -104,7 +100,10 @@ type Actions = {
mode: 'folder' | 'archive', mode: 'folder' | 'archive',
) => Promise<{ canceled: true } | { canceled: false; sourcePath: string }>; ) => Promise<{ canceled: true } | { canceled: false; sourcePath: string }>;
importFoundryProject: (sourcePath: string) => Promise<void>; importFoundryProject: (sourcePath: string) => Promise<void>;
peekImportZip: (labels: StorylineLabels, targetHasMainStart: boolean) => Promise< peekImportZip: (
labels: StorylineLabels,
targetHasMainStart: boolean,
) => Promise<
| { canceled: true } | { canceled: true }
| { | {
canceled: false; canceled: false;
@@ -355,6 +354,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
darkenScene: false, darkenScene: false,
traps: [], traps: [],
tokens: [], tokens: [],
npcTokens: [],
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' }, grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
media: { videos: [], audios: [] }, media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true }, settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
@@ -509,11 +509,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
await refreshProjects(); await refreshProjects();
}; };
const upsertMaterial = async (input: { const upsertMaterial = async (input: { materialId?: MaterialId; name: string; filePath?: string }) => {
materialId?: MaterialId;
name: string;
filePath?: string;
}) => {
const res = await api.invoke(ipcChannels.project.upsertMaterial, input); const res = await api.invoke(ipcChannels.project.upsertMaterial, input);
setState((s) => ({ ...s, project: res.project })); setState((s) => ({ ...s, project: res.project }));
await refreshProjects(); await refreshProjects();
@@ -531,10 +527,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
await refreshProjects(); await refreshProjects();
}; };
const setMaterialRotation = async ( const setMaterialRotation = async (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => {
materialId: MaterialId,
rotationDeg: 0 | 90 | 180 | 270,
) => {
const res = await api.invoke(ipcChannels.project.setMaterialRotation, { const res = await api.invoke(ipcChannels.project.setMaterialRotation, {
materialId, materialId,
rotationDeg, rotationDeg,
@@ -570,6 +563,9 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
previewRotationDeg?: 0 | 90 | 180 | 270; previewRotationDeg?: 0 | 90 | 180 | 270;
darkenScene?: boolean; darkenScene?: boolean;
traps?: import('../../../shared/types').SceneTrap[]; traps?: import('../../../shared/types').SceneTrap[];
tokens?: import('../../../shared/types').SceneToken[];
npcTokens?: import('../../../shared/types').SceneNpcToken[];
grid?: import('../../../shared/types').SceneGrid;
settings?: Partial<Scene['settings']>; settings?: Partial<Scene['settings']>;
media?: Partial<Scene['media']>; media?: Partial<Scene['media']>;
layout?: { x: number; y: number }; layout?: { x: number; y: number };
@@ -598,6 +594,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null), ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
...(patch.traps !== undefined ? { traps: patch.traps } : null), ...(patch.traps !== undefined ? { traps: patch.traps } : null),
...(patch.tokens !== undefined ? { tokens: patch.tokens } : null), ...(patch.tokens !== undefined ? { tokens: patch.tokens } : null),
...(patch.npcTokens !== undefined ? { npcTokens: patch.npcTokens } : null),
...(patch.grid !== undefined ? { grid: patch.grid } : null), ...(patch.grid !== undefined ? { grid: patch.grid } : null),
...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null), ...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null),
...(patch.media ? { media: { ...scene.media, ...patch.media } } : null), ...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),
+1 -1
View File
@@ -302,7 +302,7 @@ function FilterToolbar({
return ( return (
<Panel position="top-left"> <Panel position="top-left">
<Select <Select
className={styles.filterSelect} {...(styles.filterSelect ? { className: styles.filterSelect } : {})}
ariaLabel={ui.graphFilter} ariaLabel={ui.graphFilter}
value={value} value={value}
onChange={(next) => onChange(next as GraphGroupFilter)} onChange={(next) => onChange(next as GraphGroupFilter)}
@@ -189,6 +189,15 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
.colorInput {
width: 100%;
height: 36px;
padding: 0;
border: 1px solid var(--stroke);
border-radius: 8px;
background: transparent;
}
.avatarPick { .avatarPick {
display: grid; display: grid;
gap: 10px; gap: 10px;
+42 -5
View File
@@ -14,8 +14,10 @@ import type {
import editorStyles from '../editor/EditorApp.module.css'; import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi'; import { getDndApi } from '../shared/dndApi';
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
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 { npcDispositionRingColor } from '../../shared/types/npcDisposition';
import { NpcDescriptionField } from './NpcDescriptionField'; import { NpcDescriptionField } from './NpcDescriptionField';
import { NpcEditModal } from './NpcEditModal'; import { NpcEditModal } from './NpcEditModal';
@@ -735,11 +737,27 @@ export function NpcsEditorApp() {
<div> <div>
<div className={styles.fieldLabel}>{t('npcs.avatar')}</div> <div className={styles.fieldLabel}>{t('npcs.avatar')}</div>
<div className={styles.avatarPick}> <div className={styles.avatarPick}>
<div className={styles.avatarPreview}> <PlayerTokenView
{selectedUrl ? ( name={selected.name}
<img className={styles.avatarPreviewImg} src={selectedUrl} alt="" /> imageUrl={selectedUrl}
) : null} ringColor={npcDispositionRingColor(selected.disposition ?? 'neutral')}
</div> imageOffset={selected.imageOffset}
imageScale={selected.imageScale}
sizePx={140}
panEnabled
onImageOffsetChange={(imageOffset) => {
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
imageOffset,
});
}}
onImageScaleChange={(imageScale) => {
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
imageScale,
});
}}
/>
<Button <Button
disabled={avatarBusy} disabled={avatarBusy}
onClick={() => { onClick={() => {
@@ -764,6 +782,25 @@ export function NpcsEditorApp() {
</div> </div>
</div> </div>
<label>
<div className={styles.fieldLabel}>{t('npcs.disposition')}</div>
<Select
value={selected.disposition ?? 'neutral'}
ariaLabel={t('npcs.disposition')}
onChange={(next) => {
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
disposition: next as 'hostile' | 'neutral' | 'friendly',
});
}}
options={[
{ value: 'hostile', label: t('npcs.disposition.hostile') },
{ value: 'neutral', label: t('npcs.disposition.neutral') },
{ value: 'friendly', label: t('npcs.disposition.friendly') },
]}
/>
</label>
<div> <div>
<div className={styles.fieldLabel}>{t('npcs.name')}</div> <div className={styles.fieldLabel}>{t('npcs.name')}</div>
<Input <Input
@@ -287,6 +287,34 @@
cursor: grabbing; cursor: grabbing;
} }
.npcTile {
display: grid;
justify-content: center;
overflow: hidden;
padding: 7px 4px;
border: 1px solid var(--stroke, #2a2f3a);
border-radius: 8px;
background: rgb(0 0 0 / 22%);
cursor: grab;
}
.sceneNpcToken {
position: absolute;
z-index: 2;
overflow: visible;
transform: translate(-50%, -50%);
cursor: move;
touch-action: none;
}
.sceneNpcToken > * {
pointer-events: none;
}
.sceneNpcToken .handle {
pointer-events: auto;
}
.tokenThumb { .tokenThumb {
aspect-ratio: 1; aspect-ratio: 1;
border-radius: 6px; border-radius: 6px;
@@ -452,3 +480,49 @@
.toolbar button { .toolbar button {
font-size: 12px; font-size: 12px;
} }
.ctxMenuBackdrop {
position: fixed;
inset: 0;
z-index: 40;
border: none;
padding: 0;
margin: 0;
background: transparent;
cursor: default;
}
.ctxMenu {
position: fixed;
z-index: 41;
min-width: 200px;
padding: 6px;
border-radius: 8px;
border: 1px solid var(--stroke, #2a2f3a);
background: var(--color-surface-menu, #1a1e28);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
display: grid;
gap: 2px;
}
.ctxItem,
.ctxItemDanger {
text-align: left;
padding: 8px 10px;
border-radius: 6px;
border: none;
background: transparent;
color: var(--text1, #e8eaef);
font-size: 13px;
cursor: pointer;
width: 100%;
}
.ctxItemDanger {
color: var(--color-danger, #e57373);
}
.ctxItem:hover,
.ctxItemDanger:hover {
background: rgba(255, 255, 255, 0.06);
}
+375 -17
View File
@@ -2,7 +2,27 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts'; import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import type { SceneGrid, SceneToken, SceneTrap, SceneTrapType, TokenId } from '../../shared/types'; import type {
NpcDisposition,
NpcId,
ProjectNpc,
SceneGrid,
SceneNpcToken,
SceneToken,
SceneTrap,
SceneTrapType,
TokenId,
} from '../../shared/types';
import {
asSceneNpcTokenId,
clampSceneNpcTokenSizeN,
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
} from '../../shared/types/appPlayers';
import {
normalizeNpcDisposition,
npcDispositionRingColor,
otherNpcDispositions,
} from '../../shared/types/npcDisposition';
import { import {
asSceneTokenId, asSceneTokenId,
asTokenId, asTokenId,
@@ -14,6 +34,7 @@ import {
DEFAULT_SCENE_GRID, DEFAULT_SCENE_GRID,
SCENE_GRID_SIZE_MAX, SCENE_GRID_SIZE_MAX,
SCENE_GRID_SIZE_MIN, SCENE_GRID_SIZE_MIN,
sceneGridTokenFitFactor,
sceneGridTypeLabelRu, sceneGridTypeLabelRu,
} from '../../shared/types/sceneGrid'; } from '../../shared/types/sceneGrid';
import { import {
@@ -24,21 +45,28 @@ import {
} from '../../shared/types/sceneTraps'; } from '../../shared/types/sceneTraps';
import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView'; import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
import editorStyles from '../editor/EditorApp.module.css'; import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi'; import { getDndApi } from '../shared/dndApi';
import { EllipsisText } from '../shared/ui/EllipsisText'; import { EllipsisText } from '../shared/ui/EllipsisText';
import ellipsisStyles from '../shared/ui/ellipsisText.module.css'; import ellipsisStyles from '../shared/ui/ellipsisText.module.css';
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay'; import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
import { RotatedImage } from '../shared/RotatedImage'; import { RotatedImage } from '../shared/RotatedImage';
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 { SceneTokenMarker } from './SceneTokenMarker';
import styles from './SceneEditorApp.module.css'; import styles from './SceneEditorApp.module.css';
import { SceneTokenMarker } from './SceneTokenMarker';
import { TokenEditModal } from './TokenEditModal'; import { TokenEditModal } from './TokenEditModal';
import { TOKEN_DND_MIME, TokenTile } from './TokenTile'; import { TOKEN_DND_MIME, TokenTile } from './TokenTile';
function dispositionMakeKey(d: NpcDisposition): string {
if (d === 'hostile') return 'npcs.makeHostile';
if (d === 'friendly') return 'npcs.makeFriendly';
return 'npcs.makeNeutral';
}
function isTypingTarget(el: EventTarget | null): boolean { function isTypingTarget(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false; if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName; const tag = el.tagName;
@@ -47,14 +75,37 @@ function isTypingTarget(el: EventTarget | null): boolean {
} }
type LocalView = { scale: number; ox: number; oy: number }; type LocalView = { scale: number; ox: number; oy: number };
type Selection = { kind: 'trap' | 'token'; id: string } | null; type Selection = { kind: 'trap' | 'token' | 'npcToken'; id: string } | null;
type DragMode = type DragMode =
| { kind: 'pan'; lastX: number; lastY: number } | { kind: 'pan'; lastX: number; lastY: number }
| { kind: 'moveTrap'; trapId: string; startNx: number; startNy: number; pointerNx: number; pointerNy: number } | {
kind: 'moveTrap';
trapId: string;
startNx: number;
startNy: number;
pointerNx: number;
pointerNy: number;
}
| { kind: 'resizeTrap'; trapId: string; startSize: number; startDist: number } | { kind: 'resizeTrap'; trapId: string; startSize: number; startDist: number }
| { kind: 'moveToken'; tokenId: string; startNx: number; startNy: number; pointerNx: number; pointerNy: number } | {
kind: 'moveToken';
tokenId: string;
startNx: number;
startNy: number;
pointerNx: number;
pointerNy: number;
}
| { kind: 'resizeToken'; tokenId: string; startSize: number; startDist: number } | { kind: 'resizeToken'; tokenId: string; startSize: number; startDist: number }
| {
kind: 'moveNpcToken';
tokenId: string;
startNx: number;
startNy: number;
pointerNx: number;
pointerNy: number;
}
| { kind: 'resizeNpcToken'; tokenId: string; startSize: number; startDist: number }
| { | {
kind: 'rotateToken'; kind: 'rotateToken';
tokenId: string; tokenId: string;
@@ -80,27 +131,116 @@ function shortestAngleDelta(fromDeg: number, toDeg: number): number {
return d; return d;
} }
const SCENE_NPC_DND_MIME = 'application/x-dnd-scene-npc-id';
function SceneNpcMarker({
placement,
npc,
left,
top,
sizePx,
selected,
onSelect,
onContextMenu,
onMovePointerDown,
onResizePointerDown,
}: {
placement: SceneNpcToken;
npc: ProjectNpc;
left: number;
top: number;
sizePx: number;
selected: boolean;
onSelect: () => void;
onContextMenu: (e: React.MouseEvent) => void;
onMovePointerDown: (e: React.PointerEvent) => void;
onResizePointerDown: (e: React.PointerEvent) => void;
}) {
const imageUrl = useAssetUrl(npc.avatarAssetId);
const disposition = normalizeNpcDisposition(placement.disposition ?? npc.disposition);
return (
<div
data-testid={`scene-npc-token-${placement.id}`}
className={[styles.sceneNpcToken, selected ? styles.sceneTokenSelected : ''].filter(Boolean).join(' ')}
style={{ left, top, width: sizePx, height: sizePx }}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e);
}}
onPointerDown={(e) => {
if (e.button !== 0) return;
onSelect();
onMovePointerDown(e);
}}
>
<PlayerTokenView
name={npc.name}
imageUrl={imageUrl}
ringColor={npcDispositionRingColor(disposition)}
imageOffset={npc.imageOffset}
imageScale={npc.imageScale}
sizePx={sizePx}
/>
{selected ? <div className={styles.handle} onPointerDown={onResizePointerDown} /> : null}
</div>
);
}
function NpcPaletteTile({ npc }: { npc: ProjectNpc }) {
const imageUrl = useAssetUrl(npc.avatarAssetId);
const disposition = normalizeNpcDisposition(npc.disposition);
return (
<div
className={styles.npcTile}
data-testid={`scene-npc-tile-${npc.id}`}
draggable
onDragStart={(e) => {
e.dataTransfer.setData(SCENE_NPC_DND_MIME, npc.id);
e.dataTransfer.effectAllowed = 'copy';
}}
title={npc.name}
>
<PlayerTokenView
name={npc.name}
imageUrl={imageUrl}
ringColor={npcDispositionRingColor(disposition)}
imageOffset={npc.imageOffset}
imageScale={npc.imageScale}
sizePx={70}
/>
</div>
);
}
export function SceneEditorApp() { export function SceneEditorApp() {
const api = getDndApi(); const api = getDndApi();
const { t } = useEditorI18n();
const appTokens = useAppTokens(); const appTokens = useAppTokens();
const [session, setSession] = useState<SessionState | null>(null); const [session, setSession] = useState<SessionState | null>(null);
const [trapsOpen, setTrapsOpen] = useState(false); const [trapsOpen, setTrapsOpen] = useState(false);
const [gridOpen, setGridOpen] = useState(false); const [gridOpen, setGridOpen] = useState(false);
const [tokensOpen, setTokensOpen] = useState(false); const [tokensOpen, setTokensOpen] = useState(false);
const [npcsOpen, setNpcsOpen] = useState(false);
const [tokenSearch, setTokenSearch] = useState(''); const [tokenSearch, setTokenSearch] = useState('');
const [tokenModal, setTokenModal] = useState<{ mode: 'create' } | { mode: 'edit'; tokenId: TokenId } | null>( const [npcSearch, setNpcSearch] = useState('');
null, const [tokenModal, setTokenModal] = useState<
); { mode: 'create' } | { mode: 'edit'; tokenId: TokenId } | null
>(null);
const [pendingDeleteToken, setPendingDeleteToken] = useState<{ id: TokenId; name: string } | null>(null); const [pendingDeleteToken, setPendingDeleteToken] = useState<{ id: TokenId; name: string } | null>(null);
const [npcCtxMenu, setNpcCtxMenu] = 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>( const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
null,
);
const hostRef = useRef<HTMLDivElement | null>(null); const hostRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<DragMode>(null); const dragRef = useRef<DragMode>(null);
const saveTrapsTimerRef = useRef(0); const saveTrapsTimerRef = useRef(0);
const saveTokensTimerRef = useRef(0); const saveTokensTimerRef = useRef(0);
const saveNpcTokensTimerRef = useRef(0);
const saveGridTimerRef = useRef(0); const saveGridTimerRef = useRef(0);
const spaceDownRef = useRef(false); const spaceDownRef = useRef(false);
@@ -111,15 +251,24 @@ export function SceneEditorApp() {
const rot = scene?.previewRotationDeg ?? 0; const rot = scene?.previewRotationDeg ?? 0;
const [localTraps, setLocalTraps] = useState<SceneTrap[]>([]); const [localTraps, setLocalTraps] = useState<SceneTrap[]>([]);
const [localTokens, setLocalTokens] = useState<SceneToken[]>([]); const [localTokens, setLocalTokens] = useState<SceneToken[]>([]);
const [localNpcTokens, setLocalNpcTokens] = useState<SceneNpcToken[]>([]);
const [localGrid, setLocalGrid] = useState<SceneGrid>({ ...DEFAULT_SCENE_GRID }); const [localGrid, setLocalGrid] = useState<SceneGrid>({ ...DEFAULT_SCENE_GRID });
const trapsRef = useRef<SceneTrap[]>([]); const trapsRef = useRef<SceneTrap[]>([]);
const tokensRef = useRef<SceneToken[]>([]); const tokensRef = useRef<SceneToken[]>([]);
const npcTokensRef = useRef<SceneNpcToken[]>([]);
useEffect(() => {
trapsRef.current = localTraps; trapsRef.current = localTraps;
tokensRef.current = localTokens; tokensRef.current = localTokens;
npcTokensRef.current = localNpcTokens;
}, [localNpcTokens, localTokens, localTraps]);
useEffect(() => { useEffect(() => {
setLocalTraps(scene?.traps ?? []); setLocalTraps(scene?.traps ?? []);
setLocalTokens((scene?.tokens ?? []).filter((t) => appTokens.some((a) => a.id === t.tokenId))); setLocalTokens((scene?.tokens ?? []).filter((t) => appTokens.some((a) => a.id === t.tokenId)));
setLocalNpcTokens(
(scene?.npcTokens ?? []).filter((t) => project?.npcs.some((npc) => npc.id === t.npcId)),
);
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID }); setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
setSelected(null); setSelected(null);
setView({ scale: 1, ox: 0.5, oy: 0.5 }); setView({ scale: 1, ox: 0.5, oy: 0.5 });
@@ -136,6 +285,12 @@ export function SceneEditorApp() {
setLocalTokens((scene?.tokens ?? []).filter((t) => known.has(t.tokenId))); setLocalTokens((scene?.tokens ?? []).filter((t) => known.has(t.tokenId)));
}, [scene?.tokens, appTokens]); }, [scene?.tokens, appTokens]);
useEffect(() => {
if (dragRef.current) return;
const known = new Set((project?.npcs ?? []).map((npc) => npc.id));
setLocalNpcTokens((scene?.npcTokens ?? []).filter((t) => known.has(t.npcId)));
}, [scene?.npcTokens, project?.npcs]);
useEffect(() => { useEffect(() => {
if (dragRef.current) return; if (dragRef.current) return;
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID }); setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
@@ -176,6 +331,19 @@ export function SceneEditorApp() {
[api, sceneId], [api, sceneId],
); );
const persistNpcTokens = useCallback(
(next: SceneNpcToken[]) => {
if (!sceneId) return;
setLocalNpcTokens(next);
npcTokensRef.current = next;
if (saveNpcTokensTimerRef.current) window.clearTimeout(saveNpcTokensTimerRef.current);
saveNpcTokensTimerRef.current = window.setTimeout(() => {
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { npcTokens: next } });
}, 120);
},
[api, sceneId],
);
const persistGrid = useCallback( const persistGrid = useCallback(
(next: SceneGrid) => { (next: SceneGrid) => {
if (!sceneId) return; if (!sceneId) return;
@@ -196,8 +364,10 @@ export function SceneEditorApp() {
e.preventDefault(); e.preventDefault();
if (selected.kind === 'trap') { if (selected.kind === 'trap') {
persistTraps(trapsRef.current.filter((t) => t.id !== selected.id)); persistTraps(trapsRef.current.filter((t) => t.id !== selected.id));
} else { } else if (selected.kind === 'token') {
persistTokens(tokensRef.current.filter((t) => t.id !== selected.id)); persistTokens(tokensRef.current.filter((t) => t.id !== selected.id));
} else {
persistNpcTokens(npcTokensRef.current.filter((t) => t.id !== selected.id));
} }
setSelected(null); setSelected(null);
} }
@@ -212,7 +382,7 @@ export function SceneEditorApp() {
window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp); window.removeEventListener('keyup', onKeyUp);
}; };
}, [persistTraps, persistTokens, sceneId, selected]); }, [persistNpcTokens, persistTraps, persistTokens, sceneId, selected]);
const hostToNorm = (clientX: number, clientY: number): { x: number; y: number } | null => { const hostToNorm = (clientX: number, clientY: number): { x: number; y: number } | null => {
const host = hostRef.current; const host = hostRef.current;
@@ -286,6 +456,24 @@ export function SceneEditorApp() {
persistTokens([...tokensRef.current, placement]); persistTokens([...tokensRef.current, placement]);
}; };
const addNpcTokenAt = (npcId: NpcId, nx: number, ny: number) => {
const gridCellSize =
localGrid.enabled && Number.isFinite(localGrid.sizeN)
? clampSceneNpcTokenSizeN(localGrid.sizeN)
: DEFAULT_SCENE_NPC_TOKEN_SIZE_N;
const npc = project?.npcs.find((item) => item.id === npcId);
const placement: SceneNpcToken = {
id: asSceneNpcTokenId(randomId('snpc')),
npcId,
nx,
ny,
sizeN: gridCellSize,
disposition: normalizeNpcDisposition(npc?.disposition),
};
setSelected({ kind: 'npcToken', id: placement.id });
persistNpcTokens([...npcTokensRef.current, placement]);
};
const onStageDrop = (e: React.DragEvent) => { const onStageDrop = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
const p = hostToNorm(e.clientX, e.clientY); const p = hostToNorm(e.clientX, e.clientY);
@@ -295,6 +483,11 @@ export function SceneEditorApp() {
addTokenAt(asTokenId(tokenId), p.x, p.y); addTokenAt(asTokenId(tokenId), p.x, p.y);
return; return;
} }
const npcId = e.dataTransfer.getData(SCENE_NPC_DND_MIME);
if (npcId) {
addNpcTokenAt(npcId as NpcId, p.x, p.y);
return;
}
const type = e.dataTransfer.getData('application/x-dnd-trap-type') as SceneTrapType; const type = e.dataTransfer.getData('application/x-dnd-trap-type') as SceneTrapType;
if (!SCENE_TRAP_TYPES.includes(type)) return; if (!SCENE_TRAP_TYPES.includes(type)) return;
addTrapAt(type, p.x, p.y); addTrapAt(type, p.x, p.y);
@@ -308,13 +501,24 @@ export function SceneEditorApp() {
persistTokens(tokensRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t))); persistTokens(tokensRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t)));
}; };
const updateNpcToken = (id: string, patch: Partial<SceneNpcToken>) => {
persistNpcTokens(npcTokensRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t)));
};
const filteredTokens = useMemo(() => { const filteredTokens = useMemo(() => {
const q = tokenSearch.trim().toLowerCase(); const q = tokenSearch.trim().toLowerCase();
if (!q) return appTokens; if (!q) return appTokens;
return appTokens.filter((t) => t.name.toLowerCase().includes(q)); return appTokens.filter((t) => t.name.toLowerCase().includes(q));
}, [appTokens, tokenSearch]); }, [appTokens, tokenSearch]);
const editingToken = tokenModal?.mode === 'edit' ? appTokens.find((t) => t.id === tokenModal.tokenId) ?? null : null; const filteredNpcs = useMemo(() => {
const q = npcSearch.trim().toLowerCase();
const npcs = project?.npcs ?? [];
return q ? npcs.filter((npc) => npc.name.toLowerCase().includes(q)) : npcs;
}, [npcSearch, project?.npcs]);
const editingToken =
tokenModal?.mode === 'edit' ? (appTokens.find((t) => t.id === tokenModal.tokenId) ?? null) : null;
const isImage = scene?.previewAssetType === 'image' && Boolean(url); const isImage = scene?.previewAssetType === 'image' && Boolean(url);
return ( return (
@@ -396,6 +600,28 @@ export function SceneEditorApp() {
) : null} ) : null}
</div> </div>
<div className={styles.accordion} data-testid="scene-npc-accordion">
<button type="button" className={styles.accordionHead} onClick={() => setNpcsOpen((v) => !v)}>
НПС {npcsOpen ? '▾' : '▸'}
</button>
{npcsOpen ? (
<div className={styles.tokensPanel}>
<Input
value={npcSearch}
onChange={setNpcSearch}
placeholder="Поиск НПС…"
onKeyDown={(e) => e.stopPropagation()}
/>
<div className={styles.tokenGrid}>
{filteredNpcs.map((npc) => (
<NpcPaletteTile key={npc.id} npc={npc} />
))}
{filteredNpcs.length === 0 ? <div className={styles.hint}>Нет НПС</div> : null}
</div>
</div>
) : null}
</div>
<div className={styles.accordion}> <div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setTokensOpen((v) => !v)}> <button type="button" className={styles.accordionHead} onClick={() => setTokensOpen((v) => !v)}>
Неигровые токены {tokensOpen ? '▾' : '▸'} Неигровые токены {tokensOpen ? '▾' : '▸'}
@@ -409,7 +635,6 @@ export function SceneEditorApp() {
value={tokenSearch} value={tokenSearch}
onChange={setTokenSearch} onChange={setTokenSearch}
placeholder="Поиск…" placeholder="Поиск…"
autoFocus={tokensOpen}
onKeyDown={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}
/> />
<div className={styles.tokenGrid}> <div className={styles.tokenGrid}>
@@ -457,10 +682,14 @@ export function SceneEditorApp() {
<div className={styles.clearSceneBtn}> <div className={styles.clearSceneBtn}>
<Button <Button
disabled={!sceneId || (localTraps.length === 0 && localTokens.length === 0)} data-testid="scene-clear-btn"
disabled={
!sceneId || (localTraps.length === 0 && localTokens.length === 0 && localNpcTokens.length === 0)
}
onClick={() => { onClick={() => {
persistTraps([]); persistTraps([]);
persistTokens([]); persistTokens([]);
persistNpcTokens([]);
setSelected(null); setSelected(null);
}} }}
> >
@@ -543,6 +772,26 @@ export function SceneEditorApp() {
}); });
return; return;
} }
if (d.kind === 'moveNpcToken') {
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
updateNpcToken(d.tokenId, {
nx: Math.max(0, Math.min(1, d.startNx + (p.x - d.pointerNx))),
ny: Math.max(0, Math.min(1, d.startNy + (p.y - d.pointerNy))),
});
return;
}
if (d.kind === 'resizeNpcToken') {
const p = hostToNorm(e.clientX, e.clientY);
const tok = npcTokensRef.current.find((t) => t.id === d.tokenId);
if (!p || !tok) return;
const dist = Math.hypot(p.x - tok.nx, p.y - tok.ny);
const ratio = d.startDist > 1e-6 ? dist / d.startDist : 1;
updateNpcToken(d.tokenId, {
sizeN: clampSceneNpcTokenSizeN(d.startSize * ratio),
});
return;
}
if (d.kind === 'rotateToken') { if (d.kind === 'rotateToken') {
const ang = pointerAngleDeg(d.centerClientX, d.centerClientY, e.clientX, e.clientY); const ang = pointerAngleDeg(d.centerClientX, d.centerClientY, e.clientX, e.clientY);
const delta = shortestAngleDelta(d.startPointerAngle, ang); const delta = shortestAngleDelta(d.startPointerAngle, ang);
@@ -635,6 +884,60 @@ export function SceneEditorApp() {
); );
}) })
: null} : null}
{contentRect
? localNpcTokens.map((tok) => {
const npc = project?.npcs.find((item) => item.id === tok.npcId);
if (!npc) return null;
const minDim = Math.min(contentRect.w, contentRect.h);
const sizePx = Math.max(16, tok.sizeN * sceneGridTokenFitFactor(localGrid) * minDim);
const left = contentRect.x + tok.nx * contentRect.w;
const top = contentRect.y + tok.ny * contentRect.h;
return (
<SceneNpcMarker
key={tok.id}
placement={tok}
npc={npc}
left={left}
top={top}
sizePx={sizePx}
selected={selected?.kind === 'npcToken' && selected.id === tok.id}
onSelect={() => setSelected({ kind: 'npcToken', id: tok.id })}
onContextMenu={(e) => {
setSelected({ kind: 'npcToken', id: tok.id });
setNpcCtxMenu({ x: e.clientX, y: e.clientY, placementId: tok.id });
}}
onMovePointerDown={(e) => {
if (spaceDownRef.current) return;
e.stopPropagation();
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = {
kind: 'moveNpcToken',
tokenId: tok.id,
startNx: tok.nx,
startNy: tok.ny,
pointerNx: p.x,
pointerNy: p.y,
};
}}
onResizePointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = {
kind: 'resizeNpcToken',
tokenId: tok.id,
startSize: tok.sizeN,
startDist: Math.max(1e-4, Math.hypot(p.x - tok.nx, p.y - tok.ny)),
};
}}
/>
);
})
: null}
{contentRect {contentRect
? localTraps.map((trap) => { ? localTraps.map((trap) => {
const minDim = Math.min(contentRect.w, contentRect.h); const minDim = Math.min(contentRect.w, contentRect.h);
@@ -645,7 +948,9 @@ export function SceneEditorApp() {
return ( return (
<div <div
key={trap.id} key={trap.id}
className={[styles.trap, isSelected ? styles.trapSelected : ''].filter(Boolean).join(' ')} className={[styles.trap, isSelected ? styles.trapSelected : '']
.filter(Boolean)
.join(' ')}
style={{ left, top, width: sizePx, height: sizePx }} style={{ left, top, width: sizePx, height: sizePx }}
onPointerDown={(e) => { onPointerDown={(e) => {
if (e.button !== 0 || spaceDownRef.current) return; if (e.button !== 0 || spaceDownRef.current) return;
@@ -744,6 +1049,59 @@ export function SceneEditorApp() {
document.body, document.body,
) )
: null} : null}
{npcCtxMenu
? createPortal(
<>
<button
type="button"
className={styles.ctxMenuBackdrop}
aria-label={t('common.close')}
onClick={() => setNpcCtxMenu(null)}
/>
<div
className={styles.ctxMenu}
style={{ left: npcCtxMenu.x, top: npcCtxMenu.y }}
role="menu"
>
<button
type="button"
className={styles.ctxItemDanger}
role="menuitem"
onClick={() => {
const id = npcCtxMenu.placementId;
setNpcCtxMenu(null);
persistNpcTokens(npcTokensRef.current.filter((item) => item.id !== id));
setSelected((current) => (current?.id === id ? null : current));
}}
>
{t('common.delete')}
</button>
{(() => {
const tok = localNpcTokens.find((item) => item.id === npcCtxMenu.placementId);
const npc = tok ? project?.npcs.find((item) => item.id === tok.npcId) : undefined;
if (!tok || !npc) return null;
const current = normalizeNpcDisposition(tok.disposition ?? npc.disposition);
return otherNpcDispositions(current).map((d) => (
<button
key={d}
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
updateNpcToken(tok.id, { disposition: d });
setNpcCtxMenu(null);
}}
>
{t(dispositionMakeKey(d))}
</button>
));
})()}
</div>
</>,
document.body,
)
: null}
</div> </div>
); );
} }
+27
View File
@@ -15,6 +15,11 @@ import { MaterialOverlay } from './materials/MaterialOverlay';
import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState'; import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay'; import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState'; import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
import { SceneNpcTokensOverlay } from './playerToken/SceneNpcTokensOverlay';
import { ScenePlayerTokensOverlay } from './playerToken/ScenePlayerTokensOverlay';
import { useAppPlayers } from './playerToken/useAppPlayers';
import { useSceneNpcTokensSession } from './playerToken/useSceneNpcTokensSession';
import { useScenePlayerTokensSession } from './playerToken/useScenePlayerTokensSession';
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';
@@ -26,6 +31,7 @@ import styles from './PresentationView.module.css';
import { RotatedImage } from './RotatedImage'; import { RotatedImage } from './RotatedImage';
import { useAssetUrl } from './useAssetImageUrl'; import { useAssetUrl } from './useAssetImageUrl';
import { useVideoPlaybackState } from './video/useVideoPlaybackState'; import { useVideoPlaybackState } from './video/useVideoPlaybackState';
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../shared/types/appPlayers';
export type PresentationViewProps = { export type PresentationViewProps = {
session: SessionState | null; session: SessionState | null;
@@ -48,7 +54,10 @@ export function PresentationView({
const [materialsOverlay] = useMaterialsOverlayState(); const [materialsOverlay] = useMaterialsOverlayState();
const [npcsOverlay] = useNpcsOverlayState(); const [npcsOverlay] = useNpcsOverlayState();
const appTokens = useAppTokens(); const appTokens = useAppTokens();
const { players: appPlayers } = useAppPlayers();
const [sceneTokensSession] = useSceneTokensSession(); const [sceneTokensSession] = useSceneTokensSession();
const [sceneNpcTokensSession] = useSceneNpcTokensSession();
const [scenePlayerTokensSession] = useScenePlayerTokensSession();
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>(
@@ -187,6 +196,24 @@ export function PresentationView({
viewport={contentRect} viewport={contentRect}
/> />
) : null} ) : null}
{scene?.previewAssetType === 'image' && contentRect ? (
<SceneNpcTokensOverlay
placements={scene.npcTokens ?? []}
library={project?.npcs ?? []}
session={sceneNpcTokensSession}
viewport={contentRect}
grid={scene.grid}
/>
) : null}
{scene?.previewAssetType === 'image' && contentRect ? (
<ScenePlayerTokensOverlay
library={appPlayers}
session={scenePlayerTokensSession}
displayScale={sceneNpcTokensSession.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE}
viewport={contentRect}
grid={scene.grid}
/>
) : null}
{scene?.previewAssetType === 'image' && contentRect ? ( {scene?.previewAssetType === 'image' && contentRect ? (
<SceneTrapsOverlay <SceneTrapsOverlay
traps={scene.traps ?? []} traps={scene.traps ?? []}
@@ -0,0 +1,63 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
user-select: none;
}
.circle {
position: relative;
border-radius: 50%;
border: 4px solid #c9a227;
overflow: hidden;
background: #18181b;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.45);
flex-shrink: 0;
}
.circlePan {
cursor: grab;
touch-action: none;
}
.circlePan:active {
cursor: grabbing;
}
.avatar {
position: absolute;
left: 50%;
top: 50%;
width: 140%;
height: 140%;
object-fit: cover;
pointer-events: none;
}
.avatarEmpty {
position: absolute;
inset: 0;
background: repeating-linear-gradient(
-45deg,
#27272a,
#27272a 6px,
#3f3f46 6px,
#3f3f46 12px
);
}
.namePlate {
max-width: 100%;
padding: 4px 10px;
border-radius: 6px;
background: rgba(9, 9, 11, 0.88);
color: #fafafa;
font-size: 12px;
font-weight: 600;
line-height: 1.3;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -0,0 +1,163 @@
import React, { useCallback, useEffect, useRef } from 'react';
import type { PlayerImageOffset } from '../../../shared/types/appPlayers';
import {
clampPlayerImageOffset,
clampPlayerImageScale,
DEFAULT_PLAYER_IMAGE_SCALE,
DEFAULT_PLAYER_RING_COLOR,
PLAYER_IMAGE_SCALE_STEP,
} from '../../../shared/types/appPlayers';
import styles from './PlayerTokenView.module.css';
export type PlayerTokenViewProps = {
name: string;
imageUrl: string | null;
ringColor?: string;
imageOffset?: PlayerImageOffset;
imageScale?: number;
/** Размер круга в px (для превью в модалке). На сцене задавайте через CSS width/height родителя. */
sizePx?: number;
/** Разрешить drag-pan аватара внутри круга и zoom колесом. */
panEnabled?: boolean;
onImageOffsetChange?: (offset: PlayerImageOffset) => void;
onImageScaleChange?: (scale: number) => void;
className?: string;
/** Без подписи имени (компактный маркер). */
hideName?: boolean;
/** Session «Неактивен»: серый фильтр аватара. */
inactive?: boolean;
'data-testid'?: string;
};
export function PlayerTokenView({
name,
imageUrl,
ringColor = DEFAULT_PLAYER_RING_COLOR,
imageOffset,
imageScale,
sizePx = 160,
panEnabled = false,
onImageOffsetChange,
onImageScaleChange,
className,
hideName = false,
inactive = false,
'data-testid': testId,
}: PlayerTokenViewProps) {
const offset = clampPlayerImageOffset(imageOffset);
const scale = clampPlayerImageScale(imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE);
const circleRef = useRef<HTMLDivElement | null>(null);
const scaleRef = useRef(scale);
scaleRef.current = scale;
const onImageScaleChangeRef = useRef(onImageScaleChange);
onImageScaleChangeRef.current = onImageScaleChange;
const dragRef = useRef<{
pointerId: number;
startX: number;
startY: number;
origX: number;
origY: number;
} | null>(null);
const onPointerDown = useCallback(
(e: React.PointerEvent) => {
if (!panEnabled || !onImageOffsetChange) return;
e.preventDefault();
e.stopPropagation();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
dragRef.current = {
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
origX: offset.x,
origY: offset.y,
};
},
[offset.x, offset.y, onImageOffsetChange, panEnabled],
);
const onPointerMove = useCallback(
(e: React.PointerEvent) => {
const d = dragRef.current;
if (!d || d.pointerId !== e.pointerId || !onImageOffsetChange) return;
const dx = (e.clientX - d.startX) / Math.max(1, sizePx);
const dy = (e.clientY - d.startY) / Math.max(1, sizePx);
onImageOffsetChange(clampPlayerImageOffset({ x: d.origX + dx, y: d.origY + dy }));
},
[onImageOffsetChange, sizePx],
);
const onPointerUp = useCallback((e: React.PointerEvent) => {
const d = dragRef.current;
if (!d || d.pointerId !== e.pointerId) return;
dragRef.current = null;
try {
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
}, []);
useEffect(() => {
const el = circleRef.current;
if (!el || !panEnabled) return;
const onWheel = (e: WheelEvent) => {
const cb = onImageScaleChangeRef.current;
if (!cb) return;
e.preventDefault();
e.stopPropagation();
const direction = e.deltaY < 0 ? 1 : -1;
cb(clampPlayerImageScale(scaleRef.current + direction * PLAYER_IMAGE_SCALE_STEP));
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [panEnabled]);
const rootClass = [styles.root, className ?? ''].filter(Boolean).join(' ');
const nameFontPx = Math.max(9, Math.min(13, Math.round(sizePx * 0.16)));
const nameGapPx = Math.max(2, Math.min(8, Math.round(sizePx * 0.06)));
return (
<div
className={rootClass}
style={{ width: sizePx, gap: hideName ? undefined : nameGapPx }}
data-testid={testId}
>
<div
ref={circleRef}
className={[styles.circle, panEnabled ? styles.circlePan : ''].filter(Boolean).join(' ')}
style={{
width: sizePx,
height: sizePx,
borderColor: inactive ? '#9ca3af' : ringColor,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
{imageUrl ? (
<img
className={styles.avatar}
src={imageUrl}
alt=""
draggable={false}
style={{
transform: `translate(-50%, -50%) translate(${String(offset.x * 100)}%, ${String(offset.y * 100)}%) scale(${String(scale)})`,
...(inactive ? { filter: 'grayscale(1)', opacity: 0.85 } : {}),
}}
/>
) : (
<div className={styles.avatarEmpty} />
)}
</div>
{!hideName ? (
<div className={styles.namePlate} title={name} style={{ fontSize: nameFontPx }}>
{name || '—'}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,27 @@
.layer {
position: absolute;
inset: 0;
z-index: 9;
pointer-events: none;
}
.token {
position: absolute;
overflow: visible;
transform: translate(-50%, -50%);
pointer-events: none;
touch-action: none;
}
.token > * {
pointer-events: none;
}
.editable {
pointer-events: auto;
cursor: grab;
}
.editable:active {
cursor: grabbing;
}
@@ -0,0 +1,213 @@
import React, { useRef, useState } from 'react';
import type {
NpcDisposition,
ProjectNpc,
SceneGrid,
SceneNpcToken,
SceneNpcTokensSessionState,
} from '../../../shared/types';
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../../shared/types/appPlayers';
import {
normalizeNpcDisposition,
npcDispositionRingColor,
} from '../../../shared/types/npcDisposition';
import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid';
import { useAssetUrl } from '../useAssetImageUrl';
import { PlayerTokenView } from './PlayerTokenView';
import { useLiveDragBroadcast } from './useLiveDragBroadcast';
import styles from './SceneNpcTokensOverlay.module.css';
type Viewport = { x: number; y: number; w: number; h: number };
export function resolveNpcTokenDisposition(
placement: SceneNpcToken,
npc: ProjectNpc,
sessionDisposition?: NpcDisposition,
): NpcDisposition {
if (sessionDisposition) return normalizeNpcDisposition(sessionDisposition);
return normalizeNpcDisposition(placement.disposition ?? npc.disposition);
}
function NpcSprite({
placement,
npc,
nx,
ny,
viewport,
editable,
displayScale,
gridFit,
disposition,
inactive,
onMove,
onContextMenu,
}: {
placement: SceneNpcToken;
npc: ProjectNpc;
nx: number;
ny: number;
viewport: Viewport;
editable: boolean;
displayScale: number;
gridFit: number;
disposition: NpcDisposition;
inactive: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
}) {
const imageUrl = useAssetUrl(npc.avatarAssetId);
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
const dragRef = useRef<{
pointerId: number;
startNx: number;
startNy: number;
pointerNx: number;
pointerNy: number;
lastNx: number;
lastNy: number;
} | null>(null);
const { schedule, flush } = useLiveDragBroadcast(onMove, String(placement.id));
const pos = localPos ?? { nx, ny };
const minDim = Math.min(viewport.w, viewport.h);
const sizePx = Math.max(16, placement.sizeN * gridFit * displayScale * minDim);
const point = (e: React.PointerEvent) => {
const host = e.currentTarget.parentElement;
const rect = host?.getBoundingClientRect();
return {
x: Math.max(0, Math.min(1, (e.clientX - ((rect?.left ?? 0) + viewport.x)) / Math.max(1, viewport.w))),
y: Math.max(0, Math.min(1, (e.clientY - ((rect?.top ?? 0) + viewport.y)) / Math.max(1, viewport.h))),
};
};
const end = (e: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (drag?.pointerId !== e.pointerId) return;
dragRef.current = null;
flush(drag.lastNx, drag.lastNy);
setLocalPos(null);
};
return (
<div
className={[styles.token, editable ? styles.editable : ''].filter(Boolean).join(' ')}
data-testid={`session-npc-token-${placement.id}`}
style={{
left: viewport.x + pos.nx * viewport.w,
top: viewport.y + pos.ny * viewport.h,
width: sizePx,
height: sizePx,
}}
onContextMenu={
onContextMenu
? (e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, placement);
}
: undefined
}
onPointerDown={
editable && onMove !== undefined
? (e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const p = point(e);
dragRef.current = {
pointerId: e.pointerId,
startNx: pos.nx,
startNy: pos.ny,
pointerNx: p.x,
pointerNy: p.y,
lastNx: pos.nx,
lastNy: pos.ny,
};
e.currentTarget.setPointerCapture(e.pointerId);
}
: undefined
}
onPointerMove={
editable && onMove
? (e) => {
const drag = dragRef.current;
if (drag?.pointerId !== e.pointerId) return;
const p = point(e);
drag.lastNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx));
drag.lastNy = Math.max(0, Math.min(1, drag.startNy + p.y - drag.pointerNy));
setLocalPos({ nx: drag.lastNx, ny: drag.lastNy });
schedule(drag.lastNx, drag.lastNy);
}
: undefined
}
onPointerUp={end}
onPointerCancel={end}
>
<PlayerTokenView
name={npc.name}
imageUrl={imageUrl}
ringColor={npcDispositionRingColor(disposition, inactive)}
imageOffset={npc.imageOffset}
imageScale={npc.imageScale}
sizePx={sizePx}
inactive={inactive}
/>
</div>
);
}
export function SceneNpcTokensOverlay({
placements,
library,
session,
viewport,
grid = null,
editable = false,
onMove,
onContextMenu,
}: {
placements: readonly SceneNpcToken[];
library: readonly ProjectNpc[];
session: SceneNpcTokensSessionState | null;
viewport: Viewport | null;
/** Для гекса — вписанный диаметр ячейки, не описанный. */
grid?: SceneGrid | null;
editable?: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
}) {
if (!viewport) return null;
const byId = new Map(library.map((npc) => [npc.id, npc]));
const displayScale = session?.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE;
const gridFit = sceneGridTokenFitFactor(grid);
return (
<div className={styles.layer}>
{placements.map((placement) => {
const npc = byId.get(placement.npcId);
if (!npc) return null;
const override = session?.byPlacementId[String(placement.id)];
const disposition = resolveNpcTokenDisposition(placement, npc, override?.disposition);
const inactive = Boolean(override?.inactive);
return (
<NpcSprite
key={placement.id}
placement={placement}
npc={npc}
nx={override?.nx ?? placement.nx}
ny={override?.ny ?? placement.ny}
viewport={viewport}
editable={editable}
displayScale={displayScale}
gridFit={gridFit}
disposition={disposition}
inactive={inactive}
{...(onMove ? { onMove } : {})}
{...(onContextMenu ? { onContextMenu } : {})}
/>
);
})}
</div>
);
}
@@ -0,0 +1,174 @@
import React, { useRef, useState } from 'react';
import type {
AppPlayer,
SceneGrid,
ScenePlayerTokensSessionState,
} from '../../../shared/types';
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../../shared/types/appPlayers';
import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid';
import { usePlayerImageUrl } from './usePlayerImageUrl';
import { PlayerTokenView } from './PlayerTokenView';
import { useLiveDragBroadcast } from './useLiveDragBroadcast';
import styles from './SceneNpcTokensOverlay.module.css';
type Viewport = { x: number; y: number; w: number; h: number };
function PlayerSprite({
player,
nx,
ny,
sizeN,
viewport,
editable,
displayScale,
gridFit,
onMove,
}: {
player: AppPlayer;
nx: number;
ny: number;
sizeN: number;
viewport: Viewport;
editable: boolean;
displayScale: number;
gridFit: number;
onMove?: (playerId: string, nx: number, ny: number) => void;
}) {
const imageUrl = usePlayerImageUrl(player.id);
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
const dragRef = useRef<{
pointerId: number;
startNx: number;
startNy: number;
pointerNx: number;
pointerNy: number;
lastNx: number;
lastNy: number;
} | null>(null);
const { schedule, flush } = useLiveDragBroadcast(onMove, String(player.id));
const pos = localPos ?? { nx, ny };
const minDim = Math.min(viewport.w, viewport.h);
const sizePx = Math.max(16, sizeN * gridFit * displayScale * minDim);
const point = (e: React.PointerEvent) => {
const host = e.currentTarget.parentElement;
const rect = host?.getBoundingClientRect();
return {
x: Math.max(0, Math.min(1, (e.clientX - ((rect?.left ?? 0) + viewport.x)) / Math.max(1, viewport.w))),
y: Math.max(0, Math.min(1, (e.clientY - ((rect?.top ?? 0) + viewport.y)) / Math.max(1, viewport.h))),
};
};
const end = (e: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (drag?.pointerId !== e.pointerId) return;
dragRef.current = null;
flush(drag.lastNx, drag.lastNy);
setLocalPos(null);
};
return (
<div
className={[styles.token, editable ? styles.editable : ''].filter(Boolean).join(' ')}
data-testid={`session-player-token-${player.id}`}
style={{
left: viewport.x + pos.nx * viewport.w,
top: viewport.y + pos.ny * viewport.h,
width: sizePx,
height: sizePx,
}}
onPointerDown={
editable && onMove !== undefined
? (e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const p = point(e);
dragRef.current = {
pointerId: e.pointerId,
startNx: pos.nx,
startNy: pos.ny,
pointerNx: p.x,
pointerNy: p.y,
lastNx: pos.nx,
lastNy: pos.ny,
};
e.currentTarget.setPointerCapture(e.pointerId);
}
: undefined
}
onPointerMove={
editable && onMove
? (e) => {
const drag = dragRef.current;
if (drag?.pointerId !== e.pointerId) return;
const p = point(e);
drag.lastNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx));
drag.lastNy = Math.max(0, Math.min(1, drag.startNy + p.y - drag.pointerNy));
setLocalPos({ nx: drag.lastNx, ny: drag.lastNy });
schedule(drag.lastNx, drag.lastNy);
}
: undefined
}
onPointerUp={end}
onPointerCancel={end}
>
<PlayerTokenView
name={player.name}
imageUrl={imageUrl}
ringColor={player.ringColor}
imageOffset={player.imageOffset}
imageScale={player.imageScale}
sizePx={sizePx}
/>
</div>
);
}
export function ScenePlayerTokensOverlay({
library,
session,
displayScale = DEFAULT_NPC_TOKEN_SESSION_SCALE,
viewport,
grid = null,
editable = false,
onMove,
}: {
library: readonly AppPlayer[];
session: ScenePlayerTokensSessionState | null;
/** Общий масштаб с НПС-токенами. */
displayScale?: number;
viewport: Viewport | null;
grid?: SceneGrid | null;
editable?: boolean;
onMove?: (playerId: string, nx: number, ny: number) => void;
}) {
if (!viewport || !session?.visible) return null;
const byId = new Map(library.map((p) => [String(p.id), p]));
const gridFit = sceneGridTokenFitFactor(grid);
return (
<div className={styles.layer}>
{session.selectedPlayerIds.map((playerId) => {
const player = byId.get(playerId);
const placement = session.byPlayerId[playerId];
if (!player || !placement) return null;
return (
<PlayerSprite
key={playerId}
player={player}
nx={placement.nx}
ny={placement.ny}
sizeN={placement.sizeN}
viewport={viewport}
editable={editable}
displayScale={displayScale}
gridFit={gridFit}
{...(onMove ? { onMove } : {})}
/>
);
})}
</div>
);
}
@@ -0,0 +1,24 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { AppPlayer, AppPlayerTeam } from '../../../shared/types';
import { getDndApi } from '../dndApi';
export function useAppPlayers(): { players: AppPlayer[]; teams: AppPlayerTeam[] } {
const api = getDndApi();
const [players, setPlayers] = useState<AppPlayer[]>([]);
const [teams, setTeams] = useState<AppPlayerTeam[]>([]);
useEffect(() => {
void api.invoke(ipcChannels.players.list, {}).then((res) => {
setPlayers(res.players);
setTeams(res.teams);
});
return api.on(ipcChannels.players.stateChanged, ({ players: nextPlayers, teams: nextTeams }) => {
setPlayers(nextPlayers);
setTeams(nextTeams);
});
}, [api]);
return { players, teams };
}
@@ -0,0 +1,48 @@
import { useCallback, useEffect, useRef } from 'react';
/** Во время drag шлёт onMove не чаще одного раза за кадр (для live-синхронизации презентации). */
export function useLiveDragBroadcast(
onMove: ((id: string, nx: number, ny: number) => void) | undefined,
id: string,
): {
schedule: (nx: number, ny: number) => void;
flush: (nx: number, ny: number) => void;
} {
const rafRef = useRef(0);
const pendingRef = useRef<{ nx: number; ny: number } | null>(null);
const onMoveRef = useRef(onMove);
onMoveRef.current = onMove;
const idRef = useRef(id);
idRef.current = id;
useEffect(
() => () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
},
[],
);
const schedule = useCallback((nx: number, ny: number) => {
if (!onMoveRef.current) return;
pendingRef.current = { nx, ny };
if (rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = 0;
const pending = pendingRef.current;
const cb = onMoveRef.current;
if (!pending || !cb) return;
cb(idRef.current, pending.nx, pending.ny);
});
}, []);
const flush = useCallback((nx: number, ny: number) => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = 0;
}
pendingRef.current = null;
onMoveRef.current?.(idRef.current, nx, ny);
}, []);
return { schedule, flush };
}
@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { PlayerId } from '../../../shared/types';
import { getDndApi } from '../dndApi';
export function usePlayerImageUrl(playerId: PlayerId | null | undefined): string | null {
const api = getDndApi();
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (!playerId) {
setUrl(null);
return;
}
let cancelled = false;
void api.invoke(ipcChannels.players.imageUrl, { id: playerId }).then((res) => {
if (!cancelled) setUrl(res.url);
});
return () => {
cancelled = true;
};
}, [api, playerId]);
return url;
}
@@ -0,0 +1,152 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type {
SceneNpcTokensSessionEvent,
SceneNpcTokensSessionPlacement,
SceneNpcTokensSessionState,
} from '../../../shared/types';
import {
clampNpcTokenSessionScale,
DEFAULT_NPC_TOKEN_SESSION_SCALE,
} from '../../../shared/types/appPlayers';
import { normalizeNpcDisposition } from '../../../shared/types/npcDisposition';
import { getDndApi } from '../dndApi';
function withScale(state: SceneNpcTokensSessionState): SceneNpcTokensSessionState {
return {
...state,
scale: clampNpcTokenSessionScale(state.scale),
};
}
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
return p.nx === undefined && p.ny === undefined && !p.disposition && !p.inactive;
}
function applyEvent(
prev: SceneNpcTokensSessionState,
event: SceneNpcTokensSessionEvent,
): SceneNpcTokensSessionState {
if (event.kind === 'clear') {
return {
revision: prev.revision + 1,
byPlacementId: {},
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
};
}
if (event.kind === 'setScale') {
const scale = clampNpcTokenSessionScale(event.scale);
if (prev.scale === scale) return prev;
return { ...prev, revision: prev.revision + 1, scale };
}
if (event.kind === 'setDisposition') {
const placementId = String(event.placementId ?? '');
if (!placementId) return prev;
const disposition = normalizeNpcDisposition(event.disposition);
const cur = prev.byPlacementId[placementId];
if (cur?.disposition === disposition) return prev;
return {
revision: prev.revision + 1,
byPlacementId: {
...prev.byPlacementId,
[placementId]: {
...(cur?.nx !== undefined ? { nx: cur.nx } : {}),
...(cur?.ny !== undefined ? { ny: cur.ny } : {}),
disposition,
...(cur?.inactive ? { inactive: true } : {}),
},
},
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
};
}
if (event.kind === 'setInactive') {
const placementId = String(event.placementId ?? '');
if (!placementId) return prev;
const inactive = Boolean(event.inactive);
const cur = prev.byPlacementId[placementId];
if (Boolean(cur?.inactive) === inactive) return prev;
const next: SceneNpcTokensSessionPlacement = {
...(cur?.nx !== undefined ? { nx: cur.nx } : {}),
...(cur?.ny !== undefined ? { ny: cur.ny } : {}),
...(cur?.disposition ? { disposition: cur.disposition } : {}),
...(inactive ? { inactive: true } : {}),
};
if (isEmptyPlacement(next)) {
const { [placementId]: _removed, ...rest } = prev.byPlacementId;
return {
revision: prev.revision + 1,
byPlacementId: rest,
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
};
}
return {
revision: prev.revision + 1,
byPlacementId: {
...prev.byPlacementId,
[placementId]: next,
},
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
};
}
// move
const placementId = String(event.placementId ?? '');
if (!placementId) return prev;
const cur = prev.byPlacementId[placementId];
return {
revision: prev.revision + 1,
byPlacementId: {
...prev.byPlacementId,
[placementId]: {
nx: event.nx,
ny: event.ny,
...(cur?.disposition ? { disposition: cur.disposition } : {}),
...(cur?.inactive ? { inactive: true } : {}),
},
},
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
};
}
export function useSceneNpcTokensSession(): [
SceneNpcTokensSessionState,
{ dispatch: (event: SceneNpcTokensSessionEvent) => void },
] {
const api = getDndApi();
const [state, setState] = useState<SceneNpcTokensSessionState>({
revision: 0,
byPlacementId: {},
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
});
const localRevRef = useRef(0);
useEffect(() => {
void api.invoke(ipcChannels.sceneNpcTokensSession.getState, {}).then((res) => {
const next = withScale(res.state);
// Не затираем более свежий optimistic/broadcast стейт устаревшим getState.
if (next.revision < localRevRef.current) return;
localRevRef.current = next.revision;
setState(next);
});
return api.on(ipcChannels.sceneNpcTokensSession.stateChanged, ({ state: incoming }) => {
const next = withScale(incoming);
if (next.revision < localRevRef.current) return;
localRevRef.current = next.revision;
setState(next);
});
}, [api]);
const dispatch = useCallback(
(event: SceneNpcTokensSessionEvent) => {
setState((prev) => {
const next = applyEvent(prev, event);
localRevRef.current = Math.max(localRevRef.current, next.revision);
return next;
});
void api.invoke(ipcChannels.sceneNpcTokensSession.dispatch, { event });
},
[api],
);
return [state, { dispatch }];
}
@@ -0,0 +1,102 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type {
ScenePlayerTokensSessionEvent,
ScenePlayerTokensSessionState,
} from '../../../shared/types';
import { getDndApi } from '../dndApi';
function applyEvent(
prev: ScenePlayerTokensSessionState,
event: ScenePlayerTokensSessionEvent,
): ScenePlayerTokensSessionState {
if (event.kind === 'clear') {
return { revision: prev.revision + 1, selectedPlayerIds: [], visible: false, byPlayerId: {} };
}
if (event.kind === 'clearPlacements') {
return { ...prev, revision: prev.revision + 1, visible: false, byPlayerId: {} };
}
if (event.kind === 'setSelection') {
const seen = new Set<string>();
const selectedPlayerIds: string[] = [];
for (const id of event.playerIds) {
const s = String(id ?? '').trim();
if (!s || seen.has(s)) continue;
seen.add(s);
selectedPlayerIds.push(s);
}
return { revision: prev.revision + 1, selectedPlayerIds, visible: false, byPlayerId: {} };
}
if (event.kind === 'setVisible') {
if (event.visible && prev.selectedPlayerIds.length === 0) return prev;
return { ...prev, revision: prev.revision + 1, visible: Boolean(event.visible) };
}
if (event.kind === 'show') {
if (prev.selectedPlayerIds.length === 0) return prev;
// placements приходят с main; локально только включаем visible после seed через broadcast
return { ...prev, revision: prev.revision + 1, visible: true };
}
if (event.kind === 'seedBottom') {
return prev;
}
if (event.kind === 'move') {
const prevPlacement = prev.byPlayerId[event.playerId];
return {
...prev,
revision: prev.revision + 1,
byPlayerId: {
...prev.byPlayerId,
[event.playerId]: {
nx: event.nx,
ny: event.ny,
sizeN: prevPlacement?.sizeN ?? 0.1,
},
},
};
}
return prev;
}
export function useScenePlayerTokensSession(): [
ScenePlayerTokensSessionState,
{ dispatch: (event: ScenePlayerTokensSessionEvent) => void },
] {
const api = getDndApi();
const [state, setState] = useState<ScenePlayerTokensSessionState>({
revision: 0,
selectedPlayerIds: [],
visible: false,
byPlayerId: {},
});
const localRevRef = useRef(0);
useEffect(() => {
void api.invoke(ipcChannels.scenePlayerTokensSession.getState, {}).then((res) => {
if (res.state.revision < localRevRef.current) return;
localRevRef.current = res.state.revision;
setState(res.state);
});
return api.on(ipcChannels.scenePlayerTokensSession.stateChanged, ({ state: next }) => {
if (next.revision < localRevRef.current) return;
localRevRef.current = next.revision;
setState(next);
});
}, [api]);
const dispatch = useCallback(
(event: ScenePlayerTokensSessionEvent) => {
if (event.kind !== 'seedBottom' && event.kind !== 'show') {
setState((prev) => {
const next = applyEvent(prev, event);
localRevRef.current = Math.max(localRevRef.current, next.revision);
return next;
});
}
void api.invoke(ipcChannels.scenePlayerTokensSession.dispatch, { event });
},
[api],
);
return [state, { dispatch }];
}
@@ -184,7 +184,7 @@ export function SceneTokensOverlay({
ny={ny} ny={ny}
viewport={viewport} viewport={viewport}
editable={editable} editable={editable}
onMove={onMove} {...(onMove ? { onMove } : {})}
/> />
); );
})} })}
+3
View File
@@ -17,6 +17,7 @@ type ButtonProps = {
iconOnly?: boolean; iconOnly?: boolean;
/** Позиция тултипа относительно кнопки. */ /** Позиция тултипа относительно кнопки. */
tooltipPlacement?: 'top' | 'bottom' | 'bottom-left'; tooltipPlacement?: 'top' | 'bottom' | 'bottom-left';
'data-testid'?: string;
}; };
export function Button({ export function Button({
@@ -28,6 +29,7 @@ export function Button({
ariaLabel, ariaLabel,
iconOnly = false, iconOnly = false,
tooltipPlacement = 'top', tooltipPlacement = 'top',
'data-testid': testId,
}: ButtonProps) { }: ButtonProps) {
const btnRef = useRef<HTMLButtonElement | null>(null); const btnRef = useRef<HTMLButtonElement | null>(null);
const hostRef = useRef<HTMLSpanElement | null>(null); const hostRef = useRef<HTMLSpanElement | null>(null);
@@ -85,6 +87,7 @@ export function Button({
className={btnClass} className={btnClass}
disabled={disabled} disabled={disabled}
aria-label={ariaLabel} aria-label={ariaLabel}
data-testid={testId}
onClick={disabled ? undefined : onClick} onClick={disabled ? undefined : onClick}
onMouseEnter={disabled ? undefined : showTip} onMouseEnter={disabled ? undefined : showTip}
onMouseLeave={disabled ? undefined : hideTip} onMouseLeave={disabled ? undefined : hideTip}
@@ -70,3 +70,63 @@ void test('WindowErrorBoundary component exists and catches errors', () => {
assert.ok(src.includes('componentDidCatch')); assert.ok(src.includes('componentDidCatch'));
assert.ok(src.includes('role="alert"')); assert.ok(src.includes('role="alert"'));
}); });
void test('EditorApp: Players header after File, opens modal', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'editor/EditorApp.tsx'), 'utf8');
const fileIdx = src.indexOf("t('top.file')");
const playersIdx = src.indexOf('data-testid="players-header-btn"');
assert.ok(fileIdx > 0, 'File menu present');
assert.ok(playersIdx > fileIdx, 'Players button must follow File in source order');
assert.ok(src.includes('PlayersManagerModal'));
assert.ok(src.includes('setPlayersManagerOpen(true)'));
});
void test('Players modal: flat teams only, progress overlay, PlayerTokenView', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'editor/PlayersModals.tsx'), 'utf8');
assert.ok(src.includes('PlayerTokenView'));
assert.ok(src.includes('data-testid="players-modal"'));
assert.ok(src.includes('data-testid="players-save-progress"'));
assert.doesNotMatch(src, /parentId|подкоманд|subteam|sub-team/i);
assert.ok(src.includes("application/x-dnd-player-id"));
});
void test('Scene NPC accordion: separate mime and PlayerTokenView markers', () => {
const scene = fs.readFileSync(path.join(rendererRoot, 'sceneEditor/SceneEditorApp.tsx'), 'utf8');
const tokenTile = fs.readFileSync(path.join(rendererRoot, 'sceneEditor/TokenTile.tsx'), 'utf8');
assert.ok(scene.includes('data-testid="scene-npc-accordion"'));
assert.ok(scene.includes("application/x-dnd-scene-npc-id"));
assert.ok(tokenTile.includes("application/x-dnd-app-token-id"));
assert.notEqual(
'application/x-dnd-scene-npc-id',
'application/x-dnd-app-token-id',
'NPC scene mime must differ from app token mime',
);
assert.ok(scene.includes('PlayerTokenView'));
assert.ok(scene.includes('persistNpcTokens([])'));
assert.doesNotMatch(scene, /SceneTokenMarker[\s\S]{0,80}npcTokens/);
});
void test('Control and Presentation use SceneNpcTokensOverlay', () => {
const control = fs.readFileSync(path.join(rendererRoot, 'control/ControlApp.tsx'), 'utf8');
const presentation = fs.readFileSync(path.join(rendererRoot, 'shared/PresentationView.tsx'), 'utf8');
assert.ok(control.includes('SceneNpcTokensOverlay'));
assert.ok(presentation.includes('SceneNpcTokensOverlay'));
assert.ok(control.includes('sceneNpcTokensSession'));
assert.ok(control.includes('ScenePlayerTokensOverlay'));
assert.ok(presentation.includes('ScenePlayerTokensOverlay'));
assert.ok(control.includes('toggle-session-players'));
});
void test('EditorApp: split Run button and launch-with-players modal', () => {
const editor = fs.readFileSync(path.join(rendererRoot, 'editor/EditorApp.tsx'), 'utf8');
assert.ok(editor.includes('splitRun'));
assert.ok(editor.includes('run-menu-btn'));
assert.ok(editor.includes('LaunchPlayersModal'));
assert.ok(editor.includes('top.runWithPlayers'));
});
void test('NpcsEditorApp: token appearance via PlayerTokenView', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsEditorApp.tsx'), 'utf8');
assert.ok(src.includes('PlayerTokenView'));
assert.ok(src.includes('disposition') || src.includes('updateNpcFields'));
});
+1
View File
@@ -24,6 +24,7 @@ function scene(id: string): Scene {
darkenScene: false, darkenScene: false,
traps: [], traps: [],
tokens: [], tokens: [],
npcTokens: [],
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' }, grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
media: { videos: [], audios: [] }, media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true }, settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
@@ -10,6 +10,7 @@ import {
asNpcRelationId, asNpcRelationId,
asProjectId, asProjectId,
asSceneId, asSceneId,
asSceneNpcTokenId,
} from '../types/ids'; } from '../types/ids';
import { PROJECT_SCHEMA_VERSION } from '../types'; import { PROJECT_SCHEMA_VERSION } from '../types';
@@ -40,6 +41,7 @@ function scene(id: string, title: string): Scene {
darkenScene: false, darkenScene: false,
traps: [], traps: [],
tokens: [], tokens: [],
npcTokens: [],
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' }, grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
media: { videos: [], audios: [] }, media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false }, settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
@@ -242,6 +244,10 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
x: 0, x: 0,
y: 0, y: 0,
groupId: gChild, groupId: gChild,
ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
}, },
{ {
id: n2, id: n2,
@@ -251,6 +257,10 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
x: 10, x: 10,
y: 0, y: 0,
groupId: gChild, groupId: gChild,
ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
}, },
{ {
id: n3, id: n3,
@@ -260,6 +270,10 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
x: 20, x: 20,
y: 0, y: 0,
groupId: asNpcGroupId('g_other'), groupId: asNpcGroupId('g_other'),
ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
}, },
]; ];
const relations: ProjectNpcRelation[] = [ const relations: ProjectNpcRelation[] = [
@@ -308,6 +322,142 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
assert.ok(!partial.npcGroups.some((g) => g.id === asNpcGroupId('g_other'))); assert.ok(!partial.npcGroups.some((g) => g.id === asNpcGroupId('g_other')));
}); });
void test('buildPartialExportProject: keeps npcTokens only for exported NPCs', () => {
const n1 = asNpcId('npc1');
const n2 = asNpcId('npc2');
const avatar = asAssetId('a1');
const sceneId = asSceneId('s1');
const source = minimalProject({
scenes: {
[sceneId]: {
...scene('s1', 'Start'),
npcTokens: [
{ id: asSceneNpcTokenId('nt1'), npcId: n1, nx: 0.2, ny: 0.3, sizeN: 0.1, disposition: 'neutral' },
{ id: asSceneNpcTokenId('nt2'), npcId: n2, nx: 0.5, ny: 0.5, sizeN: 0.1, disposition: 'hostile' },
{ id: asSceneNpcTokenId('nt3'), npcId: asNpcId('missing'), nx: 0.1, ny: 0.1, sizeN: 0.1, disposition: 'neutral' },
],
},
},
sceneGraphNodes: [node('main', 's1', { isStartScene: true })],
assets: {
[avatar]: {
id: avatar,
type: 'image',
mime: 'image/png',
originalName: 'a.png',
relPath: 'assets/a.png',
sha256: 'abc',
sizeBytes: 1,
createdAt: '2020-01-01T00:00:00.000Z',
},
},
npcs: [
{
id: n1,
name: 'A',
avatarAssetId: avatar,
description: '',
x: 0,
y: 0,
groupId: null,
ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
{
id: n2,
name: 'B',
avatarAssetId: avatar,
description: '',
x: 10,
y: 0,
groupId: null,
ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
],
});
const partial = buildPartialExportProject(source, [{ kind: 'main' }], {
newProjectId: newExportBundleProjectId(),
exportTitle: 'Export',
labels: LABELS,
npcIds: [n1],
});
const tokens = partial.scenes[sceneId]?.npcTokens ?? [];
assert.equal(tokens.length, 1);
assert.equal(tokens[0]!.npcId, n1);
});
void test('mergeStorylinesIntoProject: remaps npcTokens to created NPC ids', () => {
const avatar = asAssetId('a1');
const sourceNpcId = asNpcId('src_npc');
const source = minimalProject({
scenes: {
[asSceneId('s1')]: {
...scene('s1', 'Side'),
npcTokens: [
{ id: asSceneNpcTokenId('nt1'), npcId: sourceNpcId, nx: 0.4, ny: 0.6, sizeN: 0.12, disposition: 'friendly' },
],
},
},
sceneGraphNodes: [node('side', 's1', { isSideStoryStart: true, sideStoryLineTitle: 'Side', x: 0 })],
assets: {
[avatar]: {
id: avatar,
type: 'image',
mime: 'image/png',
originalName: 'a.png',
relPath: 'assets/a.png',
sha256: 'npcsha',
sizeBytes: 1,
createdAt: '2020-01-01T00:00:00.000Z',
},
},
npcs: [
{
id: sourceNpcId,
name: 'Hero',
avatarAssetId: avatar,
description: '',
x: 0,
y: 0,
groupId: null,
ringColor: '#aabbcc',
disposition: 'neutral',
imageOffset: { x: 0.1, y: -0.1 },
imageScale: 1,
},
],
});
const target = minimalProject({
scenes: { [asSceneId('t1')]: scene('t1', 'Existing') },
sceneGraphNodes: [node('tgn', 't1', { x: 0 })],
});
const { project: merged } = mergeStorylinesIntoProject(
target,
source,
[{ kind: 'side', startGraphNodeId: asGraphNodeId('side') }],
[{ sourceSceneId: asSceneId('s1'), mode: 'create' }],
{
graphOffsetX: 200,
npcResolutions: [{ sourceNpcId, mode: 'create' }],
},
);
assert.equal(merged.npcs.length, 1);
const importedScene = Object.values(merged.scenes).find((s) => s.title === 'Side');
assert.ok(importedScene);
assert.equal(importedScene!.npcTokens.length, 1);
assert.equal(importedScene!.npcTokens[0]!.npcId, merged.npcs[0]!.id);
assert.notEqual(importedScene!.npcTokens[0]!.npcId, sourceNpcId);
assert.equal(merged.npcs[0]!.ringColor, '#aabbcc');
});
void test('mergeStorylinesIntoProject: imports all NPCs from export bundle', () => { void test('mergeStorylinesIntoProject: imports all NPCs from export bundle', () => {
const avatar = asAssetId('a1'); const avatar = asAssetId('a1');
const sourceNpcId = asNpcId('src_npc'); const sourceNpcId = asNpcId('src_npc');
@@ -335,6 +485,10 @@ void test('mergeStorylinesIntoProject: imports all NPCs from export bundle', ()
x: 0, x: 0,
y: 0, y: 0,
groupId: null, groupId: null,
ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
}, },
], ],
}); });
+38
View File
@@ -19,6 +19,7 @@ import {
asNpcRelationId, asNpcRelationId,
asProjectId, asProjectId,
asSceneId, asSceneId,
asSceneNpcTokenId,
asTokenId, asTokenId,
} from '../types/ids'; } from '../types/ids';
@@ -291,6 +292,15 @@ export function buildPartialExportProject(
npcs: exportedNpcs.map((n) => ({ ...n })), npcs: exportedNpcs.map((n) => ({ ...n })),
npcGroups: exportedGroups.map((g) => ({ ...g })), npcGroups: exportedGroups.map((g) => ({ ...g })),
npcRelations: exportedRelations.map((r) => ({ ...r })), npcRelations: exportedRelations.map((r) => ({ ...r })),
scenes: Object.fromEntries(
Object.entries(draft.scenes).map(([sid, sc]) => [
sid,
{
...sc,
npcTokens: (sc.npcTokens ?? []).filter((t) => exportedNpcIds.has(t.npcId)),
},
]),
) as Project['scenes'],
}; };
const assetIds = collectReferencedAssetIdsForProject(draft); const assetIds = collectReferencedAssetIdsForProject(draft);
@@ -686,11 +696,39 @@ export function mergeStorylinesIntoProject(
x: n.x, x: n.x,
y: n.y, y: n.y,
groupId: mappedGroupId && npcGroups.some((g) => g.id === mappedGroupId) ? mappedGroupId : null, groupId: mappedGroupId && npcGroups.some((g) => g.id === mappedGroupId) ? mappedGroupId : null,
ringColor: n.ringColor ?? '#c9a227',
disposition: n.disposition === 'hostile' || n.disposition === 'friendly' ? n.disposition : 'neutral',
imageOffset: n.imageOffset ?? { x: 0, y: 0 },
imageScale: typeof n.imageScale === 'number' && Number.isFinite(n.imageScale) ? n.imageScale : 1,
}); });
npcNameKeys.add(name.toLowerCase()); npcNameKeys.add(name.toLowerCase());
npcsCreated += 1; npcsCreated += 1;
} }
// Remap npcTokens on newly created scenes to imported NPC ids.
for (const sid of sourceSceneIds) {
const res = resolutionBySource.get(sid);
if (res?.mode === 'use') continue;
const newSid = sceneIdMap.get(sid);
if (!newSid) continue;
const sc = scenes[newSid];
if (!sc) continue;
scenes[newSid] = {
...sc,
npcTokens: (sc.npcTokens ?? [])
.map((t) => {
const mappedNpc = npcIdMap.get(t.npcId);
if (!mappedNpc) return null;
return {
...t,
id: asSceneNpcTokenId(`snt_${generateId()}`),
npcId: asNpcId(mappedNpc),
};
})
.filter((t): t is NonNullable<typeof t> => Boolean(t)),
};
}
const npcRelations = [...(target.npcRelations ?? [])]; const npcRelations = [...(target.npcRelations ?? [])];
const exportedNpcIdSet = new Set(exportedNpcs.map((n) => n.id)); const exportedNpcIdSet = new Set(exportedNpcs.map((n) => n.id));
for (const r of source.npcRelations ?? []) { for (const r of source.npcRelations ?? []) {
+18
View File
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const contractsPath = path.join(here, 'contracts.ts');
void test('contracts: players and sceneNpcTokensSession channels exist', () => {
const src = fs.readFileSync(contractsPath, 'utf8');
assert.match(src, /players:\s*\{/);
assert.match(src, /list:\s*'players\.list'/);
assert.match(src, /upsertProgress:\s*'players\.upsertProgress'/);
assert.match(src, /sceneNpcTokensSession:\s*\{/);
assert.match(src, /scenePlayerTokensSession:\s*\{/);
assert.match(src, /npcTokens\?:\s*SceneNpcToken\[\]/);
});
+118 -1
View File
@@ -12,16 +12,28 @@ import type {
NpcId, NpcId,
NpcRelationId, NpcRelationId,
NpcGroupId, NpcGroupId,
NpcDisposition,
NpcsOverlayEvent, NpcsOverlayEvent,
NpcsOverlayState, NpcsOverlayState,
Project, Project,
ProjectId, ProjectId,
AppToken, AppToken,
AppPlayer,
AppPlayerTeam,
PlayerId,
PlayerTeamId,
PlayerImageOffset,
PlayersUpsertProgressEvent,
Scene, Scene,
SceneDarknessEvent, SceneDarknessEvent,
SceneDarknessState, SceneDarknessState,
SceneGrid, SceneGrid,
SceneId, SceneId,
SceneNpcToken,
SceneNpcTokensSessionEvent,
SceneNpcTokensSessionState,
ScenePlayerTokensSessionEvent,
ScenePlayerTokensSessionState,
SceneToken, SceneToken,
SceneTokensSessionEvent, SceneTokensSessionEvent,
SceneTokensSessionState, SceneTokensSessionState,
@@ -185,6 +197,30 @@ export const ipcChannels = {
dispatch: 'sceneTokensSession.dispatch', dispatch: 'sceneTokensSession.dispatch',
stateChanged: 'sceneTokensSession.stateChanged', stateChanged: 'sceneTokensSession.stateChanged',
}, },
players: {
list: 'players.list',
upsert: 'players.upsert',
delete: 'players.delete',
setOrder: 'players.setOrder',
upsertTeam: 'players.upsertTeam',
deleteTeam: 'players.deleteTeam',
setTeamsOrder: 'players.setTeamsOrder',
assignTeam: 'players.assignTeam',
pickImage: 'players.pickImage',
imageUrl: 'players.imageUrl',
upsertProgress: 'players.upsertProgress',
stateChanged: 'players.stateChanged',
},
sceneNpcTokensSession: {
getState: 'sceneNpcTokensSession.getState',
dispatch: 'sceneNpcTokensSession.dispatch',
stateChanged: 'sceneNpcTokensSession.stateChanged',
},
scenePlayerTokensSession: {
getState: 'scenePlayerTokensSession.getState',
dispatch: 'scenePlayerTokensSession.dispatch',
stateChanged: 'scenePlayerTokensSession.stateChanged',
},
video: { video: {
getState: 'video.getState', getState: 'video.getState',
dispatch: 'video.dispatch', dispatch: 'video.dispatch',
@@ -257,6 +293,10 @@ 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.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
[ipcChannels.scenePlayerTokensSession.stateChanged]: { state: ScenePlayerTokensSessionState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState }; [ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot }; [ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean }; [ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
@@ -372,6 +412,10 @@ export type IpcInvokeMap = {
description?: string; description?: string;
filePath?: string; filePath?: string;
groupId?: NpcGroupId | null; groupId?: NpcGroupId | null;
ringColor?: string;
disposition?: NpcDisposition;
imageOffset?: PlayerImageOffset;
imageScale?: number;
}; };
res: { project: Project }; res: { project: Project };
}; };
@@ -381,6 +425,10 @@ export type IpcInvokeMap = {
name?: string; name?: string;
description?: string; description?: string;
groupId?: NpcGroupId | null; groupId?: NpcGroupId | null;
ringColor?: string;
disposition?: NpcDisposition;
imageOffset?: PlayerImageOffset;
imageScale?: number;
}; };
res: { project: Project }; res: { project: Project };
}; };
@@ -577,7 +625,7 @@ export type IpcInvokeMap = {
res: { ok: true }; res: { ok: true };
}; };
[ipcChannels.windows.openMultiWindow]: { [ipcChannels.windows.openMultiWindow]: {
req: Record<string, never>; req: { playerIds?: string[] };
res: { ok: true }; res: { ok: true };
}; };
[ipcChannels.windows.closeMultiWindow]: { [ipcChannels.windows.closeMultiWindow]: {
@@ -720,6 +768,70 @@ export type IpcInvokeMap = {
req: { event: SceneTokensSessionEvent }; req: { event: SceneTokensSessionEvent };
res: { ok: true }; res: { ok: true };
}; };
[ipcChannels.players.list]: {
req: Record<string, never>;
res: { players: AppPlayer[]; teams: AppPlayerTeam[] };
};
[ipcChannels.players.upsert]: {
req: {
id?: PlayerId | null;
name: string;
filePath?: string | null;
teamId?: PlayerTeamId | null;
ringColor?: string;
imageOffset?: PlayerImageOffset;
imageScale?: number;
};
res: { player: AppPlayer };
};
[ipcChannels.players.delete]: {
req: { id: PlayerId };
res: { ok: true };
};
[ipcChannels.players.setOrder]: {
req: { playerIds: PlayerId[] };
res: { players: AppPlayer[] };
};
[ipcChannels.players.upsertTeam]: {
req: { id?: PlayerTeamId | null; name: string; color?: string };
res: { team: AppPlayerTeam };
};
[ipcChannels.players.deleteTeam]: {
req: { id: PlayerTeamId };
res: { ok: true };
};
[ipcChannels.players.setTeamsOrder]: {
req: { teamIds: PlayerTeamId[] };
res: { teams: AppPlayerTeam[] };
};
[ipcChannels.players.assignTeam]: {
req: { playerId: PlayerId; teamId: PlayerTeamId | null };
res: { player: AppPlayer | null };
};
[ipcChannels.players.pickImage]: {
req: Record<string, never>;
res: { canceled: true } | { canceled: false; filePath: string; previewDataUrl: string };
};
[ipcChannels.players.imageUrl]: {
req: { id: PlayerId };
res: { url: string | null };
};
[ipcChannels.sceneNpcTokensSession.getState]: {
req: Record<string, never>;
res: { state: SceneNpcTokensSessionState };
};
[ipcChannels.sceneNpcTokensSession.dispatch]: {
req: { event: SceneNpcTokensSessionEvent };
res: { ok: true };
};
[ipcChannels.scenePlayerTokensSession.getState]: {
req: Record<string, never>;
res: { state: ScenePlayerTokensSessionState };
};
[ipcChannels.scenePlayerTokensSession.dispatch]: {
req: { event: ScenePlayerTokensSessionEvent };
res: { ok: true };
};
[ipcChannels.video.getState]: { [ipcChannels.video.getState]: {
req: Record<string, never>; req: Record<string, never>;
res: { state: VideoPlaybackState }; res: { state: VideoPlaybackState };
@@ -761,6 +873,10 @@ 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.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
[ipcChannels.scenePlayerTokensSession.stateChanged]: { state: ScenePlayerTokensSessionState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState }; [ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: Record<string, never>; [ipcChannels.license.statusChanged]: Record<string, never>;
}; };
@@ -776,6 +892,7 @@ export type ScenePatch = {
darkenScene?: boolean; darkenScene?: boolean;
traps?: SceneTrap[]; traps?: SceneTrap[];
tokens?: SceneToken[]; tokens?: SceneToken[];
npcTokens?: SceneNpcToken[];
grid?: SceneGrid; grid?: SceneGrid;
settings?: Partial<Scene['settings']>; settings?: Partial<Scene['settings']>;
media?: Partial<Scene['media']>; media?: Partial<Scene['media']>;
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { asPlayerId, asPlayerTeamId } from '../types/ids';
import { layoutPlayerTokensBottom } from '../types/appPlayers';
import { resolveLaunchPlayerIds } from './resolveLaunchPlayerIds';
void test('layoutPlayerTokensBottom spaces tokens along bottom', () => {
const layout = layoutPlayerTokensBottom(['a', 'b', 'c'], 0.1);
assert.equal(layout.a?.ny, 0.88);
assert.equal(layout.a?.nx, 0.25);
assert.equal(layout.b?.nx, 0.5);
assert.equal(layout.c?.nx, 0.75);
});
void test('resolveLaunchPlayerIds merges players and teams with dedupe', () => {
const teamId = asPlayerTeamId('t1');
const players = [
{
id: asPlayerId('p1'),
name: 'A',
imageRelPath: 'x',
sha256: '1',
teamId,
ringColor: '#fff',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
{
id: asPlayerId('p2'),
name: 'B',
imageRelPath: 'y',
sha256: '2',
teamId: null,
ringColor: '#fff',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
];
const ids = resolveLaunchPlayerIds(players, new Set(['p1', 'p2']), new Set(['t1']));
assert.deepEqual([...ids].sort(), ['p1', 'p2']);
});
+62
View File
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
assignPlayerToTeam,
createPlayerTeamDraft,
deletePlayerTeam,
normalizeAppPlayerTeams,
uniquePlayerTeamName,
} from './playerTeams';
import type { AppPlayer, AppPlayerTeam } from '../types/appPlayers';
import { asPlayerId, asPlayerTeamId } from '../types/ids';
void test('uniquePlayerTeamName appends suffix on collision', () => {
const teams: AppPlayerTeam[] = [
{ id: asPlayerTeamId('t1'), name: 'Alpha', color: '#ffffff' },
];
assert.equal(uniquePlayerTeamName('Alpha', teams), 'Alpha (2)');
assert.equal(uniquePlayerTeamName('Beta', teams), 'Beta');
});
void test('normalizeAppPlayerTeams drops invalid and duplicates', () => {
const teams = normalizeAppPlayerTeams([
{ id: 't1', name: 'A', color: '#abc' },
{ id: 't1', name: 'Dup', color: '#ffffff' },
{ id: '', name: 'Bad', color: '#ffffff' },
null,
{ id: 't2', name: 'B', color: '#112233' },
]);
assert.equal(teams.length, 2);
assert.equal(teams[0]!.id, 't1');
assert.equal(teams[0]!.color, '#aabbcc'); // #abc → expanded
assert.equal(teams[1]!.color, '#112233');
});
void test('createPlayerTeamDraft has no parentId field', () => {
const team = createPlayerTeamDraft('Team', '#ff0000', []);
assert.equal(team.name, 'Team');
assert.ok(!('parentId' in team));
});
void test('assignPlayerToTeam and deletePlayerTeam ungroup players', () => {
const t1 = asPlayerTeamId('t1');
const players: AppPlayer[] = [
{
id: asPlayerId('p1'),
name: 'P',
imageRelPath: 'files/a.png',
sha256: 'x',
teamId: t1,
ringColor: '#c9a227',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
];
const teams: AppPlayerTeam[] = [{ id: t1, name: 'T', color: '#ffffff' }];
const assigned = assignPlayerToTeam(players, 'p1', null, new Set([t1]));
assert.equal(assigned[0]!.teamId, null);
const del = deletePlayerTeam(teams, players, t1);
assert.equal(del.teams.length, 0);
assert.equal(del.players[0]!.teamId, null);
});
+71
View File
@@ -0,0 +1,71 @@
import type { AppPlayer, AppPlayerTeam, PlayerTeamId } from '../types/appPlayers';
import { asPlayerTeamId } from '../types/ids';
import { DEFAULT_PLAYER_RING_COLOR, normalizeAppPlayerTeam } from '../types/appPlayers';
import { normalizeHexColor } from '../npcs/npcGroups';
/** Имя команды уникально среди плоского списка (без вложенности). */
export function uniquePlayerTeamName(
base: string,
teams: readonly AppPlayerTeam[],
exceptId?: PlayerTeamId | null,
): string {
const root = base.trim() || 'Team';
const used = new Set(
teams.filter((t) => t.id !== exceptId).map((t) => t.name.trim().toLowerCase()),
);
if (!used.has(root.toLowerCase())) return root;
let i = 2;
while (used.has(`${root.toLowerCase()} (${String(i)})`)) i += 1;
return `${root} (${String(i)})`;
}
export function normalizeAppPlayerTeams(raw: unknown): AppPlayerTeam[] {
if (!Array.isArray(raw)) return [];
const out: AppPlayerTeam[] = [];
const ids = new Set<string>();
for (const item of raw) {
const t = normalizeAppPlayerTeam(item);
if (!t || ids.has(t.id)) continue;
ids.add(t.id);
out.push(t);
}
return out;
}
export function assignPlayerToTeam(
players: readonly AppPlayer[],
playerId: string,
teamId: PlayerTeamId | null,
teamIds: Set<string>,
): AppPlayer[] {
return players.map((p) => {
if (p.id !== playerId) return p;
if (teamId && !teamIds.has(teamId)) return { ...p, teamId: null };
return { ...p, teamId };
});
}
/** Удаление команды: игроки этой команды становятся без команды. */
export function deletePlayerTeam(
teams: readonly AppPlayerTeam[],
players: readonly AppPlayer[],
teamId: PlayerTeamId,
): { teams: AppPlayerTeam[]; players: AppPlayer[] } {
return {
teams: teams.filter((t) => t.id !== teamId),
players: players.map((p) => (p.teamId === teamId ? { ...p, teamId: null } : p)),
};
}
export function createPlayerTeamDraft(
name: string,
color: string | undefined,
teams: readonly AppPlayerTeam[],
): AppPlayerTeam {
const id = asPlayerTeamId(`pteam_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
return {
id,
name: uniquePlayerTeamName(name, teams),
color: normalizeHexColor(color, DEFAULT_PLAYER_RING_COLOR),
};
}
@@ -0,0 +1,15 @@
import type { AppPlayer } from '../types';
/** Игроки + члены выбранных команд, без дублей. */
export function resolveLaunchPlayerIds(
players: readonly AppPlayer[],
selectedPlayerIds: ReadonlySet<string>,
selectedTeamIds: ReadonlySet<string>,
): string[] {
const out = new Set<string>();
for (const id of selectedPlayerIds) out.add(id);
for (const p of players) {
if (p.teamId && selectedTeamIds.has(String(p.teamId))) out.add(String(p.id));
}
return [...out];
}
+73
View File
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
clampPlayerImageOffset,
clampPlayerImageScale,
DEFAULT_PLAYER_IMAGE_SCALE,
DEFAULT_PLAYER_RING_COLOR,
normalizeAppPlayer,
normalizeSceneNpcToken,
PLAYER_IMAGE_OFFSET_MAX,
PLAYER_IMAGE_SCALE_MAX,
PLAYER_IMAGE_SCALE_MIN,
} from './appPlayers';
import { asNpcId, asSceneNpcTokenId } from './ids';
void test('clampPlayerImageOffset clamps and defaults', () => {
assert.deepEqual(clampPlayerImageOffset(null), { x: 0, y: 0 });
assert.deepEqual(clampPlayerImageOffset({ x: 99, y: -99 }), {
x: PLAYER_IMAGE_OFFSET_MAX,
y: -PLAYER_IMAGE_OFFSET_MAX,
});
});
void test('clampPlayerImageScale clamps and defaults', () => {
assert.equal(clampPlayerImageScale(null), DEFAULT_PLAYER_IMAGE_SCALE);
assert.equal(clampPlayerImageScale(0.1), PLAYER_IMAGE_SCALE_MIN);
assert.equal(clampPlayerImageScale(9), PLAYER_IMAGE_SCALE_MAX);
});
void test('normalizeAppPlayer fills ringColor and offset', () => {
const p = normalizeAppPlayer({
id: 'player_1',
name: 'Ada',
imageRelPath: 'files/a.png',
sha256: 'abc',
});
assert.ok(p);
assert.equal(p!.ringColor, DEFAULT_PLAYER_RING_COLOR);
assert.deepEqual(p!.imageOffset, { x: 0, y: 0 });
assert.equal(p!.imageScale, DEFAULT_PLAYER_IMAGE_SCALE);
assert.equal(p!.teamId, null);
});
void test('normalizeAppPlayer drops unknown teamId when set provided', () => {
const p = normalizeAppPlayer(
{
id: 'player_1',
name: 'Ada',
imageRelPath: 'files/a.png',
sha256: 'abc',
teamId: 'missing',
},
new Set(['other']),
);
assert.equal(p!.teamId, null);
});
void test('normalizeSceneNpcToken validates and clamps', () => {
const t = normalizeSceneNpcToken({
id: 'snt_1',
npcId: 'npc_1',
nx: 1.5,
ny: -0.2,
sizeN: 0.2,
});
assert.ok(t);
assert.equal(t!.id, asSceneNpcTokenId('snt_1'));
assert.equal(t!.npcId, asNpcId('npc_1'));
assert.equal(t!.nx, 1);
assert.equal(t!.ny, 0);
assert.equal(normalizeSceneNpcToken({ id: 'x' }), null);
});
+211
View File
@@ -0,0 +1,211 @@
/** App-local библиотека живых игроков (userData, не в project zip). */
import type { NpcDisposition } from './npcDisposition';
import { normalizeNpcDisposition } from './npcDisposition';
import type { NpcId, PlayerId, PlayerTeamId, SceneNpcTokenId } from './ids';
import { asNpcId, asPlayerId, asPlayerTeamId, asSceneNpcTokenId } from './ids';
import { normalizeHexColor } from '../npcs/npcGroups';
export type { PlayerId, PlayerTeamId, SceneNpcTokenId };
export { asPlayerId, asPlayerTeamId, asSceneNpcTokenId };
export type PlayerImageOffset = { x: number; y: number };
/** Плоская команда игроков (без вложенности). */
export type AppPlayerTeam = {
id: PlayerTeamId;
name: string;
/** Hex `#rrggbb`. */
color: string;
};
export type AppPlayer = {
id: PlayerId;
name: string;
/** Относительный путь файла в каталоге `userData/players/`. */
imageRelPath: string;
sha256: string;
teamId: PlayerTeamId | null;
ringColor: string;
imageOffset: PlayerImageOffset;
/** Масштаб аватара внутри круга (1 = по умолчанию). */
imageScale: number;
};
/** Расстановка кампанийного НПС на карте как игрового токена (в проекте). */
export type SceneNpcToken = {
id: SceneNpcTokenId;
npcId: NpcId;
nx: number;
ny: number;
sizeN: number;
/** Состояние экземпляра на карте (копируется из НПС при постановке). */
disposition: NpcDisposition;
};
export const DEFAULT_PLAYER_RING_COLOR = '#c9a227';
export const DEFAULT_PLAYER_IMAGE_OFFSET: PlayerImageOffset = { x: 0, y: 0 };
export const PLAYER_IMAGE_OFFSET_MIN = -0.45;
export const PLAYER_IMAGE_OFFSET_MAX = 0.45;
export const DEFAULT_PLAYER_IMAGE_SCALE = 1;
export const PLAYER_IMAGE_SCALE_MIN = 0.5;
export const PLAYER_IMAGE_SCALE_MAX = 3;
export const PLAYER_IMAGE_SCALE_STEP = 0.08;
export const DEFAULT_SCENE_NPC_TOKEN_SIZE_N = 0.1;
export const SCENE_NPC_TOKEN_SIZE_MIN = 0.04;
export const SCENE_NPC_TOKEN_SIZE_MAX = 0.45;
/** Множитель размера всех НПС-токенов на пульте/презентации (session-only). */
export const DEFAULT_NPC_TOKEN_SESSION_SCALE = 1;
export const NPC_TOKEN_SESSION_SCALE_MIN = 0.4;
export const NPC_TOKEN_SESSION_SCALE_MAX = 2.5;
export type SceneNpcTokensSessionPlacement = {
/** Есть после move; без move позиция берётся из сцены. */
nx?: number;
ny?: number;
/** Session-only override типа экземпляра. */
disposition?: NpcDisposition;
/** Session-only: серая рамка и grayscale. */
inactive?: boolean;
};
export type SceneNpcTokensSessionState = {
revision: number;
byPlacementId: Record<string, SceneNpcTokensSessionPlacement>;
/** Общий масштаб отображения всех НПС-токенов (не пишется в проект). */
scale: number;
};
export type SceneNpcTokensSessionEvent =
| { kind: 'move'; placementId: string; nx: number; ny: number }
| { kind: 'setDisposition'; placementId: string; disposition: NpcDisposition }
| { kind: 'setInactive'; placementId: string; inactive: boolean }
| { kind: 'setScale'; scale: number }
| { kind: 'clear' };
/** Session-only токены живых игроков на карте во время показа. */
export type ScenePlayerTokenPlacement = { nx: number; ny: number; sizeN: number };
export type ScenePlayerTokensSessionState = {
revision: number;
selectedPlayerIds: string[];
visible: boolean;
byPlayerId: Record<string, ScenePlayerTokenPlacement>;
};
export type ScenePlayerTokensSessionEvent =
| { kind: 'setSelection'; playerIds: readonly string[] }
| { kind: 'setVisible'; visible: boolean }
| { kind: 'show'; sizeN: number }
| { kind: 'seedBottom'; sizeN: number }
| { kind: 'move'; playerId: string; nx: number; ny: number }
| { kind: 'clearPlacements' }
| { kind: 'clear' };
export function clampNpcTokenSessionScale(raw: unknown): number {
const n = typeof raw === 'number' && Number.isFinite(raw) ? raw : DEFAULT_NPC_TOKEN_SESSION_SCALE;
return Math.max(NPC_TOKEN_SESSION_SCALE_MIN, Math.min(NPC_TOKEN_SESSION_SCALE_MAX, n));
}
/** Ряд токенов внизу карты (первый показ «Показать игроков»). */
export function layoutPlayerTokensBottom(
playerIds: readonly string[],
sizeN: number,
): Record<string, ScenePlayerTokenPlacement> {
const clamped = clampSceneNpcTokenSizeN(sizeN);
const n = playerIds.length;
const out: Record<string, ScenePlayerTokenPlacement> = {};
const ny = 0.88;
for (let i = 0; i < n; i += 1) {
const id = playerIds[i];
if (!id) continue;
out[id] = { nx: (i + 1) / (n + 1), ny, sizeN: clamped };
}
return out;
}
export type PlayersUpsertProgressEvent = {
percent: number;
stage: string;
detail?: string;
};
export function clampPlayerImageOffset(raw: unknown): PlayerImageOffset {
if (!raw || typeof raw !== 'object') return { ...DEFAULT_PLAYER_IMAGE_OFFSET };
const obj = raw as { x?: unknown; y?: unknown };
const x = typeof obj.x === 'number' && Number.isFinite(obj.x) ? obj.x : 0;
const y = typeof obj.y === 'number' && Number.isFinite(obj.y) ? obj.y : 0;
return {
x: Math.max(PLAYER_IMAGE_OFFSET_MIN, Math.min(PLAYER_IMAGE_OFFSET_MAX, x)),
y: Math.max(PLAYER_IMAGE_OFFSET_MIN, Math.min(PLAYER_IMAGE_OFFSET_MAX, y)),
};
}
export function clampPlayerImageScale(raw: unknown): number {
const n = typeof raw === 'number' && Number.isFinite(raw) ? raw : DEFAULT_PLAYER_IMAGE_SCALE;
return Math.max(PLAYER_IMAGE_SCALE_MIN, Math.min(PLAYER_IMAGE_SCALE_MAX, n));
}
export function clampSceneNpcTokenSizeN(sizeN: number): number {
if (!Number.isFinite(sizeN)) return DEFAULT_SCENE_NPC_TOKEN_SIZE_N;
return Math.max(SCENE_NPC_TOKEN_SIZE_MIN, Math.min(SCENE_NPC_TOKEN_SIZE_MAX, sizeN));
}
export function normalizeAppPlayerTeam(raw: unknown): AppPlayerTeam | null {
if (!raw || typeof raw !== 'object') return null;
const obj = raw as Partial<AppPlayerTeam>;
if (typeof obj.id !== 'string' || !obj.id) return null;
if (typeof obj.name !== 'string') return null;
const name = obj.name.trim();
if (!name) return null;
return {
id: asPlayerTeamId(obj.id),
name,
color: normalizeHexColor(obj.color, DEFAULT_PLAYER_RING_COLOR),
};
}
export function normalizeAppPlayer(raw: unknown, teamIds?: Set<string>): AppPlayer | null {
if (!raw || typeof raw !== 'object') return null;
const obj = raw as Partial<AppPlayer> & { teamId?: string | null };
if (typeof obj.id !== 'string' || !obj.id) return null;
if (typeof obj.name !== 'string') return null;
const name = obj.name.trim();
if (!name) return null;
if (typeof obj.imageRelPath !== 'string' || !obj.imageRelPath) return null;
if (typeof obj.sha256 !== 'string' || !obj.sha256) return null;
let teamId: PlayerTeamId | null = null;
if (typeof obj.teamId === 'string' && obj.teamId) {
if (!teamIds || teamIds.has(obj.teamId)) teamId = asPlayerTeamId(obj.teamId);
}
return {
id: asPlayerId(obj.id),
name,
imageRelPath: obj.imageRelPath,
sha256: obj.sha256,
teamId,
ringColor: normalizeHexColor(obj.ringColor, DEFAULT_PLAYER_RING_COLOR),
imageOffset: clampPlayerImageOffset(obj.imageOffset),
imageScale: clampPlayerImageScale((obj as { imageScale?: unknown }).imageScale),
};
}
export function normalizeSceneNpcToken(raw: unknown): SceneNpcToken | null {
if (!raw || typeof raw !== 'object') return null;
const obj = raw as Partial<SceneNpcToken>;
if (typeof obj.id !== 'string' || !obj.id) return null;
if (typeof obj.npcId !== 'string' || !obj.npcId) return null;
const nx = typeof obj.nx === 'number' && Number.isFinite(obj.nx) ? obj.nx : null;
const ny = typeof obj.ny === 'number' && Number.isFinite(obj.ny) ? obj.ny : null;
if (nx === null || ny === null) return null;
return {
id: asSceneNpcTokenId(obj.id),
npcId: asNpcId(obj.npcId),
nx: Math.max(0, Math.min(1, nx)),
ny: Math.max(0, Math.min(1, ny)),
sizeN: clampSceneNpcTokenSizeN(typeof obj.sizeN === 'number' ? obj.sizeN : DEFAULT_SCENE_NPC_TOKEN_SIZE_N),
disposition: normalizeNpcDisposition((obj as { disposition?: unknown }).disposition),
};
}
+15 -1
View File
@@ -10,10 +10,12 @@ import type {
} from './ids'; } from './ids';
import type { MaterialLegend } from './materialLegend'; import type { MaterialLegend } from './materialLegend';
import type { SceneToken } from './appTokens'; import type { SceneToken } from './appTokens';
import type { SceneNpcToken, PlayerImageOffset } from './appPlayers';
import type { NpcDisposition } from './npcDisposition';
import type { SceneGrid } from './sceneGrid'; import type { SceneGrid } from './sceneGrid';
import type { SceneTrap } from './sceneTraps'; import type { SceneTrap } from './sceneTraps';
export const PROJECT_SCHEMA_VERSION = 10 as const; export const PROJECT_SCHEMA_VERSION = 11 as const;
/** Материал кампании: изображение, показываемое поверх сцены во время игры. */ /** Материал кампании: изображение, показываемое поверх сцены во время игры. */
export type ProjectMaterial = { export type ProjectMaterial = {
@@ -46,6 +48,16 @@ export type ProjectNpc = {
y: number; y: number;
/** `null` — системная секция «Без группы». */ /** `null` — системная секция «Без группы». */
groupId: NpcGroupId | null; groupId: NpcGroupId | null;
/**
* @deprecated Цвет кольца выводится из `disposition`. Поле оставлено для совместимости старых проектов.
*/
ringColor: string;
/** Дефолтный тип токена при постановке на карту. */
disposition: NpcDisposition;
/** Сдвиг аватара внутри круга токена. */
imageOffset: PlayerImageOffset;
/** Масштаб аватара внутри круга токена. */
imageScale: number;
}; };
/** Однонаправленная связь: от `sourceNpcId` к `targetNpcId`; подпись на линии. */ /** Однонаправленная связь: от `sourceNpcId` к `targetNpcId`; подпись на линии. */
@@ -154,6 +166,8 @@ export type Scene = {
traps: SceneTrap[]; traps: SceneTrap[];
/** Неигровые токены на карте (ссылки на app-local пул). */ /** Неигровые токены на карте (ссылки на app-local пул). */
tokens: SceneToken[]; tokens: SceneToken[];
/** Кампанийные НПС на карте как игровые токены (отдельно от неигровых). */
npcTokens: SceneNpcToken[];
/** Боевая сетка поверх превью (под ловушками/эффектами). */ /** Боевая сетка поверх превью (под ловушками/эффектами). */
grid: SceneGrid; grid: SceneGrid;
media: SceneMediaRefs; media: SceneMediaRefs;
+15
View File
@@ -10,6 +10,9 @@ export type NpcRelationId = Brand<string, 'NpcRelationId'>;
export type NpcGroupId = Brand<string, 'NpcGroupId'>; export type NpcGroupId = Brand<string, 'NpcGroupId'>;
export type TokenId = Brand<string, 'TokenId'>; export type TokenId = Brand<string, 'TokenId'>;
export type SceneTokenId = Brand<string, 'SceneTokenId'>; export type SceneTokenId = Brand<string, 'SceneTokenId'>;
export type PlayerId = Brand<string, 'PlayerId'>;
export type PlayerTeamId = Brand<string, 'PlayerTeamId'>;
export type SceneNpcTokenId = Brand<string, 'SceneNpcTokenId'>;
export function asProjectId(value: string): ProjectId { export function asProjectId(value: string): ProjectId {
return value as ProjectId; return value as ProjectId;
@@ -50,3 +53,15 @@ export function asTokenId(value: string): TokenId {
export function asSceneTokenId(value: string): SceneTokenId { export function asSceneTokenId(value: string): SceneTokenId {
return value as SceneTokenId; return value as SceneTokenId;
} }
export function asPlayerId(value: string): PlayerId {
return value as PlayerId;
}
export function asPlayerTeamId(value: string): PlayerTeamId {
return value as PlayerTeamId;
}
export function asSceneNpcTokenId(value: string): SceneNpcTokenId {
return value as SceneNpcTokenId;
}
+2
View File
@@ -1,9 +1,11 @@
export * from './appPlayers';
export * from './appTokens'; export * from './appTokens';
export * from './domain'; export * from './domain';
export * from './effects'; export * from './effects';
export * from './ids'; export * from './ids';
export * from './materialLegend'; export * from './materialLegend';
export * from './materials'; export * from './materials';
export * from './npcDisposition';
export * from './npcs'; export * from './npcs';
export * from './sceneDarkness'; export * from './sceneDarkness';
export * from './sceneGrid'; export * from './sceneGrid';
+28
View File
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
DEFAULT_NPC_DISPOSITION,
normalizeNpcDisposition,
npcDispositionRingColor,
otherNpcDispositions,
} from './npcDisposition';
void test('normalizeNpcDisposition defaults to neutral', () => {
assert.equal(normalizeNpcDisposition(undefined), DEFAULT_NPC_DISPOSITION);
assert.equal(normalizeNpcDisposition('hostile'), 'hostile');
assert.equal(normalizeNpcDisposition('friendly'), 'friendly');
assert.equal(normalizeNpcDisposition('nope'), 'neutral');
});
void test('npcDispositionRingColor and inactive', () => {
assert.equal(npcDispositionRingColor('hostile'), '#e53935');
assert.equal(npcDispositionRingColor('neutral'), '#c9a227');
assert.equal(npcDispositionRingColor('friendly'), '#43a047');
assert.equal(npcDispositionRingColor('hostile', true), '#9e9e9e');
});
void test('otherNpcDispositions hides current', () => {
assert.deepEqual(otherNpcDispositions('neutral'), ['hostile', 'friendly']);
assert.deepEqual(otherNpcDispositions('hostile'), ['neutral', 'friendly']);
});
+29
View File
@@ -0,0 +1,29 @@
/** Отношение НПС: задаёт цвет кольца токена. */
export const NPC_DISPOSITIONS = ['hostile', 'neutral', 'friendly'] as const;
export type NpcDisposition = (typeof NPC_DISPOSITIONS)[number];
export const DEFAULT_NPC_DISPOSITION: NpcDisposition = 'neutral';
export const NPC_DISPOSITION_RING_COLOR: Record<NpcDisposition, string> = {
hostile: '#e53935',
neutral: '#c9a227',
friendly: '#43a047',
};
/** Серый для session-only «Неактивен». */
export const NPC_INACTIVE_RING_COLOR = '#9e9e9e';
export function normalizeNpcDisposition(raw: unknown): NpcDisposition {
if (raw === 'hostile' || raw === 'friendly' || raw === 'neutral') return raw;
return DEFAULT_NPC_DISPOSITION;
}
export function npcDispositionRingColor(disposition: NpcDisposition, inactive = false): string {
if (inactive) return NPC_INACTIVE_RING_COLOR;
return NPC_DISPOSITION_RING_COLOR[disposition];
}
export function otherNpcDispositions(current: NpcDisposition): NpcDisposition[] {
return NPC_DISPOSITIONS.filter((d) => d !== current);
}
+14
View File
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { sceneGridTokenFitFactor } from './sceneGrid';
void test('sceneGridTokenFitFactor: square and disabled = 1', () => {
assert.equal(sceneGridTokenFitFactor(undefined), 1);
assert.equal(sceneGridTokenFitFactor({ enabled: false, type: 'hex' }), 1);
assert.equal(sceneGridTokenFitFactor({ enabled: true, type: 'square' }), 1);
});
void test('sceneGridTokenFitFactor: hex uses inscribed diameter', () => {
assert.ok(Math.abs(sceneGridTokenFitFactor({ enabled: true, type: 'hex' }) - Math.sqrt(3) / 2) < 1e-9);
});
+11
View File
@@ -59,3 +59,14 @@ export function normalizeSceneGrid(raw: unknown): SceneGrid {
export function sceneGridTypeLabelRu(type: SceneGridType): string { export function sceneGridTypeLabelRu(type: SceneGridType): string {
return type === 'hex' ? 'Гексогональная' : 'Квадратная'; return type === 'hex' ? 'Гексогональная' : 'Квадратная';
} }
/**
* Множитель диаметра токена «в одну ячейку» относительно `grid.sizeN`.
* Square: сторона клетки = sizeN.
* Flat-top hex: sizeN ширина (vertex-to-vertex, описанная окружность);
* вписанная окружность = flat-to-flat = sizeN · 3/2.
*/
export function sceneGridTokenFitFactor(grid: Pick<SceneGrid, 'enabled' | 'type'> | null | undefined): number {
if (!grid?.enabled || grid.type !== 'hex') return 1;
return Math.sqrt(3) / 2;
}
+121
View File
@@ -0,0 +1,121 @@
import fs from 'node:fs/promises';
import { createRequire } from 'node:module';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { test as base, expect, _electron as electron, type ElectronApplication, type Page } from '@playwright/test';
const require = createRequire(import.meta.url);
const electronExecutable = require('electron') as string;
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const mainEntry = path.join(root, 'dist/main/index.cjs');
const fixturePng = path.join(root, 'e2e/fixtures/sample.png');
const SAMPLE_PNG_B64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
export type ElectronFixtures = {
electronApp: ElectronApplication;
editorWindow: Page;
userDataDir: string;
sampleImagePath: string;
};
async function ensureBuilt() {
try {
await fs.access(mainEntry);
const editorHtml = await fs.readFile(path.join(root, 'dist/renderer/editor.html'), 'utf8');
// Production Vite build uses relative asset URLs (required for Electron file://).
if (editorHtml.includes('src="/assets/') || editorHtml.includes("src='/assets/")) {
throw new Error('dev-base');
}
} catch (err) {
const reason = err instanceof Error && err.message === 'dev-base' ? 'dev-base' : 'missing';
throw new Error(
reason === 'dev-base'
? 'dist/renderer was built with Vite base "/". Run "npm run build" (production) before e2e.'
: `Missing ${mainEntry}. Run "npm run build" (or "npm run test:e2e:build") before e2e.`,
);
}
}
export const test = base.extend<ElectronFixtures>({
userDataDir: async ({}, use) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-e2e-'));
await use(dir);
await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
},
sampleImagePath: async ({}, use) => {
await fs.mkdir(path.dirname(fixturePng), { recursive: true });
await fs.writeFile(fixturePng, Buffer.from(SAMPLE_PNG_B64, 'base64'));
await use(fixturePng);
},
electronApp: async ({ userDataDir }, use) => {
await ensureBuilt();
const app = await electron.launch({
executablePath: electronExecutable,
// Launch the package root so app.getAppPath() is the repo (not dist/main).
args: ['.', `--user-data-dir=${userDataDir}`],
cwd: root,
env: {
...process.env,
NODE_ENV: 'production',
DND_SKIP_LICENSE: '1',
DND_SKIP_BOOT: '1',
ELECTRON_DISABLE_SECURITY_WARNINGS: 'true',
},
});
await use(app);
try {
await app.evaluate(({ app: electronApp }) => {
electronApp.exit(0);
});
} catch {
/* ignore */
}
try {
await Promise.race([
app.close(),
new Promise<void>((resolve) => {
setTimeout(resolve, 3_000);
}),
]);
} catch {
/* ignore */
}
},
editorWindow: async ({ electronApp }, use) => {
const page = await electronApp.firstWindow();
await page.waitForLoadState('domcontentloaded');
await expect(page.getByTestId('players-header-btn')).toBeVisible({ timeout: 60_000 });
await use(page);
},
});
export { expect };
/** Stub native open-dialog to return a fixed image path. */
export async function stubOpenImageDialog(app: ElectronApplication, filePath: string) {
await app.evaluate(async ({ dialog }, chosen) => {
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [chosen] });
}, filePath);
}
export async function invokeInRenderer<T>(
page: Page,
channel: string,
payload: unknown = {},
): Promise<T> {
return page.evaluate(
async ({ channel: ch, payload: pl }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (window as any).dnd.invoke(ch, pl);
},
{ channel, payload },
);
}
+105
View File
@@ -0,0 +1,105 @@
import { test, expect, stubOpenImageDialog, invokeInRenderer } from './fixtures/electron';
test.describe('Players library', () => {
test('CRUD: add player with progress overlay, ring color persists in store', async ({
electronApp,
editorWindow,
sampleImagePath,
}) => {
await stubOpenImageDialog(electronApp, sampleImagePath);
await editorWindow.getByTestId('players-header-btn').click();
await expect(editorWindow.getByTestId('players-modal')).toBeVisible();
await editorWindow.getByTestId('players-add').click();
await editorWindow.getByPlaceholder(/Имя игрока|Player name/i).fill('Ada Lovelace');
await editorWindow.getByTestId('players-choose-image').click();
await expect(editorWindow.getByTestId('player-token-preview')).toBeVisible();
const colorInput = editorWindow.locator('input[type="color"]').first();
await colorInput.fill('#224466');
await editorWindow.getByTestId('players-save').click();
// Progress overlay may flash quickly; assert it either appeared or save finished.
await expect
.poll(async () => editorWindow.locator('[data-testid^="player-row-"]').count(), {
timeout: 30_000,
})
.toBe(1);
const listed = await invokeInRenderer<{ players: Array<{ name: string; ringColor: string }> }>(
editorWindow,
'players.list',
{},
);
expect(listed.players).toHaveLength(1);
expect(listed.players[0]!.name).toBe('Ada Lovelace');
expect(listed.players[0]!.ringColor.toLowerCase()).toBe('#224466');
});
test('Teams: assign then delete team leaves player ungrouped; no subteam UI', async ({
electronApp,
editorWindow,
sampleImagePath,
}) => {
await stubOpenImageDialog(electronApp, sampleImagePath);
await editorWindow.getByTestId('players-header-btn').click();
const modal = editorWindow.getByTestId('players-modal');
await expect(modal).toBeVisible();
await expect(modal).not.toContainText(/подкоманд|sub-?team|parentId/i);
await editorWindow.getByTestId('players-add').click();
await editorWindow.getByPlaceholder(/Имя игрока|Player name/i).fill('Bob');
await editorWindow.getByTestId('players-choose-image').click();
await editorWindow.getByTestId('players-save').click();
await expect(editorWindow.locator('[data-testid^="player-row-"]')).toHaveCount(1, {
timeout: 30_000,
});
const dialogHandler = async (d: { message: () => string; accept: (text?: string) => Promise<void> }) => {
const msg = d.message();
if (/команд|team/i.test(msg) && /цвет|color/i.test(msg)) await d.accept('#ff0000');
else if (/цвет|color/i.test(msg)) await d.accept('#ff0000');
else await d.accept('Party');
};
editorWindow.on('dialog', dialogHandler);
await editorWindow.getByTestId('players-add-team').click();
// Allow prompts to settle
await editorWindow.waitForTimeout(500);
editorWindow.off('dialog', dialogHandler);
const state = await invokeInRenderer<{
players: Array<{ id: string; teamId: string | null }>;
teams: Array<{ id: string; name: string }>;
}>(editorWindow, 'players.list', {});
// If UI prompts failed (headless), create team via IPC.
let team = state.teams.find((t) => t.name === 'Party');
if (!team) {
const created = await invokeInRenderer<{ team: { id: string; name: string } }>(
editorWindow,
'players.upsertTeam',
{ name: 'Party', color: '#ff0000' },
);
team = created.team;
}
const player = state.players[0]!;
expect(team).toBeTruthy();
await invokeInRenderer(editorWindow, 'players.assignTeam', {
playerId: player.id,
teamId: team!.id,
});
let mid = await invokeInRenderer<{ players: Array<{ teamId: string | null }> }>(
editorWindow,
'players.list',
{},
);
expect(mid.players[0]!.teamId).toBe(team!.id);
await invokeInRenderer(editorWindow, 'players.deleteTeam', { id: team!.id });
mid = await invokeInRenderer(editorWindow, 'players.list', {});
expect(mid.players[0]!.teamId).toBeNull();
});
});
+205
View File
@@ -0,0 +1,205 @@
import { test, expect, invokeInRenderer, stubOpenImageDialog } from './fixtures/electron';
type Project = {
id: string;
currentSceneId: string | null;
scenes: Record<
string,
{
tokens: unknown[];
npcTokens: Array<{ id: string; npcId: string; nx: number; ny: number; sizeN: number }>;
}
>;
npcs: Array<{ id: string; name: string }>;
};
async function seedProjectWithNpcAndPlacement(
page: import('@playwright/test').Page,
sampleImagePath: string,
) {
await invokeInRenderer<{ project: Project }>(page, 'project.create', {
name: `E2E Scene ${Date.now()}`,
});
const sceneId = `scene_e2e_${Date.now()}`;
await invokeInRenderer(page, 'project.updateScene', {
sceneId,
patch: {
title: 'Map',
description: '',
media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
layout: { x: 0, y: 0 },
previewAssetId: null,
previewAssetType: null,
previewVideoAutostart: false,
npcTokens: [],
tokens: [],
traps: [],
},
});
await invokeInRenderer(page, 'project.setCurrentScene', { sceneId });
await invokeInRenderer(page, 'project.importScenePreview', {
sceneId,
filePath: sampleImagePath,
});
const npcUpsert = await invokeInRenderer<{ project: Project }>(page, 'project.upsertNpc', {
name: `Guard ${Date.now()}`,
filePath: sampleImagePath,
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
});
const npc = npcUpsert.project.npcs[npcUpsert.project.npcs.length - 1]!;
const placementId = `snt_e2e_${Date.now()}`;
await invokeInRenderer(page, 'project.updateScene', {
sceneId,
patch: {
npcTokens: [
{
id: placementId,
npcId: npc.id,
nx: 0.35,
ny: 0.4,
sizeN: 0.12,
disposition: 'neutral',
},
],
},
});
const refreshed = await invokeInRenderer<{ project: Project }>(page, 'project.get', {});
return {
sceneId,
npcId: npc.id,
placementId,
project: refreshed.project,
};
}
test.describe('Scene NPC tokens', () => {
test('scene editor shows circular NPC token; clear removes both kinds', async ({
electronApp,
editorWindow,
sampleImagePath,
}) => {
await stubOpenImageDialog(electronApp, sampleImagePath);
const seeded = await seedProjectWithNpcAndPlacement(editorWindow, sampleImagePath);
const token = await invokeInRenderer<{ token: { id: string } }>(editorWindow, 'tokens.upsert', {
name: `Prop ${Date.now()}`,
filePath: sampleImagePath,
});
await invokeInRenderer(editorWindow, 'project.updateScene', {
sceneId: seeded.sceneId,
patch: {
tokens: [
{
id: `st_${Date.now()}`,
tokenId: token.token.id,
nx: 0.7,
ny: 0.7,
sizeN: 0.1,
rotationDeg: 0,
},
],
npcTokens: [
{
id: seeded.placementId,
npcId: seeded.npcId,
nx: 0.35,
ny: 0.4,
sizeN: 0.12,
},
],
},
});
const winPromise = electronApp.waitForEvent('window');
await invokeInRenderer(editorWindow, 'windows.openSceneEditor', {});
const sceneWin = await winPromise;
await sceneWin.waitForLoadState('domcontentloaded');
const accordion = sceneWin.getByTestId('scene-npc-accordion');
await expect(accordion).toBeVisible({ timeout: 30_000 });
// Expand if collapsed
await accordion.locator('button').first().click().catch(() => undefined);
await expect(sceneWin.getByTestId(`scene-npc-tile-${seeded.npcId}`)).toBeVisible({
timeout: 15_000,
});
await expect(sceneWin.getByTestId(`scene-npc-token-${seeded.placementId}`)).toBeVisible();
await sceneWin.getByTestId('scene-clear-btn').click();
await expect(sceneWin.getByTestId(`scene-npc-token-${seeded.placementId}`)).toHaveCount(0);
await expect
.poll(
async () => {
const after = await invokeInRenderer<{ project: Project }>(editorWindow, 'project.get', {});
const scene = after.project.scenes[seeded.sceneId]!;
return { tokens: scene.tokens.length, npcTokens: scene.npcTokens.length };
},
{ timeout: 10_000 },
)
.toEqual({ tokens: 0, npcTokens: 0 });
await invokeInRenderer(editorWindow, 'windows.closeSceneEditor', {}).catch(() => undefined);
});
test('session move resets after re-open multiwindow', async ({
editorWindow,
sampleImagePath,
electronApp,
}) => {
await stubOpenImageDialog(electronApp, sampleImagePath);
const seeded = await seedProjectWithNpcAndPlacement(editorWindow, sampleImagePath);
// Need a graph start node for session; open multiwindow may still work with currentSceneId.
await invokeInRenderer(editorWindow, 'project.addSceneGraphNode', {
sceneId: seeded.sceneId,
x: 0,
y: 0,
});
const withNode = await invokeInRenderer<{ project: { sceneGraphNodes: Array<{ id: string }> } }>(
editorWindow,
'project.get',
{},
);
const nodeId = withNode.project.sceneGraphNodes[0]?.id;
if (nodeId) {
await invokeInRenderer(editorWindow, 'project.setSceneGraphNodeStart', {
graphNodeId: nodeId,
});
}
await invokeInRenderer(editorWindow, 'windows.openMultiWindow', {});
await editorWindow.waitForTimeout(1000);
await invokeInRenderer(editorWindow, 'sceneNpcTokensSession.dispatch', {
event: { kind: 'move', placementId: seeded.placementId, nx: 0.8, ny: 0.8 },
});
let session = await invokeInRenderer<{
state: { byPlacementId: Record<string, { nx: number; ny: number }> };
}>(editorWindow, 'sceneNpcTokensSession.getState', {});
expect(session.state.byPlacementId[seeded.placementId]?.nx).toBeCloseTo(0.8, 5);
await invokeInRenderer(editorWindow, 'windows.closeMultiWindow', {});
await editorWindow.waitForTimeout(500);
await invokeInRenderer(editorWindow, 'windows.openMultiWindow', {});
await editorWindow.waitForTimeout(500);
session = await invokeInRenderer(editorWindow, 'sceneNpcTokensSession.getState', {});
expect(session.state.byPlacementId[seeded.placementId]).toBeUndefined();
const proj = await invokeInRenderer<{ project: Project }>(editorWindow, 'project.get', {});
const placement = proj.project.scenes[seeded.sceneId]!.npcTokens.find(
(t) => t.id === seeded.placementId,
);
expect(placement?.nx).toBeCloseTo(0.35, 5);
expect(placement?.ny).toBeCloseTo(0.4, 5);
await invokeInRenderer(editorWindow, 'windows.closeMultiWindow', {}).catch(() => undefined);
});
});
+64
View File
@@ -25,6 +25,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@playwright/test": "^1.62.0",
"@resvg/resvg-js": "^2.6.2", "@resvg/resvg-js": "^2.6.2",
"@rollup/plugin-strip": "^3.0.4", "@rollup/plugin-strip": "^3.0.4",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
@@ -2472,6 +2473,22 @@
"url": "https://opencollective.com/pkgr" "url": "https://opencollective.com/pkgr"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@reactflow/background": { "node_modules/@reactflow/background": {
"version": "11.3.14", "version": "11.3.14",
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz", "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
@@ -12338,6 +12355,53 @@
"url": "https://opencollective.com/pixijs" "url": "https://opencollective.com/pixijs"
} }
}, },
"node_modules/playwright": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/plist": { "node_modules/plist": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
+5 -2
View File
@@ -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/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 && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs", "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/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/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs",
"format": "prettier . --check", "format": "prettier . --check",
"format:write": "prettier . --write", "format:write": "prettier . --write",
"postinstall": "patch-package", "postinstall": "patch-package",
@@ -23,7 +23,9 @@
"release": "powershell -ExecutionPolicy Bypass -File scripts/ttrpg-release/release.ps1", "release": "powershell -ExecutionPolicy Bypass -File scripts/ttrpg-release/release.ps1",
"release:all": "powershell -ExecutionPolicy Bypass -File scripts/ttrpg-release/release-all.ps1", "release:all": "powershell -ExecutionPolicy Bypass -File scripts/ttrpg-release/release-all.ps1",
"prepare:release": "powershell -ExecutionPolicy Bypass -File scripts/ttrpg-release/prepare-release.ps1", "prepare:release": "powershell -ExecutionPolicy Bypass -File scripts/ttrpg-release/prepare-release.ps1",
"publish:updates": "powershell -ExecutionPolicy Bypass -File scripts/ttrpg-release/publish.ps1" "publish:updates": "powershell -ExecutionPolicy Bypass -File scripts/ttrpg-release/publish.ps1",
"test:e2e": "playwright test",
"test:e2e:build": "npm run build && playwright test"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@@ -45,6 +47,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@playwright/test": "^1.62.0",
"@resvg/resvg-js": "^2.6.2", "@resvg/resvg-js": "^2.6.2",
"@rollup/plugin-strip": "^3.0.4", "@rollup/plugin-strip": "^3.0.4",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 90_000,
expect: { timeout: 15_000 },
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [['list']],
use: {
trace: 'retain-on-failure',
},
});