feat(players): app Players library and circular NPC tokens on scenes

Add userData players/teams, scene npcTokens with hex-inscribed sizing, session scale synced to presentation, and Playwright e2e coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-30 13:38:35 +08:00
parent a3a03eb9e3
commit 101f595bac
57 changed files with 4176 additions and 317 deletions
+237 -113
View File
@@ -29,6 +29,8 @@ import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
import { ZipProjectStore } from './project/zipStore';
import { SceneViewStore } from './sceneView/sceneViewStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { PlayersStore } from './players/playersStore';
import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore';
import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore';
import { TokensStore } from './tokens/tokensStore';
import { installAutoUpdater } from './update/installAutoUpdater';
@@ -86,21 +88,13 @@ function emitScenePreviewImportProgress(evt: ScenePreviewImportEvent): void {
}
}
function emitMaterialUpsertProgress(evt: {
percent: number;
stage: string;
detail?: string;
}): void {
function emitMaterialUpsertProgress(evt: { percent: number; stage: string; detail?: string }): void {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.project.materialUpsertProgress, evt);
}
}
function emitNpcUpsertProgress(evt: {
percent: number;
stage: string;
detail?: string;
}): void {
function emitNpcUpsertProgress(evt: { percent: number; stage: string; detail?: string }): void {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.project.npcUpsertProgress, evt);
}
@@ -179,7 +173,9 @@ const videoStore = new VideoPlaybackStore();
const materialsOverlayStore = new MaterialsOverlayStore();
const npcsOverlayStore = new NpcsOverlayStore();
const sceneTokensSessionStore = new SceneTokensSessionStore();
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
let tokensStore: TokensStore | null = null;
let playersStore: PlayersStore | null = null;
function emitEffectsState(): void {
const state = effectsStore.getState();
@@ -248,6 +244,27 @@ 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 syncSceneDarknessForProject(project: Project): void {
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined;
@@ -397,11 +414,13 @@ async function main() {
const licenseService = new LicenseService(app.getPath('userData'));
tokensStore = new TokensStore(app.getPath('userData'));
await tokensStore.ensureLoaded();
playersStore = new PlayersStore(app.getPath('userData'));
await playersStore.ensureLoaded();
setLicenseAssert(() => {
licenseService.assertForIpc();
});
installAppMenuForSession();
registerDndAssetProtocol(projectStore, tokensStore);
registerDndAssetProtocol(projectStore, tokensStore, playersStore);
registerHandler(ipcChannels.app.quit, () => {
markAppQuitting();
app.quit();
@@ -420,6 +439,7 @@ async function main() {
sceneDarknessStore.resetSession();
sceneTrapsStore.resetSession();
sceneTokensSessionStore.reset();
sceneNpcTokensSessionStore.reset();
effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() });
openMultiWindow();
const project = projectStore.getOpenProject();
@@ -430,6 +450,7 @@ async function main() {
emitSceneDarknessState();
emitSceneTrapsState();
emitSceneTokensSessionState();
emitSceneNpcTokensSessionState();
emitEffectsState();
return { ok: true };
});
@@ -526,8 +547,10 @@ async function main() {
const project = await projectStore.openProjectById(projectId);
sceneViewStore.reset();
sceneTokensSessionStore.reset();
sceneNpcTokensSessionStore.reset();
emitSceneViewState();
emitSceneTokensSessionState();
emitSceneNpcTokensSessionState();
emitSessionState();
warmNpcsEditorWindow();
return { project };
@@ -542,6 +565,7 @@ async function main() {
sceneTrapsStore.resetSession();
sceneViewStore.reset();
sceneTokensSessionStore.reset();
sceneNpcTokensSessionStore.reset();
emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
@@ -549,6 +573,7 @@ async function main() {
emitSceneTrapsState();
emitSceneViewState();
emitSceneTokensSessionState();
emitSceneNpcTokensSessionState();
emitSessionState();
return { ok: true };
});
@@ -684,36 +709,39 @@ async function main() {
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.upsertMaterial, async ({ materialId, name, filePath: pathFromDrop }) => {
let filePath = pathFromDrop;
if (!filePath && !materialId) {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('images', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
if (canceled || filePaths.length === 0) {
throw new Error('Material image is required');
registerHandler(
ipcChannels.project.upsertMaterial,
async ({ materialId, name, filePath: pathFromDrop }) => {
let filePath = pathFromDrop;
if (!filePath && !materialId) {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('images', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
if (canceled || filePaths.length === 0) {
throw new Error('Material image is required');
}
filePath = filePaths[0];
}
filePath = filePaths[0];
}
const project = await projectStore.upsertMaterial(
{
...(materialId ? { materialId } : {}),
name,
...(filePath ? { filePath } : {}),
},
(p) => emitMaterialUpsertProgress(p),
);
syncMaterialsOverlayWithProject(project);
emitMaterialsOverlayState();
emitSessionState();
return { project };
});
const project = await projectStore.upsertMaterial(
{
...(materialId ? { materialId } : {}),
name,
...(filePath ? { filePath } : {}),
},
(p) => emitMaterialUpsertProgress(p),
);
syncMaterialsOverlayWithProject(project);
emitMaterialsOverlayState();
emitSessionState();
return { project };
},
);
registerHandler(ipcChannels.project.deleteMaterial, async ({ materialId }) => {
const project = await projectStore.deleteMaterial(materialId);
syncMaterialsOverlayWithProject(project);
@@ -750,14 +778,13 @@ async function main() {
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 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.project.upsertNpc,
async ({ npcId, name, description, filePath: pathFromDrop, groupId }) => {
async ({ npcId, name, description, filePath: pathFromDrop, groupId, ringColor, imageOffset }) => {
let filePath = pathFromDrop;
if (!filePath && !npcId) {
const { canceled, filePaths } = await dialog.showOpenDialog({
@@ -781,6 +808,8 @@ async function main() {
...(typeof description === 'string' ? { description } : {}),
...(filePath ? { filePath } : {}),
...(groupId !== undefined ? { groupId } : {}),
...(ringColor !== undefined ? { ringColor } : {}),
...(imageOffset !== undefined ? { imageOffset } : {}),
},
(p) => emitNpcUpsertProgress(p),
);
@@ -792,11 +821,13 @@ async function main() {
);
registerHandler(
ipcChannels.project.updateNpcFields,
async ({ npcId, name, description, groupId }) => {
async ({ npcId, name, description, groupId, ringColor, imageOffset }) => {
const project = await projectStore.updateNpcFields(npcId, {
...(typeof name === 'string' ? { name } : {}),
...(typeof description === 'string' ? { description } : {}),
...(groupId !== undefined ? { groupId } : {}),
...(ringColor !== undefined ? { ringColor } : {}),
...(imageOffset !== undefined ? { imageOffset } : {}),
});
emitSessionState();
return { project };
@@ -833,8 +864,7 @@ async function main() {
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 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 };
});
@@ -856,19 +886,16 @@ async function main() {
emitSessionState();
return { project };
});
registerHandler(
ipcChannels.project.upsertNpcGroup,
async ({ groupId, name, color, parentId }) => {
const project = await projectStore.upsertNpcGroup({
...(groupId ? { groupId } : {}),
name,
...(typeof color === 'string' ? { color } : {}),
...(parentId !== undefined ? { parentId } : {}),
});
emitSessionState();
return { project };
},
);
registerHandler(ipcChannels.project.upsertNpcGroup, async ({ groupId, name, color, parentId }) => {
const project = await projectStore.upsertNpcGroup({
...(groupId ? { groupId } : {}),
name,
...(typeof color === 'string' ? { color } : {}),
...(parentId !== undefined ? { parentId } : {}),
});
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.deleteNpcGroup, async ({ groupId }) => {
const project = await projectStore.deleteNpcGroup(groupId);
syncNpcsOverlayWithProject(project);
@@ -1042,9 +1069,12 @@ async function main() {
registerHandler(ipcChannels.project.peekImportZipPath, async ({ filePath, labels, targetHasMainStart }) => {
return projectStore.peekImportFromZipPath(filePath, labels, targetHasMainStart);
});
registerHandler(ipcChannels.project.peekImportFromProject, async ({ sourceProjectId, labels, targetHasMainStart }) => {
return projectStore.peekImportFromProjectId(sourceProjectId, labels, targetHasMainStart);
});
registerHandler(
ipcChannels.project.peekImportFromProject,
async ({ sourceProjectId, labels, targetHasMainStart }) => {
return projectStore.peekImportFromProjectId(sourceProjectId, labels, targetHasMainStart);
},
);
registerHandler(
ipcChannels.project.mergeImportZip,
async ({ filePath, storylineSelections, sceneResolutions, npcResolutions }) => {
@@ -1143,7 +1173,8 @@ async function main() {
const project = await projectStore.importProjectFromFoundry(sourcePath, (p) => {
emitZipProgress({
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,
...(p.detail ? { detail: p.detail } : null),
});
@@ -1156,53 +1187,56 @@ async function main() {
throw e;
}
});
registerHandler(ipcChannels.project.exportZip, async ({ projectId, storylineSelections, npcIds, labels }) => {
const list = await projectStore.listProjects();
const entry = list.find((p) => p.id === projectId);
if (!entry) {
throw new Error('Проект не найден');
}
const defaultName = isProjectZipFileName(entry.fileName)
? entry.fileName.toLowerCase().endsWith('.ttrpg.zip')
? entry.fileName
: projectZipFileNameFromBase(stripProjectZipExtension(entry.fileName))
: projectZipFileNameFromBase(stripProjectZipExtension(entry.fileName));
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: defaultName,
filters: [PROJECT_ZIP_SAVE_DIALOG_FILTER],
});
if (canceled || !filePath) {
return { canceled: true as const };
}
const dest = normalizeSaveProjectZipPath(filePath);
try {
emitZipProgress({ kind: 'export', stage: 'zip', percent: 0, detail: 'Экспорт…' });
await projectStore.exportStorylinesZipToPath(
projectId,
storylineSelections,
npcIds ?? [],
dest,
labels,
(p) => {
emitZipProgress({
kind: 'export',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
},
async (tokenIds, exportRoot) => {
await tokensStore!.packForExport(tokenIds, exportRoot);
},
);
emitZipProgress({ kind: 'export', stage: 'done', percent: 100, detail: 'Готово' });
return { canceled: false as const };
} catch (err) {
const detail = err instanceof Error ? err.message : 'Ошибка экспорта';
emitZipProgress({ kind: 'export', stage: 'error', percent: 0, detail });
throw err;
}
});
registerHandler(
ipcChannels.project.exportZip,
async ({ projectId, storylineSelections, npcIds, labels }) => {
const list = await projectStore.listProjects();
const entry = list.find((p) => p.id === projectId);
if (!entry) {
throw new Error('Проект не найден');
}
const defaultName = isProjectZipFileName(entry.fileName)
? entry.fileName.toLowerCase().endsWith('.ttrpg.zip')
? entry.fileName
: projectZipFileNameFromBase(stripProjectZipExtension(entry.fileName))
: projectZipFileNameFromBase(stripProjectZipExtension(entry.fileName));
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: defaultName,
filters: [PROJECT_ZIP_SAVE_DIALOG_FILTER],
});
if (canceled || !filePath) {
return { canceled: true as const };
}
const dest = normalizeSaveProjectZipPath(filePath);
try {
emitZipProgress({ kind: 'export', stage: 'zip', percent: 0, detail: 'Экспорт…' });
await projectStore.exportStorylinesZipToPath(
projectId,
storylineSelections,
npcIds ?? [],
dest,
labels,
(p) => {
emitZipProgress({
kind: 'export',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
},
async (tokenIds, exportRoot) => {
await tokensStore!.packForExport(tokenIds, exportRoot);
},
);
emitZipProgress({ kind: 'export', stage: 'done', percent: 100, detail: 'Готово' });
return { canceled: false as const };
} catch (err) {
const detail = err instanceof Error ? err.message : 'Ошибка экспорта';
emitZipProgress({ kind: 'export', stage: 'error', percent: 0, detail });
throw err;
}
},
);
registerHandler(ipcChannels.project.deleteProject, async ({ projectId }) => {
await projectStore.deleteProjectById(projectId);
emitSessionState();
@@ -1253,7 +1287,11 @@ async function main() {
return { tokens: tokensStore!.list() };
});
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();
return { token };
});
@@ -1288,8 +1326,7 @@ async function main() {
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 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 };
});
@@ -1305,6 +1342,93 @@ async function main() {
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.video.getState, () => {
return { state: videoStore.getState() };
});