diff --git a/.gitignore b/.gitignore index d0e5833..132e533 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ Thumbs.db .vscode/* !.vscode/extensions.json *.tsbuildinfo +test-results/ +playwright-report/ +e2e/fixtures/sample.png diff --git a/app/main/foundry/foundryImport.ts b/app/main/foundry/foundryImport.ts index a3d08e9..3f68e3f 100644 --- a/app/main/foundry/foundryImport.ts +++ b/app/main/foundry/foundryImport.ts @@ -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; } diff --git a/app/main/index.ts b/app/main/index.ts index 74b800e..86f0541 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -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() }; }); diff --git a/app/main/players/playersStore.test.ts b/app/main/players/playersStore.test.ts new file mode 100644 index 0000000..d7115be --- /dev/null +++ b/app/main/players/playersStore.test.ts @@ -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) { + 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'); + }); +}); diff --git a/app/main/players/playersStore.ts b/app/main/players/playersStore.ts new file mode 100644 index 0000000..819a8f7 --- /dev/null +++ b/app/main/players/playersStore.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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'); + } +} diff --git a/app/main/players/sceneNpcTokensSessionStore.test.ts b/app/main/players/sceneNpcTokensSessionStore.test.ts new file mode 100644 index 0000000..5965fe0 --- /dev/null +++ b/app/main/players/sceneNpcTokensSessionStore.test.ts @@ -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, {}); +}); diff --git a/app/main/players/sceneNpcTokensSessionStore.ts b/app/main/players/sceneNpcTokensSessionStore.ts new file mode 100644 index 0000000..35c3674 --- /dev/null +++ b/app/main/players/sceneNpcTokensSessionStore.ts @@ -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; + } + } + } +} diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index ddc1650..29df496 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -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 { + async setMaterialRotation(materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270): Promise { 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 { @@ -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 { 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 { 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 = { ...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 = {}; + 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, ), }; diff --git a/app/main/protocol/dndAssetProtocol.ts b/app/main/protocol/dndAssetProtocol.ts index ef9892a..1c50aa0 100644 --- a/app/main/protocol/dndAssetProtocol.ts +++ b/app/main/protocol/dndAssetProtocol.ts @@ -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 { } /** - * Обслуживает `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 }); diff --git a/app/main/tokens/tokensStore.ts b/app/main/tokens/tokensStore.ts index 9855629..e7b4be4 100644 --- a/app/main/tokens/tokensStore.ts +++ b/app/main/tokens/tokensStore.ts @@ -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 { + async upsert(input: { id?: TokenId | null; name: string; filePath?: string | null }): Promise { 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); } diff --git a/app/renderer/control/ControlApp.module.css b/app/renderer/control/ControlApp.module.css index 258db76..96bfebb 100644 --- a/app/renderer/control/ControlApp.module.css +++ b/app/renderer/control/ControlApp.module.css @@ -302,9 +302,29 @@ .previewActions { display: flex; + align-items: center; 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 { color: var(--text2); font-size: var(--text-xs); diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 7c606b4..e729710 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -10,19 +10,19 @@ import { } from '../../shared/graph/sceneGraphLineage'; import type { GraphNodeId, Scene, SceneId, SceneViewCamera } from '../../shared/types'; import { - DEFAULT_SCENE_VIEW_CAMERA, - sceneViewPanBy, - sceneViewZoomAt, -} from '../../shared/types/sceneView'; + DEFAULT_NPC_TOKEN_SESSION_SCALE, + NPC_TOKEN_SESSION_SCALE_MAX, + NPC_TOKEN_SESSION_SCALE_MIN, +} from '../../shared/types/appPlayers'; +import { DEFAULT_SCENE_VIEW_CAMERA, sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml'; import { getDndApi } from '../shared/dndApi'; import { RotatedImage } from '../shared/RotatedImage'; +import { SceneNpcTokensOverlay } from '../shared/playerToken/SceneNpcTokensOverlay'; +import { useSceneNpcTokensSession } from '../shared/playerToken/useSceneNpcTokensSession'; import { ExplosionVideoOverlay } from '../shared/effects/ExplosionVideoOverlay'; -import { - PixiEffectsOverlay, - type PixiEffectsOverlayHandle, -} from '../shared/effects/PxiEffectsOverlay'; +import { PixiEffectsOverlay, type PixiEffectsOverlayHandle } from '../shared/effects/PxiEffectsOverlay'; import type { EffectInstance, EffectToolType, ExplosionInstance } from '../../shared/types/effects'; import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay'; import { useEffectsState } from '../shared/effects/useEffectsState'; @@ -69,12 +69,7 @@ function readAudioGain(gains: Map, assetId: string): number { } /** Применяет пользовательскую громкость; `factor` — для fade in/out (0…1). */ -function applyAudioGain( - el: HTMLAudioElement, - gains: Map, - assetId: string, - factor = 1, -): void { +function applyAudioGain(el: HTMLAudioElement, gains: Map, assetId: string, factor = 1): void { el.volume = clampAudioGain(readAudioGain(gains, assetId) * factor); } @@ -131,6 +126,7 @@ export function ControlApp() { const [sceneTraps, sceneTrapsApi] = useSceneTrapsState(); const appTokens = useAppTokens(); const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession(); + const [sceneNpcTokensSession, sceneNpcTokensApi] = useSceneNpcTokensSession(); const [sceneView, sceneViewApi] = useSceneViewState(); const [sceneViewDraft, setSceneViewDraft] = useState(null); const [materialsOverlay, materialsApi] = useMaterialsOverlayState(); @@ -683,10 +679,9 @@ export function ControlApp() { return () => ro.disconnect(); }, []); - const activeSceneViewCamera: SceneViewCamera = sceneViewDraft ?? - (sceneView - ? { scale: sceneView.scale, ox: sceneView.ox, oy: sceneView.oy } - : DEFAULT_SCENE_VIEW_CAMERA); + const activeSceneViewCamera: SceneViewCamera = + sceneViewDraft ?? + (sceneView ? { scale: sceneView.scale, ox: sceneView.ox, oy: sceneView.oy } : DEFAULT_SCENE_VIEW_CAMERA); sceneViewRef.current = activeSceneViewCamera; useEffect(() => { @@ -1381,9 +1376,11 @@ export function ControlApp() { title={hasSceneDescription ? t('control.descriptionTool') : t('control.descriptionMissing')} ariaLabel={hasSceneDescription ? t('control.descriptionTool') : t('control.descriptionMissing')} onClick={() => { - void api.invoke(ipcChannels.windows.openSceneDescription, { html: sceneDescription }).catch((err) => { - console.error('[control] openSceneDescription failed', err); - }); + void api + .invoke(ipcChannels.windows.openSceneDescription, { html: sceneDescription }) + .catch((err) => { + console.error('[control] openSceneDescription failed', err); + }); }} > @@ -1689,6 +1686,24 @@ export function ControlApp() {
{t('control.screenPreview')}
+ @@ -1701,9 +1716,7 @@ export function ControlApp() { ref={previewFrameRef} className={styles.previewFrame} title={ - isVideoPreviewScene - ? undefined - : 'Колесо — зум; перетаскивание СКМ/ПКМ или Space+ЛКМ — пан' + isVideoPreviewScene ? undefined : 'Колесо — зум; перетаскивание СКМ/ПКМ или Space+ЛКМ — пан' } >
@@ -1732,11 +1745,7 @@ export function ControlApp() { : undefined } /> - + {previewContentRect ? ( ) : null} @@ -1821,9 +1830,7 @@ export function ControlApp() { const dy = e.clientY - pan.lastY; pan.lastX = e.clientX; pan.lastY = e.clientY; - publishSceneViewCamera( - sceneViewPanBy(cam, { containW, containH, dx, dy }), - ); + publishSceneViewCamera(sceneViewPanBy(cam, { containW, containH, dx, dy })); return; } const p = toNPoint(e); @@ -1901,6 +1908,19 @@ export function ControlApp() { }} /> ) : null} + {previewContentRect ? ( + { + sceneNpcTokensApi.dispatch({ kind: 'move', placementId, nx, ny }); + }} + /> + ) : null} {previewContentRect ? ( { const mm = - sceneAudioMetaRef.current.get(ref.assetId) ?? - ({ lastPlayError: null } as const); + sceneAudioMetaRef.current.get(ref.assetId) ?? ({ lastPlayError: null } as const); sceneAudioMetaRef.current.set(ref.assetId, { ...mm, lastPlayError: t('control.playFailed'), @@ -2216,7 +2235,9 @@ export function ControlApp() { {...(!allowCampaignAudio ? { extraBadge: ( -
{t('control.pauseSceneMusic')}
+
+ {t('control.pauseSceneMusic')} +
), } : {})} @@ -2250,8 +2271,7 @@ export function ControlApp() { } void el.play().catch(() => { const mm = - campaignAudioMetaRef.current.get(ref.assetId) ?? - ({ lastPlayError: null } as const); + campaignAudioMetaRef.current.get(ref.assetId) ?? ({ lastPlayError: null } as const); campaignAudioMetaRef.current.set(ref.assetId, { ...mm, lastPlayError: t('control.playFailed'), diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx index 8f8c825..3ea5658 100644 --- a/app/renderer/editor/EditorApp.tsx +++ b/app/renderer/editor/EditorApp.tsx @@ -55,6 +55,7 @@ import type { HelpSectionId } from './help/helpSections'; import { useEditorI18n } from './i18n/EditorI18nContext'; import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals'; import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals'; +import { PlayersManagerModal } from './PlayersModals'; import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml'; import { SceneDescriptionModal } from './SceneDescriptionModal'; import type { ProjectNoticeCode } from './state/projectState'; @@ -134,9 +135,9 @@ export function EditorApp() { const [importConflictsOpen, setImportConflictsOpen] = useState(false); const [importConflicts, setImportConflicts] = useState>([]); const [importNpcConflictsOpen, setImportNpcConflictsOpen] = useState(false); - const [importNpcConflicts, setImportNpcConflicts] = useState< - ReturnType - >([]); + const [importNpcConflicts, setImportNpcConflicts] = useState>( + [], + ); const [pendingImportSelections, setPendingImportSelections] = useState([]); const [pendingSceneResolutions, setPendingSceneResolutions] = useState([]); const [importReportOpen, setImportReportOpen] = useState(false); @@ -154,6 +155,7 @@ export function EditorApp() { const licenseActive = licenseSnap?.active === true; const [appNotice, setAppNotice] = useState<{ title?: string; message: string } | null>(null); const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false); + const [playersManagerOpen, setPlayersManagerOpen] = useState(false); const [materialEdit, setMaterialEdit] = useState(null); const onProjectNotice = useCallback( (code: ProjectNoticeCode) => { @@ -546,12 +548,7 @@ export function EditorApp() { sceneResolutions, npcResolutions, ) - : await actions.mergeImportZip( - importPeek.filePath!, - selections, - sceneResolutions, - npcResolutions, - ); + : await actions.mergeImportZip(importPeek.filePath!, selections, sceneResolutions, npcResolutions); setImportReport(report); setImportReportOpen(true); clearImportFlow(); @@ -571,12 +568,7 @@ export function EditorApp() { setImportNpcConflictsOpen(true); return; } - const npcResolutions = buildNpcResolutionsForImport( - state.project, - importPeek.sourceProject, - [], - [], - ); + const npcResolutions = buildNpcResolutionsForImport(state.project, importPeek.sourceProject, [], []); void runStorylineMerge(selections, sceneResolutions, npcResolutions); }, [importPeek, runStorylineMerge, state.project], @@ -724,9 +716,7 @@ export function EditorApp() { {t('scenes.batchProgress') .replace('{current}', String(state.sceneBatchImport.current)) .replace('{total}', String(state.sceneBatchImport.total))} - {state.sceneBatchImport.fileName - ? `: ${state.sceneBatchImport.fileName}` - : ''} + {state.sceneBatchImport.fileName ? `: ${state.sceneBatchImport.fileName}` : ''}
{Math.round( @@ -825,6 +815,23 @@ export function EditorApp() { {t('top.file')} ) : null} +
{appVersionText ? ( @@ -900,9 +907,7 @@ export function EditorApp() { scene={s} reorderEnabled={sceneListReorderEnabled} listDragActive={draggingListSceneId !== null} - dropPlace={ - sceneListDrop?.targetId === s.id ? sceneListDrop.place : null - } + dropPlace={sceneListDrop?.targetId === s.id ? sceneListDrop.place : null} isDragging={draggingListSceneId === s.id} onSelect={() => { setSelectedGraphNodeId(null); @@ -925,9 +930,7 @@ export function EditorApp() { return; } setSceneListDrop((cur) => - cur?.targetId === targetId && cur.place === place - ? cur - : { targetId, place }, + cur?.targetId === targetId && cur.place === place ? cur : { targetId, place }, ); }} onDropListReorder={(draggedId, targetId, place) => { @@ -1545,6 +1548,7 @@ export function EditorApp() { }} /> setCheckUpdatesOpen(false)} /> + setPlayersManagerOpen(false)} /> - {audioDrop.dragOver ? ( -
{t('drop.hintAudio')}
- ) : null} + {audioDrop.dragOver ?
{t('drop.hintAudio')}
: null} {mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
{t('campaign.noFiles')}
@@ -2537,10 +2539,7 @@ function SceneInspector({ {sideStoryStartNodes.map((gn) => (
{t('scene.sideStoryLineTitle')}
- onSideStoryLineTitleChange(gn.id, v)} - /> + onSideStoryLineTitleChange(gn.id, v)} />
))} @@ -2556,9 +2555,7 @@ function SceneInspector({ onDragOver={previewDrop.onDragOver} onDrop={previewDrop.onDrop} > - {previewDrop.dragOver ? ( -
{t('drop.hintPreview')}
- ) : null} + {previewDrop.dragOver ?
{t('drop.hintPreview')}
: null} {previewUrl && previewAssetType === 'image' ? (
- {sceneAudioDrop.dragOver ? ( -
{t('drop.hintAudio')}
- ) : null} + {sceneAudioDrop.dragOver ?
{t('drop.hintAudio')}
: null} {mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
{t('campaign.noFiles')}
@@ -2947,9 +2942,7 @@ function SceneListCard({
-
- {t('confirmDeleteScene.body', { name: scene.title })} -
+
{t('confirmDeleteScene.body', { name: scene.title })}
diff --git a/app/renderer/editor/PlayersModals.module.css b/app/renderer/editor/PlayersModals.module.css new file mode 100644 index 0000000..2575b95 --- /dev/null +++ b/app/renderer/editor/PlayersModals.module.css @@ -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; + } +} diff --git a/app/renderer/editor/PlayersModals.tsx b/app/renderer/editor/PlayersModals.tsx new file mode 100644 index 0000000..00008cf --- /dev/null +++ b/app/renderer/editor/PlayersModals.tsx @@ -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 ( + + ); +} + +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 ( +
{ + 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); + }} + > +
+ {team ? : null} + {team?.name ?? t('players.ungrouped')} + {team ? ( +
+ +
+ + +
+
+ ) : null} +
+
+ {players.map((player) => ( + onSelect(player.id)} + /> + ))} + {players.length === 0 ?
{t('players.dropHere')}
: null} +
+
+ ); +} + +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(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(null); + const [previewUrl, setPreviewUrl] = useState(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(null); + const [pendingDeleteTeam, setPendingDeleteTeam] = useState(null); + const appearanceTimerRef = useRef | 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( + <> +
+
+
+
{t('players.managerTitle')}
+ +
+
+ +
{ + 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 ?
{t('players.dropHint')}
: null} + {adding || selected ? ( + <> + { + setImageOffset(next); + persistAppearance({ imageOffset: next }); + }} + onImageScaleChange={(next) => { + setImageScale(next); + persistAppearance({ imageScale: next }); + }} + sizePx={220} + /> + + +
+ + + {selected ? ( + + ) : null} +
+ + ) : ( +
{t('players.selectPrompt')}
+ )} +
+
+
+ {pendingDeletePlayer + ? createPortal( + <> + +
+
+ {t('players.deleteConfirm', { name: pendingDeletePlayer.name })} +
+
+ + +
+
+ , + document.body, + ) + : null} + {pendingDeleteTeam + ? createPortal( + <> + +
+
+ {t('players.deleteTeamConfirm', { name: pendingDeleteTeam.name })} +
+
+ + +
+
+ , + document.body, + ) + : null} + {saving ? ( +
+
+
{t('players.savingTitle')}
+
+
+
+
+
+
{progress.detail}
+
{progress.percent}%
+
+
+
+ ) : null} + , + document.body, + ); +} diff --git a/app/renderer/editor/SceneDescriptionModal.tsx b/app/renderer/editor/SceneDescriptionModal.tsx index cb9ee4e..d6d3646 100644 --- a/app/renderer/editor/SceneDescriptionModal.tsx +++ b/app/renderer/editor/SceneDescriptionModal.tsx @@ -170,21 +170,21 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
editor.chain().focus().toggleBold().run()} > B editor.chain().focus().toggleItalic().run()} > I editor.chain().focus().toggleUnderline().run()} > U @@ -194,21 +194,21 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
editor.chain().focus().toggleHeading({ level: 2 }).run()} > H2 editor.chain().focus().toggleHeading({ level: 3 }).run()} > H3 editor.chain().focus().toggleBlockquote().run()} > @@ -218,14 +218,14 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
editor.chain().focus().toggleBulletList().run()} > editor.chain().focus().toggleOrderedList().run()} > diff --git a/app/renderer/editor/help/helpLinkify.test.ts b/app/renderer/editor/help/helpLinkify.test.ts index 2afac61..749ee45 100644 --- a/app/renderer/editor/help/helpLinkify.test.ts +++ b/app/renderer/editor/help/helpLinkify.test.ts @@ -18,6 +18,7 @@ const RU_TITLES: Record = { tokens: 'Неигровые токены', campaignAudio: 'Аудио игры', materials: 'Материалы', + players: 'Игроки', npcs: 'НПС', session: 'Запуск сессии', controlPanel: 'Пульт управления', @@ -31,8 +32,7 @@ const RU_TITLES: Record = { void test('findHelpLinkRanges: «Ловушки» и алиас «Эффекты»', () => { const catalog = buildHelpLinkCatalog((id) => RU_TITLES[id]); - const text = - 'см. «Ловушки» и кистью (см. «Эффекты»). Также разделы «Генератор сетки», «Неигровые токены».'; + const text = 'см. «Ловушки» и кистью (см. «Эффекты»). Также разделы «Генератор сетки», «Неигровые токены».'; const ranges = findHelpLinkRanges(text, catalog); assert.deepEqual( ranges.map((r) => r.id), diff --git a/app/renderer/editor/help/helpLinkify.ts b/app/renderer/editor/help/helpLinkify.ts index 4dd5a7f..d8772cb 100644 --- a/app/renderer/editor/help/helpLinkify.ts +++ b/app/renderer/editor/help/helpLinkify.ts @@ -9,6 +9,7 @@ export const HELP_SECTION_LINK_ALIASES: Partial> = { 'top.settings': 'Настройки', 'top.project': 'Проект', 'top.file': 'Файл', + 'top.players': 'Игроки', 'top.backToProjects': 'К списку проектов', 'top.appVersion': 'Версия приложения', 'top.run': 'Запустить', @@ -175,7 +176,7 @@ export const EDITOR_MESSAGES: Record> = { 'help.section.sceneEditor.title': 'Редактор сцены', 'help.section.sceneEditor.body': - '«Редактор сцены» — отдельное окно для подготовки карты: сетка боя, ловушки и неигровые токены. Доступен только для сцен с изображением (не с видео).\n\nОткрыть:\n\n1) Выберите сцену в списке слева.\n\n2) В «Свойствах сцены» загрузите картинку, если её ещё нет.\n\n3) Нажмите «Редактор сцены».\n\nСлева — аккордеоны «Сетка», «Неигровые токены» и «Ловушки»; справа — карта сцены.\n\nНавигация по карте: колесо мыши — зум; средняя кнопка мыши или Space+ЛКМ — сдвиг вида. Delete / Backspace убирает выделенный маркер на карте.\n\nПод аккордеонами кнопка «Очистить сцену» убирает с текущей карты все ловушки и токены (пул токенов приложения не трогает).\n\nПодробнее: разделы «Генератор сетки», «Ловушки» и «Неигровые токены».', + '«Редактор сцены» — отдельное окно для подготовки карты: сетка боя, ловушки, неигровые токены и круглые токены НПС. Доступен только для сцен с изображением (не с видео).\n\nОткрыть:\n\n1) Выберите сцену в списке слева.\n\n2) В «Свойствах сцены» загрузите картинку, если её ещё нет.\n\n3) Нажмите «Редактор сцены».\n\nСлева — аккордеоны «Сетка», «Неигровые токены», «НПС» и «Ловушки»; справа — карта сцены. Перетащите НПС на карту, чтобы добавить его круглый токен.\n\nНавигация по карте: колесо мыши — зум; средняя кнопка мыши или Space+ЛКМ — сдвиг вида. Delete / Backspace убирает выделенный маркер на карте.\n\nПод аккордеонами кнопка «Очистить сцену» убирает с текущей карты все ловушки и токены (пул токенов приложения не трогает).\n\nПодробнее: разделы «Генератор сетки», «Ловушки» и «Неигровые токены».', 'help.section.grid.title': 'Генератор сетки', 'help.section.grid.body': @@ -196,10 +197,13 @@ export const EDITOR_MESSAGES: Record> = { 'help.section.materials.title': 'Материалы', 'help.section.materials.body': 'Материалы — изображения кампании (карты, записки, чертежи), которые можно показать игрокам поверх сцены во время игры. Они общие для проекта и не привязаны к одной сцене.\n\nВ редакторе:\n\n1) В блоке «Свойства игры» нажмите «Материалы».\n\n2) «Добавить» — укажите уникальное название и изображение (PNG, JPG или WebP): кнопка выбора или перетаскивание файла.\n\n3) В списке можно искать, менять порядок перетаскиванием, править или удалять через меню «⋮» (перед удалением будет подтверждение).\n\n4) Под большим превью — «Повернуть»: поворот на 90° (учитывается и в плитке, и при показе на экране).\n\nВо время сессии:\n\n1) На пульте в «Инструменты» нажмите кнопку материалов (иконка карты сокровищ) — откроется отдельное окно со списком.\n\n2) Клик по плитке показывает материал поверх сцены на пульте и на презентации; повторный клик по той же плитке скрывает его.\n\n3) На предпросмотре пульта материал можно перетаскивать и менять размер за углы; крестик закрывает показ.\n\n4) В окне материалов лупы «+» / «−» — инструменты масштаба: выберите лупу, затем кликните по материалу в предпросмотре пульта.\n\nПри смене сцены показ материала сбрасывается. Описание сцены и эффекты поля с материалами не связаны.', + '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.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.body': @@ -415,6 +419,28 @@ export const EDITOR_MESSAGES: Record> = { 'materials.zoomOutHint': 'Кликните по материалу в предпросмотре пульта, чтобы уменьшить.', '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.editorTitle': 'НПС', 'npcs.add': 'Добавить', @@ -439,6 +465,7 @@ export const EDITOR_MESSAGES: Record> = { 'npcs.avatarRequired': 'Выберите аватар.', 'npcs.chooseAvatar': 'Выбрать аватар', 'npcs.dropHint': 'Перетащите изображение (PNG, JPG, WebP)', + 'npcs.ringColor': 'ЦВЕТ РАМКИ ТОКЕНА', 'npcs.description': 'ОПИСАНИЕ', 'npcs.descriptionPlaceholder': 'Описание персонажа…', 'npcs.descriptionEmpty': 'Описание отсутствует', @@ -576,6 +603,7 @@ export const EDITOR_MESSAGES: Record> = { 'control.passed': 'Пройдено', 'control.noActiveScene': 'Нет активной сцены.', 'control.screenPreview': 'Предпросмотр экрана', + 'control.npcTokenScale': 'Размер НПС', 'control.stopPresentation': 'Выключить демонстрацию', 'control.videoBrushHint': 'Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для изображения).', @@ -678,6 +706,7 @@ export const EDITOR_MESSAGES: Record> = { 'top.settings': 'Settings', 'top.project': 'Project', 'top.file': 'File', + 'top.players': 'Players', 'top.backToProjects': 'Back to projects', 'top.appVersion': 'App version', 'top.run': 'Run', @@ -743,7 +772,7 @@ export const EDITOR_MESSAGES: Record> = { 'help.section.sceneEditor.title': 'Scene editor', '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.body': @@ -764,10 +793,13 @@ export const EDITOR_MESSAGES: Record> = { 'help.section.materials.title': 'Materials', 'help.section.materials.body': 'Materials are campaign images (maps, notes, sketches) you can show players on top of the scene during play. They belong to the project and are not tied to one scene.\n\nIn the editor:\n\n1) Under Game properties, click Materials.\n\n2) Add — enter a unique name and an image (PNG, JPG, or WebP) via Choose image or by dropping a file.\n\n3) In the list you can search, reorder by drag-and-drop, and edit or delete via the ⋮ menu (delete asks for confirmation).\n\n4) Under the large preview, Rotate turns the image by 90° (applied in the tile and when shown on screen).\n\nDuring a session:\n\n1) On the control panel under Tools, click the materials button (treasure-map icon) to open a separate window with the list.\n\n2) Click a tile to show the material over the scene on the control preview and presentation; click the same tile again to hide it.\n\n3) On the control preview you can drag the material and resize it from the corners; the × button closes the overlay.\n\n4) In the materials window, the + / − magnifiers are zoom tools: pick one, then click the material on the control preview.\n\nChanging scenes clears the material overlay. Scene description and field effects are separate from materials.', + '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.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 character’s 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.body': @@ -984,6 +1016,29 @@ export const EDITOR_MESSAGES: Record> = { '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.', + '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.editorTitle': 'NPCs', 'npcs.add': 'Add', @@ -1008,13 +1063,15 @@ export const EDITOR_MESSAGES: Record> = { 'npcs.avatarRequired': 'Choose an avatar.', 'npcs.chooseAvatar': 'Choose avatar', 'npcs.dropHint': 'Drop an image (PNG, JPG, WebP)', + 'npcs.ringColor': 'TOKEN RING COLOR', 'npcs.description': 'DESCRIPTION', 'npcs.descriptionPlaceholder': 'Character description…', 'npcs.descriptionEmpty': 'No description', 'npcs.relations': 'Relations', 'npcs.untitled': 'Untitled', '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.relationEditTitle': 'Relation name', 'npcs.relationLabel': 'NAME', @@ -1144,6 +1201,7 @@ export const EDITOR_MESSAGES: Record> = { 'control.passed': 'Visited', 'control.noActiveScene': 'No active scene.', 'control.screenPreview': 'Screen preview', + 'control.npcTokenScale': 'NPC size', 'control.stopPresentation': 'Stop presentation', 'control.videoBrushHint': 'Video preview: effect brush is disabled (like on the presentation screen — overlay is for images only).', diff --git a/app/renderer/editor/state/projectState.ts b/app/renderer/editor/state/projectState.ts index ec14f27..502ab9e 100644 --- a/app/renderer/editor/state/projectState.ts +++ b/app/renderer/editor/state/projectState.ts @@ -50,11 +50,7 @@ type Actions = { importCampaignAudio: () => Promise; importCampaignAudioFromPaths: (filePaths: string[]) => Promise; updateCampaignAudios: (next: Project['campaignAudios']) => Promise; - upsertMaterial: (input: { - materialId?: MaterialId; - name: string; - filePath?: string; - }) => Promise; + upsertMaterial: (input: { materialId?: MaterialId; name: string; filePath?: string }) => Promise; deleteMaterial: (materialId: MaterialId) => Promise; setMaterialsOrder: (materialIds: MaterialId[]) => Promise; setMaterialRotation: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => Promise; @@ -104,7 +100,10 @@ type Actions = { mode: 'folder' | 'archive', ) => Promise<{ canceled: true } | { canceled: false; sourcePath: string }>; importFoundryProject: (sourcePath: string) => Promise; - peekImportZip: (labels: StorylineLabels, targetHasMainStart: boolean) => Promise< + peekImportZip: ( + labels: StorylineLabels, + targetHasMainStart: boolean, + ) => Promise< | { canceled: true } | { canceled: false; @@ -355,6 +354,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) darkenScene: false, traps: [], tokens: [], + npcTokens: [], grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' }, media: { videos: [], audios: [] }, settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true }, @@ -509,11 +509,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) await refreshProjects(); }; - const upsertMaterial = async (input: { - materialId?: MaterialId; - name: string; - filePath?: string; - }) => { + const upsertMaterial = async (input: { materialId?: MaterialId; name: string; filePath?: string }) => { const res = await api.invoke(ipcChannels.project.upsertMaterial, input); setState((s) => ({ ...s, project: res.project })); await refreshProjects(); @@ -531,10 +527,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) await refreshProjects(); }; - const setMaterialRotation = async ( - materialId: MaterialId, - rotationDeg: 0 | 90 | 180 | 270, - ) => { + const setMaterialRotation = async (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => { const res = await api.invoke(ipcChannels.project.setMaterialRotation, { materialId, rotationDeg, @@ -570,6 +563,9 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) previewRotationDeg?: 0 | 90 | 180 | 270; darkenScene?: boolean; traps?: import('../../../shared/types').SceneTrap[]; + tokens?: import('../../../shared/types').SceneToken[]; + npcTokens?: import('../../../shared/types').SceneNpcToken[]; + grid?: import('../../../shared/types').SceneGrid; settings?: Partial; media?: Partial; layout?: { x: number; y: number }; @@ -598,6 +594,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null), ...(patch.traps !== undefined ? { traps: patch.traps } : null), ...(patch.tokens !== undefined ? { tokens: patch.tokens } : null), + ...(patch.npcTokens !== undefined ? { npcTokens: patch.npcTokens } : null), ...(patch.grid !== undefined ? { grid: patch.grid } : null), ...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null), ...(patch.media ? { media: { ...scene.media, ...patch.media } } : null), diff --git a/app/renderer/npcs/NpcGraph.tsx b/app/renderer/npcs/NpcGraph.tsx index 0f0fbd2..26a563c 100644 --- a/app/renderer/npcs/NpcGraph.tsx +++ b/app/renderer/npcs/NpcGraph.tsx @@ -302,7 +302,7 @@ function FilterToolbar({ return ( { + void api.invoke(ipcChannels.project.updateNpcFields, { + npcId: selected.id, + ringColor: e.currentTarget.value, + }); + }} + /> + +
{t('npcs.name')}
* { + pointer-events: none; +} + +.sceneNpcToken .handle { + pointer-events: auto; +} + .tokenThumb { aspect-ratio: 1; border-radius: 6px; diff --git a/app/renderer/sceneEditor/SceneEditorApp.tsx b/app/renderer/sceneEditor/SceneEditorApp.tsx index ccfb0a8..2218bbb 100644 --- a/app/renderer/sceneEditor/SceneEditorApp.tsx +++ b/app/renderer/sceneEditor/SceneEditorApp.tsx @@ -2,7 +2,21 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom'; import { ipcChannels, type SessionState } from '../../shared/ipc/contracts'; -import type { SceneGrid, SceneToken, SceneTrap, SceneTrapType, TokenId } from '../../shared/types'; +import type { + 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 { asSceneTokenId, asTokenId, @@ -14,6 +28,7 @@ import { DEFAULT_SCENE_GRID, SCENE_GRID_SIZE_MAX, SCENE_GRID_SIZE_MIN, + sceneGridTokenFitFactor, sceneGridTypeLabelRu, } from '../../shared/types/sceneGrid'; import { @@ -26,14 +41,15 @@ import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView'; import editorStyles from '../editor/EditorApp.module.css'; import { getDndApi } from '../shared/dndApi'; import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay'; +import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView'; import { RotatedImage } from '../shared/RotatedImage'; import { useAppTokens } from '../shared/tokens/useAppTokens'; import { TrapGlyph } from '../shared/traps/TrapGlyph'; import { Button, Input, Select } from '../shared/ui/controls'; import { useAssetUrl } from '../shared/useAssetImageUrl'; -import { SceneTokenMarker } from './SceneTokenMarker'; import styles from './SceneEditorApp.module.css'; +import { SceneTokenMarker } from './SceneTokenMarker'; import { TokenEditModal } from './TokenEditModal'; import { TOKEN_DND_MIME, TokenTile } from './TokenTile'; @@ -45,14 +61,37 @@ function isTypingTarget(el: EventTarget | null): boolean { } 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 = | { 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: '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: 'moveNpcToken'; + tokenId: string; + startNx: number; + startNy: number; + pointerNx: number; + pointerNy: number; + } + | { kind: 'resizeNpcToken'; tokenId: string; startSize: number; startDist: number } | { kind: 'rotateToken'; tokenId: string; @@ -78,6 +117,86 @@ function shortestAngleDelta(fromDeg: number, toDeg: number): number { return d; } +const SCENE_NPC_DND_MIME = 'application/x-dnd-scene-npc-id'; + +function SceneNpcMarker({ + placement, + npc, + left, + top, + sizePx, + selected, + onSelect, + onDelete, + onMovePointerDown, + onResizePointerDown, +}: { + placement: SceneNpcToken; + npc: ProjectNpc; + left: number; + top: number; + sizePx: number; + selected: boolean; + onSelect: () => void; + onDelete: () => void; + onMovePointerDown: (e: React.PointerEvent) => void; + onResizePointerDown: (e: React.PointerEvent) => void; +}) { + const imageUrl = useAssetUrl(npc.avatarAssetId); + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + onDelete(); + }} + onPointerDown={(e) => { + if (e.button !== 0) return; + onSelect(); + onMovePointerDown(e); + }} + > + + {selected ?
: null} +
+ ); +} + +function NpcPaletteTile({ npc }: { npc: ProjectNpc }) { + const imageUrl = useAssetUrl(npc.avatarAssetId); + return ( +
{ + e.dataTransfer.setData(SCENE_NPC_DND_MIME, npc.id); + e.dataTransfer.effectAllowed = 'copy'; + }} + title={npc.name} + > + +
+ ); +} + export function SceneEditorApp() { const api = getDndApi(); const appTokens = useAppTokens(); @@ -85,20 +204,21 @@ export function SceneEditorApp() { const [trapsOpen, setTrapsOpen] = useState(false); const [gridOpen, setGridOpen] = useState(false); const [tokensOpen, setTokensOpen] = useState(false); + const [npcsOpen, setNpcsOpen] = useState(false); const [tokenSearch, setTokenSearch] = useState(''); - const [tokenModal, setTokenModal] = useState<{ mode: 'create' } | { mode: 'edit'; tokenId: TokenId } | null>( - null, - ); + const [npcSearch, setNpcSearch] = useState(''); + const [tokenModal, setTokenModal] = useState< + { mode: 'create' } | { mode: 'edit'; tokenId: TokenId } | null + >(null); const [pendingDeleteToken, setPendingDeleteToken] = useState<{ id: TokenId; name: string } | null>(null); const [selected, setSelected] = useState(null); const [view, setView] = useState({ scale: 1, ox: 0.5, oy: 0.5 }); - const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>( - null, - ); + const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null); const hostRef = useRef(null); const dragRef = useRef(null); const saveTrapsTimerRef = useRef(0); const saveTokensTimerRef = useRef(0); + const saveNpcTokensTimerRef = useRef(0); const saveGridTimerRef = useRef(0); const spaceDownRef = useRef(false); @@ -109,15 +229,24 @@ export function SceneEditorApp() { const rot = scene?.previewRotationDeg ?? 0; const [localTraps, setLocalTraps] = useState([]); const [localTokens, setLocalTokens] = useState([]); + const [localNpcTokens, setLocalNpcTokens] = useState([]); const [localGrid, setLocalGrid] = useState({ ...DEFAULT_SCENE_GRID }); const trapsRef = useRef([]); const tokensRef = useRef([]); - trapsRef.current = localTraps; - tokensRef.current = localTokens; + const npcTokensRef = useRef([]); + + useEffect(() => { + trapsRef.current = localTraps; + tokensRef.current = localTokens; + npcTokensRef.current = localNpcTokens; + }, [localNpcTokens, localTokens, localTraps]); useEffect(() => { setLocalTraps(scene?.traps ?? []); 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 }); setSelected(null); setView({ scale: 1, ox: 0.5, oy: 0.5 }); @@ -134,6 +263,12 @@ export function SceneEditorApp() { setLocalTokens((scene?.tokens ?? []).filter((t) => known.has(t.tokenId))); }, [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(() => { if (dragRef.current) return; setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID }); @@ -174,6 +309,19 @@ export function SceneEditorApp() { [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( (next: SceneGrid) => { if (!sceneId) return; @@ -194,8 +342,10 @@ export function SceneEditorApp() { e.preventDefault(); if (selected.kind === 'trap') { persistTraps(trapsRef.current.filter((t) => t.id !== selected.id)); - } else { + } else if (selected.kind === 'token') { persistTokens(tokensRef.current.filter((t) => t.id !== selected.id)); + } else { + persistNpcTokens(npcTokensRef.current.filter((t) => t.id !== selected.id)); } setSelected(null); } @@ -210,7 +360,7 @@ export function SceneEditorApp() { window.removeEventListener('keydown', onKeyDown); 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 host = hostRef.current; @@ -284,6 +434,22 @@ export function SceneEditorApp() { 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 placement: SceneNpcToken = { + id: asSceneNpcTokenId(randomId('snpc')), + npcId, + nx, + ny, + sizeN: gridCellSize, + }; + setSelected({ kind: 'npcToken', id: placement.id }); + persistNpcTokens([...npcTokensRef.current, placement]); + }; + const onStageDrop = (e: React.DragEvent) => { e.preventDefault(); const p = hostToNorm(e.clientX, e.clientY); @@ -293,6 +459,11 @@ export function SceneEditorApp() { addTokenAt(asTokenId(tokenId), p.x, p.y); 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; if (!SCENE_TRAP_TYPES.includes(type)) return; addTrapAt(type, p.x, p.y); @@ -306,22 +477,31 @@ export function SceneEditorApp() { persistTokens(tokensRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t))); }; + const updateNpcToken = (id: string, patch: Partial) => { + persistNpcTokens(npcTokensRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t))); + }; + const filteredTokens = useMemo(() => { const q = tokenSearch.trim().toLowerCase(); if (!q) return appTokens; return appTokens.filter((t) => t.name.toLowerCase().includes(q)); }, [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); return (