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:
@@ -387,6 +387,7 @@ export async function buildProjectFromFoundryDocuments(
|
||||
darkenScene: false,
|
||||
traps: [],
|
||||
tokens: [],
|
||||
npcTokens: [],
|
||||
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
|
||||
media: { videos: [], audios: audioRefs },
|
||||
settings: {
|
||||
@@ -446,6 +447,9 @@ export async function buildProjectFromFoundryDocuments(
|
||||
x: 80 + (npcIndex % 4) * 220,
|
||||
y: 80 + Math.floor(npcIndex / 4) * 200,
|
||||
groupId,
|
||||
ringColor: '#c9a227',
|
||||
imageOffset: { x: 0, y: 0 },
|
||||
imageScale: 1,
|
||||
});
|
||||
npcIndex += 1;
|
||||
}
|
||||
|
||||
+237
-113
@@ -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() };
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,32 @@
|
||||
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, {});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
clampNpcTokenSessionScale,
|
||||
DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
type SceneNpcTokensSessionEvent,
|
||||
type SceneNpcTokensSessionState,
|
||||
} from '../../shared/types/appPlayers';
|
||||
|
||||
function emptyState(revision = 1): SceneNpcTokensSessionState {
|
||||
return {
|
||||
revision,
|
||||
byPlacementId: {},
|
||||
scale: DEFAULT_NPC_TOKEN_SESSION_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]: { nx, ny },
|
||||
},
|
||||
scale: this.state.scale,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = event;
|
||||
void _exhaustive;
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+117
-54
@@ -53,9 +53,23 @@ import type {
|
||||
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
|
||||
import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
|
||||
import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens';
|
||||
import {
|
||||
clampPlayerImageOffset,
|
||||
clampPlayerImageScale,
|
||||
DEFAULT_PLAYER_RING_COLOR,
|
||||
normalizeSceneNpcToken,
|
||||
type SceneNpcToken,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
|
||||
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 {
|
||||
asAssetId,
|
||||
asGraphNodeId,
|
||||
@@ -614,6 +628,7 @@ export class ZipProjectStore {
|
||||
darkenScene: false,
|
||||
traps: [],
|
||||
tokens: [],
|
||||
npcTokens: [],
|
||||
grid: { ...DEFAULT_SCENE_GRID },
|
||||
} satisfies Scene);
|
||||
|
||||
@@ -621,6 +636,7 @@ export class ZipProjectStore {
|
||||
...base,
|
||||
traps: base.traps ?? [],
|
||||
tokens: base.tokens ?? [],
|
||||
npcTokens: base.npcTokens ?? [],
|
||||
grid: base.grid ?? { ...DEFAULT_SCENE_GRID },
|
||||
...(patch.title !== undefined ? { title: patch.title } : null),
|
||||
...(patch.description !== undefined ? { description: patch.description } : null),
|
||||
@@ -636,9 +652,7 @@ export class ZipProjectStore {
|
||||
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
||||
...(patch.traps !== undefined
|
||||
? {
|
||||
traps: patch.traps
|
||||
.map((t) => normalizeSceneTrap(t))
|
||||
.filter((t): t is SceneTrap => Boolean(t)),
|
||||
traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)),
|
||||
}
|
||||
: null),
|
||||
...(patch.tokens !== undefined
|
||||
@@ -648,6 +662,13 @@ export class ZipProjectStore {
|
||||
.filter((t): t is SceneToken => Boolean(t)),
|
||||
}
|
||||
: 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.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
|
||||
...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
|
||||
@@ -798,7 +819,10 @@ export class ZipProjectStore {
|
||||
const node = open.project.sceneGraphNodes.find((n) => n.id === graphNodeId);
|
||||
if (!node) throw new Error('Graph node not found');
|
||||
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;
|
||||
}
|
||||
await this.updateProject((p) => {
|
||||
@@ -1158,16 +1182,11 @@ export class ZipProjectStore {
|
||||
return latest;
|
||||
}
|
||||
|
||||
async setMaterialRotation(
|
||||
materialId: MaterialId,
|
||||
rotationDeg: 0 | 90 | 180 | 270,
|
||||
): Promise<Project> {
|
||||
async setMaterialRotation(materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
await this.updateProject((p) => {
|
||||
const materials = (p.materials ?? []).map((m) =>
|
||||
m.id === materialId ? { ...m, rotationDeg } : m,
|
||||
);
|
||||
const materials = (p.materials ?? []).map((m) => (m.id === materialId ? { ...m, rotationDeg } : m));
|
||||
return { ...p, materials };
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
@@ -1243,6 +1262,9 @@ export class ZipProjectStore {
|
||||
description?: string;
|
||||
filePath?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
ringColor?: string;
|
||||
imageOffset?: { x: number; y: number };
|
||||
imageScale?: number;
|
||||
},
|
||||
onProgress?: (p: { percent: number; stage: string; detail?: string }) => void,
|
||||
): Promise<Project> {
|
||||
@@ -1300,7 +1322,10 @@ export class ZipProjectStore {
|
||||
if (stagedAsset) assets[stagedAsset.id] = stagedAsset;
|
||||
|
||||
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 === null) return null;
|
||||
return groupIds.has(raw) ? raw : null;
|
||||
@@ -1314,9 +1339,17 @@ export class ZipProjectStore {
|
||||
...prev,
|
||||
name,
|
||||
avatarAssetId: nextAssetId ?? prev.avatarAssetId,
|
||||
description:
|
||||
typeof input.description === 'string' ? input.description : prev.description,
|
||||
description: typeof input.description === 'string' ? input.description : prev.description,
|
||||
groupId: resolveGroup(input.groupId, prev.groupId),
|
||||
...(input.ringColor !== undefined
|
||||
? { ringColor: normalizeHexColor(input.ringColor, DEFAULT_PLAYER_RING_COLOR) }
|
||||
: {}),
|
||||
...(input.imageOffset !== undefined
|
||||
? { imageOffset: clampPlayerImageOffset(input.imageOffset) }
|
||||
: {}),
|
||||
...(input.imageScale !== undefined
|
||||
? { imageScale: clampPlayerImageScale(input.imageScale) }
|
||||
: {}),
|
||||
};
|
||||
} else {
|
||||
if (!nextAssetId) throw new Error('NPC avatar is required');
|
||||
@@ -1329,6 +1362,9 @@ export class ZipProjectStore {
|
||||
x: 80 + (count % 4) * 220,
|
||||
y: 80 + Math.floor(count / 4) * 200,
|
||||
groupId: resolveGroup(input.groupId, null),
|
||||
ringColor: normalizeHexColor(input.ringColor, DEFAULT_PLAYER_RING_COLOR),
|
||||
imageOffset: clampPlayerImageOffset(input.imageOffset),
|
||||
imageScale: clampPlayerImageScale(input.imageScale),
|
||||
});
|
||||
}
|
||||
return { ...p, assets, npcs };
|
||||
@@ -1346,20 +1382,18 @@ export class ZipProjectStore {
|
||||
name?: string;
|
||||
description?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
ringColor?: string;
|
||||
imageOffset?: { x: number; y: number };
|
||||
imageScale?: number;
|
||||
},
|
||||
): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
const name =
|
||||
typeof patch.name === 'string' ? patch.name.trim() : undefined;
|
||||
const name = typeof patch.name === 'string' ? patch.name.trim() : undefined;
|
||||
if (name !== undefined) {
|
||||
if (name.length < 1) throw new Error('NPC name is required');
|
||||
const nameKey = name.toLowerCase();
|
||||
if (
|
||||
(open.project.npcs ?? []).some(
|
||||
(n) => n.id !== npcId && n.name.trim().toLowerCase() === nameKey,
|
||||
)
|
||||
) {
|
||||
if ((open.project.npcs ?? []).some((n) => n.id !== npcId && n.name.trim().toLowerCase() === nameKey)) {
|
||||
throw new Error('NPC name already exists');
|
||||
}
|
||||
}
|
||||
@@ -1369,14 +1403,22 @@ export class ZipProjectStore {
|
||||
if (n.id !== npcId) return n;
|
||||
let groupId = n.groupId;
|
||||
if (patch.groupId !== undefined) {
|
||||
groupId =
|
||||
patch.groupId === null ? null : groupIds.has(patch.groupId) ? patch.groupId : null;
|
||||
groupId = patch.groupId === null ? null : groupIds.has(patch.groupId) ? patch.groupId : null;
|
||||
}
|
||||
return {
|
||||
...n,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(typeof patch.description === 'string' ? { description: patch.description } : {}),
|
||||
groupId,
|
||||
...(patch.ringColor !== undefined
|
||||
? { ringColor: normalizeHexColor(patch.ringColor, DEFAULT_PLAYER_RING_COLOR) }
|
||||
: {}),
|
||||
...(patch.imageOffset !== undefined
|
||||
? { imageOffset: clampPlayerImageOffset(patch.imageOffset) }
|
||||
: {}),
|
||||
...(patch.imageScale !== undefined
|
||||
? { imageScale: clampPlayerImageScale(patch.imageScale) }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
return { ...p, npcs };
|
||||
@@ -1401,13 +1443,25 @@ export class ZipProjectStore {
|
||||
async deleteNpc(npcId: NpcId): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
await this.updateProject((p) => ({
|
||||
...p,
|
||||
npcs: (p.npcs ?? []).filter((n) => n.id !== npcId),
|
||||
npcRelations: (p.npcRelations ?? []).filter(
|
||||
(r) => r.sourceNpcId !== npcId && r.targetNpcId !== npcId,
|
||||
),
|
||||
}));
|
||||
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,
|
||||
scenes,
|
||||
npcs: (p.npcs ?? []).filter((n) => n.id !== npcId),
|
||||
npcRelations: (p.npcRelations ?? []).filter(
|
||||
(r) => r.sourceNpcId !== npcId && r.targetNpcId !== npcId,
|
||||
),
|
||||
};
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
@@ -1462,10 +1516,7 @@ export class ZipProjectStore {
|
||||
}
|
||||
const nameKey = name.toLowerCase();
|
||||
const siblingConflict = groups.some(
|
||||
(g) =>
|
||||
g.id !== editingId &&
|
||||
g.parentId === parentId &&
|
||||
g.name.trim().toLowerCase() === nameKey,
|
||||
(g) => g.id !== editingId && g.parentId === parentId && g.name.trim().toLowerCase() === nameKey,
|
||||
);
|
||||
if (siblingConflict) throw new Error('Group name already exists');
|
||||
|
||||
@@ -1498,9 +1549,7 @@ export class ZipProjectStore {
|
||||
const nextGroups = groups
|
||||
.filter((g) => g.id !== groupId)
|
||||
.map((g) => (g.parentId === groupId ? { ...g, parentId: parentOfDeleted } : g));
|
||||
const npcs = (p.npcs ?? []).map((n) =>
|
||||
n.groupId === groupId ? { ...n, groupId: null } : n,
|
||||
);
|
||||
const npcs = (p.npcs ?? []).map((n) => (n.groupId === groupId ? { ...n, groupId: null } : n));
|
||||
return { ...p, npcGroups: nextGroups, npcs };
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
@@ -1541,10 +1590,7 @@ export class ZipProjectStore {
|
||||
if (label.length < 1) throw new Error('Relation label is required');
|
||||
if (input.sourceNpcId === input.targetNpcId) throw new Error('Cannot relate NPC to itself');
|
||||
const npcs = open.project.npcs ?? [];
|
||||
if (
|
||||
!npcs.some((n) => n.id === input.sourceNpcId) ||
|
||||
!npcs.some((n) => n.id === input.targetNpcId)
|
||||
) {
|
||||
if (!npcs.some((n) => n.id === input.sourceNpcId) || !npcs.some((n) => n.id === input.targetNpcId)) {
|
||||
throw new Error('NPC not found');
|
||||
}
|
||||
await this.updateProject((p) => {
|
||||
@@ -2089,16 +2135,14 @@ export class ZipProjectStore {
|
||||
sourceForMerge = remapProjectSceneTokenIds(source, remap);
|
||||
}
|
||||
const offsetX = computeGraphImportOffsetX(this.openProject.project);
|
||||
const { project: merged, report, assetCopies } = mergeStorylinesIntoProject(
|
||||
this.openProject.project,
|
||||
sourceForMerge,
|
||||
selections,
|
||||
sceneResolutions,
|
||||
{
|
||||
graphOffsetX: offsetX,
|
||||
...(npcResolutions ? { npcResolutions } : {}),
|
||||
},
|
||||
);
|
||||
const {
|
||||
project: merged,
|
||||
report,
|
||||
assetCopies,
|
||||
} = mergeStorylinesIntoProject(this.openProject.project, sourceForMerge, selections, sceneResolutions, {
|
||||
graphOffsetX: offsetX,
|
||||
...(npcResolutions ? { npcResolutions } : {}),
|
||||
});
|
||||
|
||||
const targetCache = this.openProject.cacheDir;
|
||||
await fs.mkdir(path.join(targetCache, 'assets'), { recursive: true });
|
||||
@@ -2307,6 +2351,10 @@ function normalizeScene(s: Scene): Scene {
|
||||
const tokens = (Array.isArray(rawTokens) ? rawTokens : [])
|
||||
.map((t) => normalizeSceneToken(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 rawAudios = Array.isArray(raw.audios) ? raw.audios : [];
|
||||
@@ -2338,6 +2386,7 @@ function normalizeScene(s: Scene): Scene {
|
||||
darkenScene,
|
||||
traps,
|
||||
tokens,
|
||||
npcTokens,
|
||||
grid,
|
||||
layout: layoutIn ?? { x: 0, y: 0 },
|
||||
media: {
|
||||
@@ -2429,6 +2478,8 @@ function normalizeProject(p: Project): Project {
|
||||
x?: number;
|
||||
y?: number;
|
||||
groupId?: string | null;
|
||||
ringColor?: string;
|
||||
imageOffset?: unknown;
|
||||
};
|
||||
if (!obj.id || !obj.avatarAssetId || typeof obj.name !== 'string') return null;
|
||||
const name = obj.name.trim();
|
||||
@@ -2444,6 +2495,9 @@ function normalizeProject(p: Project): Project {
|
||||
x,
|
||||
y,
|
||||
groupId: resolveNpcGroupId(obj.groupId, groupIdSet),
|
||||
ringColor: normalizeHexColor(obj.ringColor, DEFAULT_PLAYER_RING_COLOR),
|
||||
imageOffset: clampPlayerImageOffset(obj.imageOffset),
|
||||
imageScale: clampPlayerImageScale((obj as { imageScale?: unknown }).imageScale),
|
||||
};
|
||||
})
|
||||
.filter((x): x is ProjectNpc => Boolean(x));
|
||||
@@ -2481,6 +2535,15 @@ function normalizeProject(p: Project): Project {
|
||||
if (a && a.length > 0) return a;
|
||||
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 {
|
||||
...p,
|
||||
meta: {
|
||||
@@ -2491,7 +2554,7 @@ function normalizeProject(p: Project): Project {
|
||||
createdWithAppVersion,
|
||||
schemaVersion: PROJECT_SCHEMA_VERSION,
|
||||
},
|
||||
scenes,
|
||||
scenes: scenesPruned,
|
||||
campaignAudios,
|
||||
materials,
|
||||
npcs,
|
||||
@@ -2501,7 +2564,7 @@ function normalizeProject(p: Project): Project {
|
||||
sceneGraphEdges,
|
||||
currentGraphNodeId,
|
||||
sceneListOrder: reconcileSceneListOrder(
|
||||
scenes,
|
||||
scenesPruned,
|
||||
(p as { sceneListOrder?: SceneId[] }).sceneListOrder,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -2,7 +2,8 @@ import fs from 'node:fs/promises';
|
||||
|
||||
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 { 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(
|
||||
projectStore: ZipProjectStore,
|
||||
tokensStore: TokensStore,
|
||||
playersStore: PlayersStore,
|
||||
): void {
|
||||
session.defaultSession.protocol.handle('dnd', async (request) => {
|
||||
const url = new URL(request.url);
|
||||
@@ -92,6 +94,8 @@ export function registerDndAssetProtocol(
|
||||
info = projectStore.getAssetReadInfo(asAssetId(id));
|
||||
} else if (url.hostname === 'token') {
|
||||
info = tokensStore.getImageReadInfo(asTokenId(id));
|
||||
} else if (url.hostname === 'player') {
|
||||
info = playersStore.getImageReadInfo(asPlayerId(id));
|
||||
}
|
||||
if (!info) {
|
||||
return new Response(null, { status: 404 });
|
||||
|
||||
@@ -92,11 +92,7 @@ export class TokensStore {
|
||||
return path.join(this.rootDir, relPath);
|
||||
}
|
||||
|
||||
async upsert(input: {
|
||||
id?: TokenId | null;
|
||||
name: string;
|
||||
filePath?: string | null;
|
||||
}): Promise<AppToken> {
|
||||
async upsert(input: { id?: TokenId | null; name: string; filePath?: string | null }): Promise<AppToken> {
|
||||
await this.ensureLoaded();
|
||||
const name = input.name.trim();
|
||||
if (!name) throw new Error('Token name is required');
|
||||
@@ -166,8 +162,7 @@ export class TokensStore {
|
||||
}): Promise<{ token: AppToken; remappedFrom: TokenId }> {
|
||||
await this.ensureLoaded();
|
||||
let buf = await fs.readFile(input.absFilePath);
|
||||
const sha256 =
|
||||
input.sha256 ?? crypto.createHash('sha256').update(buf).digest('hex');
|
||||
const sha256 = input.sha256 ?? crypto.createHash('sha256').update(buf).digest('hex');
|
||||
const existingByHash = this.findBySha256(sha256);
|
||||
if (existingByHash) {
|
||||
return { token: existingByHash, remappedFrom: input.preferredId };
|
||||
@@ -262,7 +257,7 @@ export class TokensStore {
|
||||
preferredId: asTokenId(t.id),
|
||||
name: typeof t.name === 'string' ? t.name : 'Token',
|
||||
absFilePath: abs,
|
||||
sha256: typeof t.sha256 === 'string' ? t.sha256 : undefined,
|
||||
...(typeof t.sha256 === 'string' ? { sha256: t.sha256 } : {}),
|
||||
});
|
||||
remap.set(remappedFrom, token.id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user