feat(npcs): add campaign NPCs with relation graph and session overlay
Add a dedicated NPC editor window, directed relations, control/presentation avatar overlay, and ru/en help for the new section. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import { SceneDarknessStore } from './effects/sceneDarknessStore';
|
||||
import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router';
|
||||
import { LicenseService } from './license/licenseService';
|
||||
import { MaterialsOverlayStore } from './materials/materialsOverlayStore';
|
||||
import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
|
||||
import { ZipProjectStore } from './project/zipStore';
|
||||
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
|
||||
import { installAutoUpdater } from './update/installAutoUpdater';
|
||||
@@ -43,8 +44,12 @@ import {
|
||||
markAppQuitting,
|
||||
openMaterialsWindow,
|
||||
openMultiWindow,
|
||||
openNpcsEditorWindow,
|
||||
openNpcsWindow,
|
||||
openSceneDescriptionWindow,
|
||||
closeMaterialsWindow,
|
||||
closeNpcsEditorWindow,
|
||||
closeNpcsWindow,
|
||||
togglePresentationFullscreen,
|
||||
waitForEditorWindowReady,
|
||||
} from './windows/createWindows';
|
||||
@@ -138,6 +143,7 @@ const effectsStore = new EffectsStore();
|
||||
const sceneDarknessStore = new SceneDarknessStore();
|
||||
const videoStore = new VideoPlaybackStore();
|
||||
const materialsOverlayStore = new MaterialsOverlayStore();
|
||||
const npcsOverlayStore = new NpcsOverlayStore();
|
||||
|
||||
function emitEffectsState(): void {
|
||||
const state = effectsStore.getState();
|
||||
@@ -162,6 +168,22 @@ function syncMaterialsOverlayWithProject(project: Project | null): void {
|
||||
materialsOverlayStore.ensureMaterialStillExists(ids);
|
||||
}
|
||||
|
||||
function emitNpcsOverlayState(): void {
|
||||
const state = npcsOverlayStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(ipcChannels.npcsOverlay.stateChanged, { state });
|
||||
}
|
||||
}
|
||||
|
||||
function syncNpcsOverlayWithProject(project: Project | null): void {
|
||||
if (!project) {
|
||||
npcsOverlayStore.clear();
|
||||
return;
|
||||
}
|
||||
const ids = new Set((project.npcs ?? []).map((n) => n.id));
|
||||
npcsOverlayStore.ensureNpcStillExists(ids);
|
||||
}
|
||||
|
||||
function emitSceneDarknessState(): void {
|
||||
const state = sceneDarknessStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
@@ -221,6 +243,7 @@ async function runStartupAfterHandlers(licenseService: LicenseService): Promise<
|
||||
emitSessionState();
|
||||
emitEffectsState();
|
||||
emitMaterialsOverlayState();
|
||||
emitNpcsOverlayState();
|
||||
emitVideoState();
|
||||
return;
|
||||
}
|
||||
@@ -235,6 +258,7 @@ async function runStartupAfterHandlers(licenseService: LicenseService): Promise<
|
||||
emitSessionState();
|
||||
emitEffectsState();
|
||||
emitMaterialsOverlayState();
|
||||
emitNpcsOverlayState();
|
||||
emitVideoState();
|
||||
return;
|
||||
}
|
||||
@@ -273,6 +297,7 @@ async function runStartupAfterHandlers(licenseService: LicenseService): Promise<
|
||||
emitSessionState();
|
||||
emitEffectsState();
|
||||
emitMaterialsOverlayState();
|
||||
emitNpcsOverlayState();
|
||||
emitVideoState();
|
||||
}
|
||||
|
||||
@@ -357,6 +382,22 @@ async function main() {
|
||||
closeMaterialsWindow();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.openNpcsEditor, () => {
|
||||
openNpcsEditorWindow();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.closeNpcsEditor, () => {
|
||||
closeNpcsEditorWindow();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.openNpcs, () => {
|
||||
openNpcsWindow();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.closeNpcs, () => {
|
||||
closeNpcsWindow();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
registerHandler(ipcChannels.materialsOverlay.getState, () => {
|
||||
return { state: materialsOverlayStore.getState() };
|
||||
@@ -366,6 +407,14 @@ async function main() {
|
||||
emitMaterialsOverlayState();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.npcsOverlay.getState, () => {
|
||||
return { state: npcsOverlayStore.getState() };
|
||||
});
|
||||
registerHandler(ipcChannels.npcsOverlay.dispatch, ({ event }) => {
|
||||
npcsOverlayStore.dispatch(event);
|
||||
emitNpcsOverlayState();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
registerHandler(ipcChannels.project.list, async () => {
|
||||
const projects = await projectStore.listProjects();
|
||||
@@ -392,9 +441,11 @@ async function main() {
|
||||
await projectStore.closeOpenProject();
|
||||
effectsStore.clear();
|
||||
materialsOverlayStore.clear();
|
||||
npcsOverlayStore.clear();
|
||||
sceneDarknessStore.resetSession();
|
||||
emitEffectsState();
|
||||
emitMaterialsOverlayState();
|
||||
emitNpcsOverlayState();
|
||||
emitSceneDarknessState();
|
||||
emitSessionState();
|
||||
return { ok: true };
|
||||
@@ -410,10 +461,12 @@ async function main() {
|
||||
await projectStore.updateProject((p) => ({ ...p, currentSceneId: sceneId, currentGraphNodeId: null }));
|
||||
effectsStore.clear();
|
||||
materialsOverlayStore.clear();
|
||||
npcsOverlayStore.clear();
|
||||
const project = projectStore.getOpenProject();
|
||||
if (project) syncSceneDarknessForProject(project);
|
||||
emitEffectsState();
|
||||
emitMaterialsOverlayState();
|
||||
emitNpcsOverlayState();
|
||||
emitSceneDarknessState();
|
||||
emitSessionState();
|
||||
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
|
||||
@@ -429,10 +482,12 @@ async function main() {
|
||||
}));
|
||||
effectsStore.clear();
|
||||
materialsOverlayStore.clear();
|
||||
npcsOverlayStore.clear();
|
||||
const project = projectStore.getOpenProject();
|
||||
if (project) syncSceneDarknessForProject(project);
|
||||
emitEffectsState();
|
||||
emitMaterialsOverlayState();
|
||||
emitNpcsOverlayState();
|
||||
emitSceneDarknessState();
|
||||
emitSessionState();
|
||||
const p = projectStore.getOpenProject();
|
||||
@@ -570,6 +625,96 @@ async function main() {
|
||||
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 }) => {
|
||||
let filePath = pathFromDrop;
|
||||
if (!filePath && !npcId) {
|
||||
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('NPC avatar is required');
|
||||
}
|
||||
filePath = filePaths[0];
|
||||
}
|
||||
const project = await projectStore.upsertNpc({
|
||||
...(npcId ? { npcId } : {}),
|
||||
name,
|
||||
...(typeof description === 'string' ? { description } : {}),
|
||||
...(filePath ? { filePath } : {}),
|
||||
});
|
||||
syncNpcsOverlayWithProject(project);
|
||||
emitNpcsOverlayState();
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.updateNpcFields, async ({ npcId, name, description }) => {
|
||||
const project = await projectStore.updateNpcFields(npcId, {
|
||||
...(typeof name === 'string' ? { name } : {}),
|
||||
...(typeof description === 'string' ? { description } : {}),
|
||||
});
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.updateNpcPosition, async ({ npcId, x, y }) => {
|
||||
const project = await projectStore.updateNpcPosition(npcId, x, y);
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.deleteNpc, async ({ npcId }) => {
|
||||
const project = await projectStore.deleteNpc(npcId);
|
||||
syncNpcsOverlayWithProject(project);
|
||||
emitNpcsOverlayState();
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.setNpcsOrder, async ({ npcIds }) => {
|
||||
const project = await projectStore.setNpcsOrder(npcIds);
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.pickNpcAvatar, 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.project.upsertNpcRelation,
|
||||
async ({ relationId, sourceNpcId, targetNpcId, label }) => {
|
||||
const project = await projectStore.upsertNpcRelation({
|
||||
...(relationId ? { relationId } : {}),
|
||||
sourceNpcId,
|
||||
targetNpcId,
|
||||
label,
|
||||
});
|
||||
emitSessionState();
|
||||
return { project };
|
||||
},
|
||||
);
|
||||
registerHandler(ipcChannels.project.deleteNpcRelation, async ({ relationId }) => {
|
||||
const project = await projectStore.deleteNpcRelation(relationId);
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId, filePath: pathFromDrop }) => {
|
||||
let filePath = pathFromDrop;
|
||||
if (!filePath) {
|
||||
|
||||
@@ -20,6 +20,8 @@ function channelRequiresLicense(channel: string): boolean {
|
||||
if (channel === ipcChannels.windows.closeMultiWindow) return false;
|
||||
if (channel === ipcChannels.windows.closeSceneDescription) return false;
|
||||
if (channel === ipcChannels.windows.closeMaterials) return false;
|
||||
if (channel === ipcChannels.windows.closeNpcs) return false;
|
||||
if (channel === ipcChannels.windows.closeNpcsEditor) return false;
|
||||
if (channel === ipcChannels.windows.togglePresentationFullscreen) return false;
|
||||
// Список файлов в %userData%/projects — только чтение; без лицензии список не должен «пропадать».
|
||||
if (channel === ipcChannels.project.list) return false;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
clampNpcsLayout,
|
||||
DEFAULT_NPCS_OVERLAY_LAYOUT,
|
||||
type NpcId,
|
||||
type NpcsOverlayEvent,
|
||||
type NpcsOverlayLayout,
|
||||
type NpcsOverlayState,
|
||||
type NpcsZoomTool,
|
||||
zoomNpcsLayoutAt,
|
||||
} from '../../shared/types';
|
||||
|
||||
function emptyState(): NpcsOverlayState {
|
||||
return {
|
||||
revision: 1,
|
||||
activeNpcId: null,
|
||||
layout: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
|
||||
zoomTool: null,
|
||||
};
|
||||
}
|
||||
|
||||
export class NpcsOverlayStore {
|
||||
private state: NpcsOverlayState = emptyState();
|
||||
|
||||
getState(): NpcsOverlayState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
clear(): NpcsOverlayState {
|
||||
if (this.state.activeNpcId === null && this.state.zoomTool === null) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeNpcId: null,
|
||||
layout: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
|
||||
zoomTool: null,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: NpcsOverlayEvent): NpcsOverlayState {
|
||||
switch (event.kind) {
|
||||
case 'hide':
|
||||
return this.clear();
|
||||
case 'show':
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeNpcId: event.npcId,
|
||||
layout: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
case 'toggle': {
|
||||
if (this.state.activeNpcId === event.npcId) {
|
||||
return this.clear();
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeNpcId: event.npcId,
|
||||
layout: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'layout.set': {
|
||||
if (this.state.activeNpcId === null) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
layout: clampNpcsLayout(event.layout),
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'zoomTool.set': {
|
||||
const tool: NpcsZoomTool = event.tool;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
zoomTool: tool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'zoomAt': {
|
||||
if (this.state.activeNpcId === null || !this.state.zoomTool) return this.state;
|
||||
const factor = this.state.zoomTool === 'zoomIn' ? 1.25 : 1 / 1.25;
|
||||
const layout: NpcsOverlayLayout = zoomNpcsLayoutAt(
|
||||
this.state.layout,
|
||||
event.nx,
|
||||
event.ny,
|
||||
factor,
|
||||
);
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
layout,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default:
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
|
||||
ensureNpcStillExists(npcIds: ReadonlySet<NpcId>): NpcsOverlayState {
|
||||
const active = this.state.activeNpcId;
|
||||
if (active === null || npcIds.has(active)) return this.state;
|
||||
return this.clear();
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,10 @@ void test('collectReferencedAssetIds: превью, видео и аудио', (
|
||||
},
|
||||
campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true }],
|
||||
materials: [{ id: 'm1', name: 'Map', assetId: 'mat1' as AssetId }],
|
||||
npcs: [{ id: 'n1', name: 'Guard', avatarAssetId: 'npc1' as AssetId }],
|
||||
} as unknown as Project;
|
||||
const s = collectReferencedAssetIds(p);
|
||||
assert.deepEqual([...s].sort(), ['a1', 'ca1', 'mat1', 'pr', 'th', 'v1'].sort());
|
||||
assert.deepEqual([...s].sort(), ['a1', 'ca1', 'mat1', 'npc1', 'pr', 'th', 'v1'].sort());
|
||||
});
|
||||
|
||||
void test('reconcileAssetFiles: снимает осиротевшие assets и удаляет файлы', async () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ export function collectReferencedAssetIds(p: Project): Set<AssetId> {
|
||||
}
|
||||
for (const au of p.campaignAudios) refs.add(au.assetId);
|
||||
for (const m of p.materials ?? []) refs.add(m.assetId);
|
||||
for (const n of p.npcs ?? []) refs.add(n.avatarAssetId);
|
||||
return refs;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,14 +37,23 @@ import type {
|
||||
MediaAssetType,
|
||||
Project,
|
||||
ProjectId,
|
||||
ProjectNpc,
|
||||
ProjectNpcRelation,
|
||||
Scene,
|
||||
SceneGraphEdge,
|
||||
SceneGraphNode,
|
||||
SceneId,
|
||||
} from '../../shared/types';
|
||||
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
|
||||
import type { AssetId, GraphNodeId, MaterialId } from '../../shared/types/ids';
|
||||
import { asAssetId, asGraphNodeId, asMaterialId, asProjectId } from '../../shared/types/ids';
|
||||
import type { AssetId, GraphNodeId, MaterialId, NpcId, NpcRelationId } from '../../shared/types/ids';
|
||||
import {
|
||||
asAssetId,
|
||||
asGraphNodeId,
|
||||
asMaterialId,
|
||||
asNpcId,
|
||||
asNpcRelationId,
|
||||
asProjectId,
|
||||
} from '../../shared/types/ids';
|
||||
import { getAppSemanticVersion } from '../versionInfo';
|
||||
|
||||
import { reconcileAssetFiles } from './assetPrune';
|
||||
@@ -226,6 +235,8 @@ export class ZipProjectStore {
|
||||
assets: {},
|
||||
campaignAudios: [],
|
||||
materials: [],
|
||||
npcs: [],
|
||||
npcRelations: [],
|
||||
currentSceneId: null,
|
||||
currentGraphNodeId: null,
|
||||
sceneGraphNodes: [],
|
||||
@@ -1134,6 +1145,225 @@ export class ZipProjectStore {
|
||||
return latest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт или обновляет НПС.
|
||||
* При создании `filePath` (аватар) обязателен; при обновлении можно сменить только имя/описание/аватар.
|
||||
*/
|
||||
async upsertNpc(input: {
|
||||
npcId?: NpcId;
|
||||
name: string;
|
||||
description?: string;
|
||||
filePath?: string;
|
||||
}): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
const name = input.name.trim();
|
||||
if (name.length < 1) throw new Error('NPC name is required');
|
||||
const nameKey = name.toLowerCase();
|
||||
const existing = open.project.npcs ?? [];
|
||||
const editingId = input.npcId ?? null;
|
||||
if (existing.some((n) => n.id !== editingId && n.name.trim().toLowerCase() === nameKey)) {
|
||||
throw new Error('NPC name already exists');
|
||||
}
|
||||
|
||||
let nextAssetId: AssetId | null = null;
|
||||
let stagedAsset: MediaAsset | null = null;
|
||||
if (input.filePath) {
|
||||
const kind = classifyMediaPath(input.filePath);
|
||||
if (kind?.type !== 'image') throw new Error('NPC avatar must be an image (png/jpg/webp)');
|
||||
const ext = path.extname(input.filePath).toLowerCase();
|
||||
if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
|
||||
throw new Error('NPC avatar must be an image (png/jpg/webp)');
|
||||
}
|
||||
let buf = await fs.readFile(input.filePath);
|
||||
try {
|
||||
const opt = await optimizeImageBufferVisuallyLossless(buf);
|
||||
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
|
||||
} catch {
|
||||
// keep original buffer
|
||||
}
|
||||
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
const id = asAssetId(this.randomId());
|
||||
const orig = path.basename(input.filePath);
|
||||
const safeOrig = sanitizeFileName(orig);
|
||||
const relPath = `assets/${id}_${safeOrig}`;
|
||||
const abs = path.join(open.cacheDir, relPath);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
await fs.writeFile(abs, buf);
|
||||
stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
|
||||
nextAssetId = id;
|
||||
}
|
||||
|
||||
await this.updateProject((p) => {
|
||||
const npcs = [...(p.npcs ?? [])];
|
||||
const assets = { ...p.assets };
|
||||
if (stagedAsset) assets[stagedAsset.id] = stagedAsset;
|
||||
|
||||
if (editingId) {
|
||||
const idx = npcs.findIndex((n) => n.id === editingId);
|
||||
if (idx < 0) throw new Error('NPC not found');
|
||||
const prev = npcs[idx]!;
|
||||
npcs[idx] = {
|
||||
...prev,
|
||||
name,
|
||||
avatarAssetId: nextAssetId ?? prev.avatarAssetId,
|
||||
description:
|
||||
typeof input.description === 'string' ? input.description : prev.description,
|
||||
};
|
||||
} else {
|
||||
if (!nextAssetId) throw new Error('NPC avatar is required');
|
||||
const count = npcs.length;
|
||||
npcs.push({
|
||||
id: asNpcId(`npc_${this.randomId()}`),
|
||||
name,
|
||||
avatarAssetId: nextAssetId,
|
||||
description: typeof input.description === 'string' ? input.description : '',
|
||||
x: 80 + (count % 4) * 220,
|
||||
y: 80 + Math.floor(count / 4) * 200,
|
||||
});
|
||||
}
|
||||
return { ...p, assets, npcs };
|
||||
});
|
||||
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
}
|
||||
|
||||
async updateNpcFields(
|
||||
npcId: NpcId,
|
||||
patch: { name?: string; description?: string },
|
||||
): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
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,
|
||||
)
|
||||
) {
|
||||
throw new Error('NPC name already exists');
|
||||
}
|
||||
}
|
||||
await this.updateProject((p) => {
|
||||
const npcs = (p.npcs ?? []).map((n) => {
|
||||
if (n.id !== npcId) return n;
|
||||
return {
|
||||
...n,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(typeof patch.description === 'string' ? { description: patch.description } : {}),
|
||||
};
|
||||
});
|
||||
return { ...p, npcs };
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
}
|
||||
|
||||
async updateNpcPosition(npcId: NpcId, x: number, y: number): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
await this.updateProject((p) => ({
|
||||
...p,
|
||||
npcs: (p.npcs ?? []).map((n) => (n.id === npcId ? { ...n, x, y } : n)),
|
||||
}));
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
}
|
||||
|
||||
async deleteNpc(npcId: NpcId): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
await this.updateProject((p) => ({
|
||||
...p,
|
||||
npcs: (p.npcs ?? []).filter((n) => n.id !== npcId),
|
||||
npcRelations: (p.npcRelations ?? []).filter(
|
||||
(r) => r.sourceNpcId !== npcId && r.targetNpcId !== npcId,
|
||||
),
|
||||
}));
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
}
|
||||
|
||||
async setNpcsOrder(npcIds: NpcId[]): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
await this.updateProject((p) => {
|
||||
const byId = new Map((p.npcs ?? []).map((n) => [n.id, n]));
|
||||
const next: ProjectNpc[] = [];
|
||||
for (const id of npcIds) {
|
||||
const n = byId.get(id);
|
||||
if (n) {
|
||||
next.push(n);
|
||||
byId.delete(id);
|
||||
}
|
||||
}
|
||||
for (const n of byId.values()) next.push(n);
|
||||
return { ...p, npcs: next };
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
}
|
||||
|
||||
async upsertNpcRelation(input: {
|
||||
relationId?: NpcRelationId;
|
||||
sourceNpcId: NpcId;
|
||||
targetNpcId: NpcId;
|
||||
label: string;
|
||||
}): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
const label = input.label.trim();
|
||||
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)
|
||||
) {
|
||||
throw new Error('NPC not found');
|
||||
}
|
||||
await this.updateProject((p) => {
|
||||
const relations = [...(p.npcRelations ?? [])];
|
||||
if (input.relationId) {
|
||||
const idx = relations.findIndex((r) => r.id === input.relationId);
|
||||
if (idx < 0) throw new Error('Relation not found');
|
||||
relations[idx] = { ...relations[idx]!, label };
|
||||
} else {
|
||||
relations.push({
|
||||
id: asNpcRelationId(`nrel_${this.randomId()}`),
|
||||
sourceNpcId: input.sourceNpcId,
|
||||
targetNpcId: input.targetNpcId,
|
||||
label,
|
||||
});
|
||||
}
|
||||
return { ...p, npcRelations: relations };
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
}
|
||||
|
||||
async deleteNpcRelation(relationId: NpcRelationId): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
await this.updateProject((p) => ({
|
||||
...p,
|
||||
npcRelations: (p.npcRelations ?? []).filter((r) => r.id !== relationId),
|
||||
}));
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
}
|
||||
|
||||
async saveNow(): Promise<void> {
|
||||
const open = this.openProject;
|
||||
if (!open) return;
|
||||
@@ -1865,6 +2095,60 @@ function normalizeProject(p: Project): Project {
|
||||
(x): x is { id: MaterialId; name: string; assetId: AssetId; rotationDeg: 0 | 90 | 180 | 270 } =>
|
||||
Boolean(x),
|
||||
);
|
||||
const rawNpcs = (p as unknown as { npcs?: unknown[] }).npcs;
|
||||
const npcs: ProjectNpc[] = (Array.isArray(rawNpcs) ? rawNpcs : [])
|
||||
.map((n, index) => {
|
||||
if (!n || typeof n !== 'object') return null;
|
||||
const obj = n as {
|
||||
id?: string;
|
||||
name?: string;
|
||||
avatarAssetId?: AssetId;
|
||||
description?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
};
|
||||
if (!obj.id || !obj.avatarAssetId || typeof obj.name !== 'string') return null;
|
||||
const name = obj.name.trim();
|
||||
if (!name) return null;
|
||||
const x = typeof obj.x === 'number' && Number.isFinite(obj.x) ? obj.x : 80 + (index % 4) * 220;
|
||||
const y =
|
||||
typeof obj.y === 'number' && Number.isFinite(obj.y) ? obj.y : 80 + Math.floor(index / 4) * 200;
|
||||
return {
|
||||
id: asNpcId(String(obj.id)),
|
||||
name,
|
||||
avatarAssetId: obj.avatarAssetId,
|
||||
description: typeof obj.description === 'string' ? obj.description : '',
|
||||
x,
|
||||
y,
|
||||
};
|
||||
})
|
||||
.filter((x): x is ProjectNpc => Boolean(x));
|
||||
const npcIdSet = new Set(npcs.map((n) => n.id));
|
||||
const rawNpcRelations = (p as unknown as { npcRelations?: unknown[] }).npcRelations;
|
||||
const npcRelations: ProjectNpcRelation[] = (Array.isArray(rawNpcRelations) ? rawNpcRelations : [])
|
||||
.map((r) => {
|
||||
if (!r || typeof r !== 'object') return null;
|
||||
const obj = r as {
|
||||
id?: string;
|
||||
sourceNpcId?: string;
|
||||
targetNpcId?: string;
|
||||
/** legacy undirected fields — трактуем как source→target */
|
||||
npcAId?: string;
|
||||
npcBId?: string;
|
||||
label?: string;
|
||||
};
|
||||
const rawSource = obj.sourceNpcId ?? obj.npcAId;
|
||||
const rawTarget = obj.targetNpcId ?? obj.npcBId;
|
||||
if (!obj.id || !rawSource || !rawTarget || typeof obj.label !== 'string') return null;
|
||||
const label = obj.label.trim();
|
||||
if (!label) return null;
|
||||
const sourceNpcId = asNpcId(String(rawSource));
|
||||
const targetNpcId = asNpcId(String(rawTarget));
|
||||
if (sourceNpcId === targetNpcId) return null;
|
||||
if (!npcIdSet.has(sourceNpcId) || !npcIdSet.has(targetNpcId)) return null;
|
||||
return { id: asNpcRelationId(String(obj.id)), sourceNpcId, targetNpcId, label };
|
||||
})
|
||||
.filter((x): x is ProjectNpcRelation => Boolean(x));
|
||||
const metaRaw = p.meta as unknown as { createdWithAppVersion?: string; appVersion?: string };
|
||||
const createdWithAppVersion = (() => {
|
||||
const c = metaRaw.createdWithAppVersion?.trim();
|
||||
@@ -1886,6 +2170,8 @@ function normalizeProject(p: Project): Project {
|
||||
scenes,
|
||||
campaignAudios,
|
||||
materials,
|
||||
npcs,
|
||||
npcRelations,
|
||||
sceneGraphNodes,
|
||||
sceneGraphEdges,
|
||||
currentGraphNodeId,
|
||||
|
||||
@@ -52,6 +52,16 @@ void test('createWindows: окно материалов закрывается
|
||||
assert.match(src, /export function closeMultiWindow[\s\S]*closeMaterialsWindow/);
|
||||
});
|
||||
|
||||
void test('createWindows: окно НПС закрывается с multi-window', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('openNpcsWindow'));
|
||||
assert.ok(src.includes('closeNpcsWindow'));
|
||||
assert.ok(src.includes('openNpcsEditorWindow'));
|
||||
assert.ok(src.includes("createWindow('npcs'"));
|
||||
assert.ok(src.includes("createWindow('npcsEditor'"));
|
||||
assert.match(src, /export function closeMultiWindow[\s\S]*closeNpcsWindow/);
|
||||
});
|
||||
|
||||
void test('createWindows: production — loadFile для HTML (не только file://)', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('loadFile'));
|
||||
|
||||
@@ -8,7 +8,14 @@ import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import { getBootSplashWindow } from './bootWindow';
|
||||
import { loadBrandingWindowIcon } from './brandingIcon';
|
||||
|
||||
type WindowKind = 'editor' | 'presentation' | 'control' | 'sceneDescription' | 'materials';
|
||||
type WindowKind =
|
||||
| 'editor'
|
||||
| 'presentation'
|
||||
| 'control'
|
||||
| 'sceneDescription'
|
||||
| 'materials'
|
||||
| 'npcsEditor'
|
||||
| 'npcs';
|
||||
|
||||
const windows = new Map<WindowKind, BrowserWindow>();
|
||||
|
||||
@@ -19,6 +26,14 @@ let pendingSceneDescriptionHtml = '';
|
||||
const MATERIALS_WINDOW_WIDTH = 300;
|
||||
const MATERIALS_WINDOW_HEIGHT = 720;
|
||||
|
||||
/** Редактор НПС — как основной редактор, шире инспектор. */
|
||||
const NPCS_EDITOR_WINDOW_WIDTH = 1400;
|
||||
const NPCS_EDITOR_WINDOW_HEIGHT = 860;
|
||||
|
||||
/** Пульт НПС: деталь слева + список справа. */
|
||||
const NPCS_WINDOW_WIDTH = 720;
|
||||
const NPCS_WINDOW_HEIGHT = 720;
|
||||
|
||||
/** Учитываем окна, которые уже уничтожены при каскадном закрытии (родитель → дочернее). */
|
||||
function broadcastMultiWindowStateChanged(open: boolean): void {
|
||||
for (const w of BrowserWindow.getAllWindows()) {
|
||||
@@ -77,6 +92,10 @@ function pageNameForKind(kind: WindowKind): string {
|
||||
return 'sceneDescription.html';
|
||||
case 'materials':
|
||||
return 'materials.html';
|
||||
case 'npcsEditor':
|
||||
return 'npcsEditor.html';
|
||||
case 'npcs':
|
||||
return 'npcs.html';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +163,8 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number }
|
||||
if (kind === 'control') return { width: 1200, height: 800 };
|
||||
if (kind === 'sceneDescription') return { width: 720, height: 640 };
|
||||
if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT };
|
||||
if (kind === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT };
|
||||
if (kind === 'npcs') return { width: NPCS_WINDOW_WIDTH, height: NPCS_WINDOW_HEIGHT };
|
||||
return { width: 1280, height: 800 };
|
||||
}
|
||||
|
||||
@@ -171,6 +192,25 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
autoHideMenuBar: true,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'npcsEditor'
|
||||
? {
|
||||
width: NPCS_EDITOR_WINDOW_WIDTH,
|
||||
height: NPCS_EDITOR_WINDOW_HEIGHT,
|
||||
minWidth: 1100,
|
||||
minHeight: 640,
|
||||
autoHideMenuBar: true,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'npcs'
|
||||
? {
|
||||
width: NPCS_WINDOW_WIDTH,
|
||||
height: NPCS_WINDOW_HEIGHT,
|
||||
minWidth: 560,
|
||||
maxWidth: 900,
|
||||
minHeight: 480,
|
||||
autoHideMenuBar: true,
|
||||
}
|
||||
: {}),
|
||||
show: false,
|
||||
backgroundColor: '#09090B',
|
||||
...(icon ? { icon } : {}),
|
||||
@@ -195,7 +235,12 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
}
|
||||
|
||||
win.setTitle(windowChromeTitle(kind, app.getLocale()));
|
||||
if (kind === 'sceneDescription' || kind === 'materials') {
|
||||
if (
|
||||
kind === 'sceneDescription' ||
|
||||
kind === 'materials' ||
|
||||
kind === 'npcsEditor' ||
|
||||
kind === 'npcs'
|
||||
) {
|
||||
win.setMenuBarVisibility(false);
|
||||
}
|
||||
|
||||
@@ -227,6 +272,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
if (!open) {
|
||||
closeSceneDescriptionWindow();
|
||||
closeMaterialsWindow();
|
||||
closeNpcsWindow();
|
||||
}
|
||||
broadcastMultiWindowStateChanged(open);
|
||||
});
|
||||
@@ -312,6 +358,7 @@ export function openMultiWindow() {
|
||||
export function closeMultiWindow(): void {
|
||||
closeSceneDescriptionWindow();
|
||||
closeMaterialsWindow();
|
||||
closeNpcsWindow();
|
||||
const pres = windows.get('presentation');
|
||||
const ctrl = windows.get('control');
|
||||
if (pres) pres.close();
|
||||
@@ -336,6 +383,20 @@ export function closeMaterialsWindow(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function closeNpcsEditorWindow(): void {
|
||||
const win = windows.get('npcsEditor');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeNpcsWindow(): void {
|
||||
const win = windows.get('npcs');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function getSceneDescriptionContent(): string {
|
||||
return pendingSceneDescriptionHtml;
|
||||
}
|
||||
@@ -413,6 +474,75 @@ export function openMaterialsWindow(): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** Редактор НПС: отдельное окно с графом и инспектором. */
|
||||
export function openNpcsEditorWindow(): void {
|
||||
const existing = windows.get('npcsEditor');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('editor');
|
||||
const win = createWindow('npcsEditor', parent ? { parent } : undefined);
|
||||
const { width, height } = win.getBounds();
|
||||
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||
const { x, y, width: dw, height: dh } = display.workArea;
|
||||
win.setBounds({
|
||||
x: Math.round(x + Math.max(0, (dw - width) / 2)),
|
||||
y: Math.round(y + Math.max(0, (dh - height) / 2)),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Пульт НПС: список + описание выбранного; оверлей аватара на сцене. */
|
||||
export function openNpcsWindow(): void {
|
||||
const existing = windows.get('npcs');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
const b = existing.getBounds();
|
||||
existing.setBounds({
|
||||
x: b.x,
|
||||
y: b.y,
|
||||
width: Math.min(NPCS_WINDOW_WIDTH, Math.max(560, b.width)),
|
||||
height: Math.max(NPCS_WINDOW_HEIGHT, b.height),
|
||||
});
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('control') ?? windows.get('presentation');
|
||||
const win = createWindow('npcs', parent ? { parent } : undefined);
|
||||
const { width, height } = win.getBounds();
|
||||
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||
const { x, y, width: dw, height: dh } = display.workArea;
|
||||
win.setBounds({
|
||||
x: Math.round(x + Math.max(0, dw - width - 24)),
|
||||
y: Math.round(y + (dh - height) / 2),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function togglePresentationFullscreen(): boolean {
|
||||
const pres = windows.get('presentation');
|
||||
if (!pres) return false;
|
||||
|
||||
@@ -17,9 +17,10 @@ import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay';
|
||||
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
|
||||
import { useEffectsState } from '../shared/effects/useEffectsState';
|
||||
import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState';
|
||||
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../shared/types';
|
||||
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
|
||||
import { MaterialOverlay } from '../shared/materials/MaterialOverlay';
|
||||
import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverlayState';
|
||||
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
|
||||
import { Button } from '../shared/ui/controls';
|
||||
import { Surface } from '../shared/ui/Surface';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
@@ -91,6 +92,7 @@ export function ControlApp() {
|
||||
const [fxState, fx] = useEffectsState();
|
||||
const [sdState, sd] = useSceneDarknessState();
|
||||
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
|
||||
const [npcsOverlay, npcsApi] = useNpcsOverlayState();
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const historyRef = useRef<GraphNodeId[]>([]);
|
||||
const [history, setHistory] = useState<GraphNodeId[]>([]);
|
||||
@@ -1203,6 +1205,28 @@ export function ControlApp() {
|
||||
</svg>
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
title={t('control.npcsTool')}
|
||||
ariaLabel={t('control.npcsTool')}
|
||||
onClick={() => {
|
||||
void api.invoke(ipcChannels.windows.openNpcs, {}).catch((err) => {
|
||||
console.error('[control] openNpcs failed', err);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className={styles.iconGlyph} aria-hidden>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
|
||||
<circle cx="12" cy="8" r="3.4" fill="#3b82f6" />
|
||||
<path
|
||||
fill="#22c55e"
|
||||
d="M5.2 19.2c.6-3.4 3.2-5.2 6.8-5.2s6.2 1.8 6.8 5.2c.1.5-.3 1-.8 1H6c-.5 0-.9-.5-.8-1z"
|
||||
/>
|
||||
<circle cx="12" cy="8" r="1.4" fill="#93c5fd" />
|
||||
</svg>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.spacer12} />
|
||||
{!isVideoPreviewScene ? (
|
||||
@@ -1586,6 +1610,32 @@ export function ControlApp() {
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{(() => {
|
||||
const activeNpc =
|
||||
session?.project && npcsOverlay?.activeNpcId
|
||||
? (session.project.npcs ?? []).find((n) => n.id === npcsOverlay.activeNpcId)
|
||||
: undefined;
|
||||
if (!activeNpc) return null;
|
||||
return (
|
||||
<MaterialOverlay
|
||||
assetId={activeNpc.avatarAssetId}
|
||||
layout={npcsOverlay?.layout ?? DEFAULT_NPCS_OVERLAY_LAYOUT}
|
||||
editable
|
||||
zoomTool={npcsOverlay?.zoomTool ?? null}
|
||||
showClose
|
||||
closeLabel={t('npcs.closeOverlay')}
|
||||
onClose={() => {
|
||||
void npcsApi.dispatch({ kind: 'hide' });
|
||||
}}
|
||||
onLayoutChange={(layout) => {
|
||||
void npcsApi.dispatch({ kind: 'layout.set', layout });
|
||||
}}
|
||||
onZoomAt={(nx, ny) => {
|
||||
void npcsApi.dispatch({ kind: 'zoomAt', nx, ny });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
|
||||
@@ -115,6 +115,17 @@
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.gamePropsButtons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gamePropsButtons > * {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebarScroll {
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
|
||||
@@ -967,7 +967,20 @@ export function EditorApp() {
|
||||
}}
|
||||
/>
|
||||
<div className={styles.spacer6} />
|
||||
<Button onClick={() => setMaterialsManagerOpen(true)}>{t('materials.open')}</Button>
|
||||
<div className={styles.gamePropsButtons}>
|
||||
<Button onClick={() => setMaterialsManagerOpen(true)}>{t('materials.open')}</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
void getDndApi()
|
||||
.invoke(ipcChannels.windows.openNpcsEditor, {})
|
||||
.catch((err) => {
|
||||
console.error('[editor] openNpcsEditor failed', err);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('npcs.open')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.spacer18} />
|
||||
<div className={styles.inspectorTitle}>{t('scenes.inspectorScene')}</div>
|
||||
{state.selectedSceneId ? (
|
||||
|
||||
@@ -23,6 +23,8 @@ function minimalProject(overrides: Partial<Project>): Project {
|
||||
assets: {},
|
||||
campaignAudios: [],
|
||||
materials: [],
|
||||
npcs: [],
|
||||
npcRelations: [],
|
||||
currentSceneId: null,
|
||||
currentGraphNodeId: null,
|
||||
sceneGraphNodes: [],
|
||||
|
||||
@@ -9,6 +9,7 @@ export const HELP_SECTION_IDS = [
|
||||
'sceneProps',
|
||||
'campaignAudio',
|
||||
'materials',
|
||||
'npcs',
|
||||
'session',
|
||||
'controlPanel',
|
||||
'transitions',
|
||||
|
||||
@@ -58,3 +58,12 @@ void test('EDITOR_MESSAGES: materials.* keys exist in both locales', () => {
|
||||
assert.notEqual(EDITOR_MESSAGES.en[key]!.trim(), '');
|
||||
}
|
||||
});
|
||||
|
||||
void test('EDITOR_MESSAGES: npcs.* keys exist in both locales', () => {
|
||||
const npcKeys = Object.keys(EDITOR_MESSAGES.ru).filter((k) => k.startsWith('npcs.'));
|
||||
assert.ok(npcKeys.length >= 20, `expected npcs.* keys, got ${String(npcKeys.length)}`);
|
||||
for (const key of npcKeys) {
|
||||
assert.ok(EDITOR_MESSAGES.en[key], `missing en ${key}`);
|
||||
assert.notEqual(EDITOR_MESSAGES.en[key]!.trim(), '');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -179,13 +179,17 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'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.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При смене сцены показ НПС сбрасывается. Игроки на презентации видят только аватар.',
|
||||
|
||||
'help.section.session.title': 'Запуск сессии',
|
||||
'help.section.session.body':
|
||||
'Когда кампания готова, можно начать игру.\n\nОбычный запуск:\n\n1) На карте связей щёлкните правой кнопкой по карточке старта → «Начальная сцена».\n\n2) Нажмите «Запустить» в шапке редактора.\n\nБыстрый запуск с любой карточки: правый клик по нужной карточке на карте → «Запустить с этой сцены». Презентация и пульт откроются сразу с выбранного места.\n\nОткроются «Презентация» (для игроков) и «Пульт управления» (для вас). Редактор на время показа затемняется — так и должно быть.\n\nВернуться к подготовке:\n\n1) На пульте нажмите «Выключить демонстрацию» или «Завершить показ» (если дальше некуда переходить).\n\n2) Дождитесь закрытия обоих окон.\n\nОкно «Презентация» перенесите на второй монитор, проектор или ТВ и разверните на весь экран (F11). Игроки увидят только картинку, видео и эффекты — без ваших кнопок.',
|
||||
|
||||
'help.section.controlPanel.title': 'Пульт управления',
|
||||
'help.section.controlPanel.body':
|
||||
'Пульт — ваш стол во время игры. Игроки смотрят на презентацию, вы управляете всем отсюда.\n\nСлева сверху — «Инструменты», затем эффекты и ход сюжета; справа — мини-копия экрана игроков, варианты переходов и музыка.\n\nВ «Инструменты» кнопка с книгой открывает отдельное окно с оформленным описанием текущей сцены. Если описания нет, кнопка неактивна — при наведении подсказка «Описание отсутствует». Кнопка материалов (иконка карты) открывает окно со списком материалов кампании — подробнее в разделе «Материалы».\n\n«Предпросмотр экрана» показывает то же, что видят игроки. На сценах с картинкой здесь же рисуют эффекты — они сразу появляются на большом экране.\n\n«Сюжетная линия» — цепочка пройденных эпизодов. Текущая сцена помечена «ТЕКУЩАЯ СЦЕНА». Клик по прошлому шагу переносит партию к тому месту на карте и добавляет новый шаг в историю.\n\n«Выключить демонстрацию» — закончить показ и вернуться в редактор.',
|
||||
'Пульт — ваш стол во время игры. Игроки смотрят на презентацию, вы управляете всем отсюда.\n\nСлева сверху — «Инструменты», затем эффекты и ход сюжета; справа — мини-копия экрана игроков, варианты переходов и музыка.\n\nВ «Инструменты» кнопка с книгой открывает отдельное окно с оформленным описанием текущей сцены. Если описания нет, кнопка неактивна — при наведении подсказка «Описание отсутствует». Кнопка материалов (иконка карты) открывает окно со списком материалов кампании — подробнее в разделе «Материалы». Кнопка НПС (цветная иконка человека) открывает окно персонажей — см. раздел «НПС».\n\n«Предпросмотр экрана» показывает то же, что видят игроки. На сценах с картинкой здесь же рисуют эффекты — они сразу появляются на большом экране.\n\n«Сюжетная линия» — цепочка пройденных эпизодов. Текущая сцена помечена «ТЕКУЩАЯ СЦЕНА». Клик по прошлому шагу переносит партию к тому месту на карте и добавляет новый шаг в историю.\n\n«Выключить демонстрацию» — закончить показ и вернуться в редактор.',
|
||||
|
||||
'help.section.transitions.title': 'Переходы между сценами',
|
||||
'help.section.transitions.body':
|
||||
@@ -360,6 +364,54 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'materials.zoomOutHint': 'Кликните по материалу в предпросмотре пульта, чтобы уменьшить.',
|
||||
'materials.zoomIdleHint': 'Выберите лупу, затем кликните по материалу в предпросмотре пульта.',
|
||||
|
||||
'npcs.open': 'НПС',
|
||||
'npcs.editorTitle': 'НПС',
|
||||
'npcs.add': 'Добавить',
|
||||
'npcs.addTitle': 'Новый НПС',
|
||||
'npcs.editTitle': 'Изменить НПС',
|
||||
'npcs.edit': 'Изменить',
|
||||
'npcs.tileMenu': 'Меню НПС',
|
||||
'npcs.search': 'Поиск НПС…',
|
||||
'npcs.searchEmpty': 'Ничего не найдено.',
|
||||
'npcs.empty': 'НПС пока нет.',
|
||||
'npcs.selectPrompt': 'Выберите НПС в списке или на графе.',
|
||||
'npcs.name': 'ИМЯ',
|
||||
'npcs.namePlaceholder': 'Имя персонажа…',
|
||||
'npcs.nameRequired': 'Укажите имя.',
|
||||
'npcs.nameDup': 'НПС с таким именем уже есть.',
|
||||
'npcs.avatar': 'АВАТАР',
|
||||
'npcs.avatarEmpty': 'Аватар не выбран',
|
||||
'npcs.avatarRequired': 'Выберите аватар.',
|
||||
'npcs.chooseAvatar': 'Выбрать аватар',
|
||||
'npcs.dropHint': 'Перетащите изображение (PNG, JPG, WebP)',
|
||||
'npcs.description': 'ОПИСАНИЕ',
|
||||
'npcs.descriptionPlaceholder': 'Описание персонажа…',
|
||||
'npcs.descriptionEmpty': 'Описание отсутствует',
|
||||
'npcs.relations': 'Отношения',
|
||||
'npcs.untitled': 'Без имени',
|
||||
'npcs.deleteTitle': 'Удаление НПС',
|
||||
'npcs.deleteConfirm': 'Вы уверены, что хотите удалить НПС «{name}»? Все связи с ним будут удалены.',
|
||||
'npcs.relationCreateTitle': 'Название связи',
|
||||
'npcs.relationEditTitle': 'Название связи',
|
||||
'npcs.relationLabel': 'НАЗВАНИЕ',
|
||||
'npcs.relationLabelPlaceholder': 'Например: друзья, враги…',
|
||||
'npcs.relationLabelRequired': 'Укажите название связи.',
|
||||
'npcs.relationEdit': 'Редактировать',
|
||||
'npcs.relationDelete': 'Удалить',
|
||||
'npcs.relationDeleteTitle': 'Удаление связи',
|
||||
'npcs.relationDeleteConfirm': 'Удалить связь «{name}»?',
|
||||
'npcs.graphZoomBar': 'Масштаб графа',
|
||||
'npcs.graphZoomIn': 'Увеличить',
|
||||
'npcs.graphZoomOut': 'Уменьшить',
|
||||
'npcs.graphFitAll': 'Показать всё',
|
||||
'npcs.windowEmpty': 'Добавьте НПС в редакторе.',
|
||||
'npcs.closeOverlay': 'Закрыть НПС',
|
||||
'npcs.zoomIn': 'Увеличить',
|
||||
'npcs.zoomOut': 'Уменьшить',
|
||||
'npcs.zoomInHint': 'Кликните по аватару в предпросмотре пульта, чтобы увеличить.',
|
||||
'npcs.zoomOutHint': 'Кликните по аватару в предпросмотре пульта, чтобы уменьшить.',
|
||||
'npcs.zoomIdleHint': 'Выберите лупу, затем кликните по аватару в предпросмотре пульта.',
|
||||
|
||||
'scene.title': 'НАЗВАНИЕ СЦЕНЫ',
|
||||
'scene.description': 'ОПИСАНИЕ',
|
||||
'scene.descriptionEmpty': 'описание отсутствует',
|
||||
@@ -423,6 +475,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.instruments': 'ИНСТРУМЕНТЫ',
|
||||
'control.descriptionTool': 'Описание',
|
||||
'control.materialsTool': 'Материалы',
|
||||
'control.npcsTool': 'НПС',
|
||||
'control.descriptionMissing': 'Описание отсутствует',
|
||||
'control.effects': 'ЭФФЕКТЫ',
|
||||
'control.tools': 'Очистка',
|
||||
@@ -618,13 +671,17 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'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.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 and are not tied to one scene.\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.',
|
||||
|
||||
'help.section.session.title': 'Starting a session',
|
||||
'help.section.session.body':
|
||||
'When your campaign is ready, you can start playing.\n\nStandard start:\n\n1) On the story map, right-click the starting card → Start scene.\n\n2) Click Run in the editor header.\n\nQuick start from any card: right-click the card on the map → Start from this scene. Presentation and the control panel open at that spot right away.\n\nPresentation (for players) and the Control panel (for you) open. The editor dims while the show runs — that is expected.\n\nReturn to prep:\n\n1) On the control panel, click Stop presentation or End presentation (when there is nowhere left to go).\n\n2) Wait until both windows close.\n\nMove the Presentation window to a second monitor, projector, or TV and go fullscreen (F11). Players see only the image, video, and effects — not your buttons.',
|
||||
|
||||
'help.section.controlPanel.title': 'Control panel',
|
||||
'help.section.controlPanel.body':
|
||||
'The control panel is your desk during play. Players watch presentation; you run everything from here.\n\nTop-left: Tools, then effects and storyline. On the right: a mini copy of the player screen, branch options, and music.\n\nUnder Tools, the book button opens a separate window with the current scene’s formatted description. If there is no description, the button is disabled — the tooltip reads “No description”. The materials button (map icon) opens a window with the campaign materials list — see the Materials section for details.\n\nScreen preview shows what players see. On image scenes, paint effects here — they appear on the big screen right away.\n\nStoryline lists episodes you have visited. The current scene is marked CURRENT SCENE. Click an earlier step to move the party back to that spot and add a new history entry.\n\nStop presentation ends the show and unlocks the editor.',
|
||||
'The control panel is your desk during play. Players watch presentation; you run everything from here.\n\nTop-left: Tools, then effects and storyline. On the right: a mini copy of the player screen, branch options, and music.\n\nUnder Tools, the book button opens a separate window with the current scene’s formatted description. If there is no description, the button is disabled — the tooltip reads “No description”. The materials button (map icon) opens a window with the campaign materials list — see the Materials section for details. The NPCs button (colored person icon) opens the characters window — see the NPCs section.\n\nScreen preview shows what players see. On image scenes, paint effects here — they appear on the big screen right away.\n\nStoryline lists episodes you have visited. The current scene is marked CURRENT SCENE. Click an earlier step to move the party back to that spot and add a new history entry.\n\nStop presentation ends the show and unlocks the editor.',
|
||||
|
||||
'help.section.transitions.title': 'Scene transitions',
|
||||
'help.section.transitions.body':
|
||||
@@ -800,6 +857,54 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'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.',
|
||||
|
||||
'npcs.open': 'NPCs',
|
||||
'npcs.editorTitle': 'NPCs',
|
||||
'npcs.add': 'Add',
|
||||
'npcs.addTitle': 'New NPC',
|
||||
'npcs.editTitle': 'Edit NPC',
|
||||
'npcs.edit': 'Edit',
|
||||
'npcs.tileMenu': 'NPC menu',
|
||||
'npcs.search': 'Search NPCs…',
|
||||
'npcs.searchEmpty': 'No matches.',
|
||||
'npcs.empty': 'No NPCs yet.',
|
||||
'npcs.selectPrompt': 'Select an NPC in the list or on the graph.',
|
||||
'npcs.name': 'NAME',
|
||||
'npcs.namePlaceholder': 'Character name…',
|
||||
'npcs.nameRequired': 'Name is required.',
|
||||
'npcs.nameDup': 'An NPC with this name already exists.',
|
||||
'npcs.avatar': 'AVATAR',
|
||||
'npcs.avatarEmpty': 'No avatar selected',
|
||||
'npcs.avatarRequired': 'Choose an avatar.',
|
||||
'npcs.chooseAvatar': 'Choose avatar',
|
||||
'npcs.dropHint': 'Drop an image (PNG, JPG, WebP)',
|
||||
'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.relationCreateTitle': 'Relation name',
|
||||
'npcs.relationEditTitle': 'Relation name',
|
||||
'npcs.relationLabel': 'NAME',
|
||||
'npcs.relationLabelPlaceholder': 'e.g. friends, rivals…',
|
||||
'npcs.relationLabelRequired': 'Relation name is required.',
|
||||
'npcs.relationEdit': 'Edit',
|
||||
'npcs.relationDelete': 'Delete',
|
||||
'npcs.relationDeleteTitle': 'Delete relation',
|
||||
'npcs.relationDeleteConfirm': 'Delete relation “{name}”?',
|
||||
'npcs.graphZoomBar': 'Graph zoom',
|
||||
'npcs.graphZoomIn': 'Zoom in',
|
||||
'npcs.graphZoomOut': 'Zoom out',
|
||||
'npcs.graphFitAll': 'Fit view',
|
||||
'npcs.windowEmpty': 'Add NPCs in the editor.',
|
||||
'npcs.closeOverlay': 'Close NPC',
|
||||
'npcs.zoomIn': 'Zoom in',
|
||||
'npcs.zoomOut': 'Zoom out',
|
||||
'npcs.zoomInHint': 'Click the avatar on the control preview to zoom in.',
|
||||
'npcs.zoomOutHint': 'Click the avatar on the control preview to zoom out.',
|
||||
'npcs.zoomIdleHint': 'Pick a magnifier, then click the avatar on the control preview.',
|
||||
|
||||
'scene.title': 'SCENE TITLE',
|
||||
'scene.description': 'DESCRIPTION',
|
||||
'scene.descriptionEmpty': 'no description',
|
||||
@@ -862,6 +967,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.instruments': 'TOOLS',
|
||||
'control.descriptionTool': 'Description',
|
||||
'control.materialsTool': 'Materials',
|
||||
'control.npcsTool': 'NPCs',
|
||||
'control.descriptionMissing': 'No description',
|
||||
'control.effects': 'EFFECTS',
|
||||
'control.tools': 'Cleanup',
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||
<title>TTRPG</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/npcs/npcsMain.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,157 @@
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import { EditorContent, useEditor, useEditorState } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
|
||||
import { normalizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
|
||||
import modalStyles from '../editor/SceneDescriptionModal.module.css';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
|
||||
import styles from './NpcsEditorApp.module.css';
|
||||
|
||||
type NpcDescriptionFieldProps = {
|
||||
html: string;
|
||||
onCommit: (html: string) => void;
|
||||
};
|
||||
|
||||
function ToolButton({
|
||||
active = false,
|
||||
title,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active?: boolean;
|
||||
title: string;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
aria-pressed={active}
|
||||
className={[modalStyles.toolBtn, active ? modalStyles.toolBtnActive : ''].filter(Boolean).join(' ')}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps) {
|
||||
const { t } = useEditorI18n();
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [2, 3] },
|
||||
codeBlock: false,
|
||||
link: false,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: t('npcs.descriptionPlaceholder'),
|
||||
}),
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
content: html || '',
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: true,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: [modalStyles.prose, 'tiptap'].join(' '),
|
||||
'aria-label': t('npcs.description'),
|
||||
},
|
||||
},
|
||||
onBlur: ({ editor: ed }) => {
|
||||
onCommit(normalizeSceneDescriptionHtml(ed.getHTML()));
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const current = normalizeSceneDescriptionHtml(editor.getHTML());
|
||||
const next = normalizeSceneDescriptionHtml(html);
|
||||
if (current !== next) {
|
||||
editor.commands.setContent(html || '', { emitUpdate: false });
|
||||
}
|
||||
}, [editor, html]);
|
||||
|
||||
const toolbarState = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor: ed }) => ({
|
||||
bold: ed.isActive('bold'),
|
||||
italic: ed.isActive('italic'),
|
||||
bulletList: ed.isActive('bulletList'),
|
||||
orderedList: ed.isActive('orderedList'),
|
||||
h2: ed.isActive('heading', { level: 2 }),
|
||||
h3: ed.isActive('heading', { level: 3 }),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.descShell}>
|
||||
<div className={modalStyles.toolbar}>
|
||||
<div className={modalStyles.toolbarGroup}>
|
||||
<ToolButton
|
||||
active={toolbarState.bold}
|
||||
title={t('scene.descriptionBold')}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
>
|
||||
B
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
active={toolbarState.italic}
|
||||
title={t('scene.descriptionItalic')}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
>
|
||||
I
|
||||
</ToolButton>
|
||||
</div>
|
||||
<div className={modalStyles.toolbarSep} />
|
||||
<div className={modalStyles.toolbarGroup}>
|
||||
<ToolButton
|
||||
active={toolbarState.h2}
|
||||
title={t('scene.descriptionHeading2')}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
>
|
||||
H2
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
active={toolbarState.h3}
|
||||
title={t('scene.descriptionHeading3')}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
>
|
||||
H3
|
||||
</ToolButton>
|
||||
</div>
|
||||
<div className={modalStyles.toolbarSep} />
|
||||
<div className={modalStyles.toolbarGroup}>
|
||||
<ToolButton
|
||||
active={toolbarState.bulletList}
|
||||
title={t('scene.descriptionBulletList')}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
>
|
||||
•
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
active={toolbarState.orderedList}
|
||||
title={t('scene.descriptionOrderedList')}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
>
|
||||
1.
|
||||
</ToolButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.descContent}>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { ProjectNpc } from '../../shared/types';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import {
|
||||
filterMaterialImagePaths,
|
||||
getDroppedFileEntries,
|
||||
pickFirstMaterialImagePath,
|
||||
useFileDropZone,
|
||||
} from '../editor/fileDrop';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import matStyles from '../editor/MaterialsModals.module.css';
|
||||
|
||||
function normalizeName(input: string): string {
|
||||
return input.trim().toLowerCase();
|
||||
}
|
||||
|
||||
type NpcEditModalProps = {
|
||||
open: boolean;
|
||||
initial: ProjectNpc | null;
|
||||
existingNames: string[];
|
||||
onClose: () => void;
|
||||
onPickImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
|
||||
onSave: (input: { name: string; filePath?: string }) => Promise<void>;
|
||||
};
|
||||
|
||||
export function NpcEditModal({
|
||||
open,
|
||||
initial,
|
||||
existingNames,
|
||||
onClose,
|
||||
onPickImage,
|
||||
onSave,
|
||||
}: NpcEditModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const [name, setName] = useState('');
|
||||
const [filePath, setFilePath] = useState<string | null>(null);
|
||||
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const existingUrl = useAssetUrl(initial?.avatarAssetId ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(initial?.name ?? '');
|
||||
setFilePath(null);
|
||||
setLocalPreviewUrl(null);
|
||||
setSaving(false);
|
||||
setError(null);
|
||||
}, [initial, open]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (localPreviewUrl?.startsWith('blob:')) URL.revokeObjectURL(localPreviewUrl);
|
||||
};
|
||||
}, [localPreviewUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open]);
|
||||
|
||||
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
|
||||
setFilePath(path);
|
||||
setLocalPreviewUrl((prev) => {
|
||||
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||
return previewUrl;
|
||||
});
|
||||
};
|
||||
|
||||
const drop = useFileDropZone({
|
||||
onDropPaths: (paths) => {
|
||||
const picked = pickFirstMaterialImagePath(paths);
|
||||
if (!picked) return;
|
||||
setPreviewFromPathAndUrl(picked, '');
|
||||
},
|
||||
filterPaths: filterMaterialImagePaths,
|
||||
});
|
||||
|
||||
const trimmed = name.trim();
|
||||
const nameOk = trimmed.length >= 1;
|
||||
const nameDup =
|
||||
nameOk &&
|
||||
existingNames.some(
|
||||
(n) =>
|
||||
normalizeName(n) === normalizeName(trimmed) &&
|
||||
normalizeName(n) !== normalizeName(initial?.name ?? ''),
|
||||
);
|
||||
const hasImage = Boolean(filePath) || Boolean(initial?.avatarAssetId);
|
||||
const canSave = nameOk && !nameDup && hasImage && !saving;
|
||||
const previewSrc = localPreviewUrl || existingUrl;
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={editorStyles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
|
||||
<div className={editorStyles.modalHeader}>
|
||||
<div className={editorStyles.modalTitle}>
|
||||
{initial ? t('npcs.editTitle') : t('npcs.addTitle')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={editorStyles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={editorStyles.fieldGrid}>
|
||||
<div className={editorStyles.fieldLabel}>{t('npcs.name')}</div>
|
||||
<Input value={name} onChange={setName} placeholder={t('npcs.namePlaceholder')} />
|
||||
{!nameOk ? <div className={editorStyles.fieldError}>{t('npcs.nameRequired')}</div> : null}
|
||||
{nameDup ? <div className={editorStyles.fieldError}>{t('npcs.nameDup')}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className={editorStyles.fieldGrid}>
|
||||
<div className={editorStyles.fieldLabel}>{t('npcs.avatar')}</div>
|
||||
<div
|
||||
className={[matStyles.imageDrop, drop.dragOver ? matStyles.imageDropOver : ''].join(' ')}
|
||||
onDragEnter={drop.onDragEnter}
|
||||
onDragLeave={drop.onDragLeave}
|
||||
onDragOver={drop.onDragOver}
|
||||
onDrop={(e) => {
|
||||
drop.onDrop(e);
|
||||
const entries = getDroppedFileEntries(e);
|
||||
const files = e.dataTransfer?.files;
|
||||
for (let i = 0; i < entries.length; i += 1) {
|
||||
const entry = entries[i]!;
|
||||
if (!pickFirstMaterialImagePath([entry.path])) continue;
|
||||
const file = files?.[i];
|
||||
if (file) {
|
||||
setPreviewFromPathAndUrl(entry.path, URL.createObjectURL(file));
|
||||
return;
|
||||
}
|
||||
setPreviewFromPathAndUrl(entry.path, '');
|
||||
return;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{drop.dragOver ? (
|
||||
<div className={editorStyles.dropHintOverlay}>{t('npcs.dropHint')}</div>
|
||||
) : null}
|
||||
{previewSrc ? (
|
||||
<img className={matStyles.previewThumb} src={previewSrc} alt="" />
|
||||
) : (
|
||||
<div className={editorStyles.muted}>{t('npcs.avatarEmpty')}</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
const picked = await onPickImage();
|
||||
if (!picked) return;
|
||||
setPreviewFromPathAndUrl(picked.filePath, picked.previewDataUrl);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('npcs.chooseAvatar')}
|
||||
</Button>
|
||||
</div>
|
||||
{!hasImage ? <div className={editorStyles.fieldError}>{t('npcs.avatarRequired')}</div> : null}
|
||||
</div>
|
||||
|
||||
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
|
||||
|
||||
<div className={editorStyles.modalFooter}>
|
||||
<Button onClick={onClose} disabled={saving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canSave}
|
||||
onClick={() => {
|
||||
if (!canSave) return;
|
||||
void (async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave(filePath ? { name: trimmed, filePath } : { name: trimmed });
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
.wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: #0c0c0f;
|
||||
}
|
||||
|
||||
.zoomBar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
background: rgba(24, 24, 27, 0.92);
|
||||
border: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
.zoomBtn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 8px;
|
||||
background: #18181b;
|
||||
color: var(--text1);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.zoomBtn:hover {
|
||||
background: #27272a;
|
||||
}
|
||||
|
||||
.node {
|
||||
width: 120px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--stroke);
|
||||
background: #18181b;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.nodeActive {
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 0 0 1px #60a5fa, 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #09090b;
|
||||
}
|
||||
|
||||
.avatarImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: var(--text1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.handle {
|
||||
width: 10px !important;
|
||||
height: 10px !important;
|
||||
background: #71717a !important;
|
||||
border: 2px solid #18181b !important;
|
||||
}
|
||||
|
||||
.handle:hover {
|
||||
background: #60a5fa !important;
|
||||
}
|
||||
|
||||
.edgeLabel {
|
||||
pointer-events: all;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(24, 24, 27, 0.92);
|
||||
border: 1px solid var(--stroke);
|
||||
color: var(--text1);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
transform: translate(-50%, -50%);
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.edgeLabelActive {
|
||||
border-color: #60a5fa;
|
||||
color: #93c5fd;
|
||||
box-shadow: 0 0 0 1px #60a5fa;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.menuBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 79;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 80;
|
||||
min-width: 160px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
background: #18181b;
|
||||
border: 1px solid var(--stroke);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text1);
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.menuItem:hover {
|
||||
background: #27272a;
|
||||
}
|
||||
|
||||
.menuItemDanger:hover {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
color: #fca5a5;
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
BaseEdge,
|
||||
ConnectionMode,
|
||||
EdgeLabelRenderer,
|
||||
Handle,
|
||||
MarkerType,
|
||||
Panel,
|
||||
Position,
|
||||
ReactFlowProvider,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
useReactFlow,
|
||||
type Connection,
|
||||
type Edge,
|
||||
type EdgeProps,
|
||||
type Node,
|
||||
type NodeProps,
|
||||
type OnConnectStartParams,
|
||||
} from 'reactflow';
|
||||
import 'reactflow/dist/style.css';
|
||||
|
||||
import type { NpcId, NpcRelationId, ProjectNpc, ProjectNpcRelation } from '../../shared/types';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import styles from './NpcGraph.module.css';
|
||||
|
||||
type OpenEdgeMenuFn = (relationId: NpcRelationId, x: number, y: number) => void;
|
||||
type SelectSourceNpcFn = (sourceNpcId: NpcId) => void;
|
||||
const OpenEdgeMenuContext = createContext<OpenEdgeMenuFn | null>(null);
|
||||
const SelectSourceNpcContext = createContext<SelectSourceNpcFn | null>(null);
|
||||
|
||||
export type NpcGraphUiStrings = {
|
||||
zoomBar: string;
|
||||
zoomIn: string;
|
||||
zoomOut: string;
|
||||
fitAll: string;
|
||||
editRelation: string;
|
||||
deleteRelation: string;
|
||||
untitled: string;
|
||||
};
|
||||
|
||||
type NpcNodeData = {
|
||||
name: string;
|
||||
avatarAssetId: ProjectNpc['avatarAssetId'];
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
const NPC_ACCENT = '#60a5fa';
|
||||
const NPC_EDGE_IDLE = '#a1a1aa';
|
||||
/** Согласовано с `.node` в CSS (ширина + типичная высота карточки). */
|
||||
const NPC_NODE_W = 120;
|
||||
const NPC_NODE_H = 150;
|
||||
/** Расстояние между параллельными связями одной пары НПС. */
|
||||
const PARALLEL_EDGE_GAP = 21;
|
||||
|
||||
type Side = 'left' | 'right' | 'top' | 'bottom';
|
||||
|
||||
type NpcEdgeData = {
|
||||
label: string;
|
||||
/** Смещение в мировых координатах (общее для пары, без учёта направления). */
|
||||
offset: number;
|
||||
relationId: NpcRelationId;
|
||||
sourceNpcId: NpcId;
|
||||
targetNpcId: NpcId;
|
||||
highlighted: boolean;
|
||||
};
|
||||
|
||||
/** Любые связи между одной парой НПС (A→B и B→A) — в одной группе разведения. */
|
||||
function undirectedPairKey(a: NpcId, b: NpcId): string {
|
||||
return a < b ? `${a}__${b}` : `${b}__${a}`;
|
||||
}
|
||||
|
||||
function sideToPosition(side: Side): Position {
|
||||
switch (side) {
|
||||
case 'left':
|
||||
return Position.Left;
|
||||
case 'right':
|
||||
return Position.Right;
|
||||
case 'top':
|
||||
return Position.Top;
|
||||
case 'bottom':
|
||||
return Position.Bottom;
|
||||
}
|
||||
}
|
||||
|
||||
/** Выбираем стороны карточек так, чтобы линия выходила наружу и не шла сквозь блок. */
|
||||
function pickEndpointSides(
|
||||
sourcePos: { x: number; y: number },
|
||||
targetPos: { x: number; y: number },
|
||||
): { sourceSide: Side; targetSide: Side } {
|
||||
const sx = sourcePos.x + NPC_NODE_W / 2;
|
||||
const sy = sourcePos.y + NPC_NODE_H / 2;
|
||||
const tx = targetPos.x + NPC_NODE_W / 2;
|
||||
const ty = targetPos.y + NPC_NODE_H / 2;
|
||||
const dx = tx - sx;
|
||||
const dy = ty - sy;
|
||||
if (Math.abs(dx) >= Math.abs(dy)) {
|
||||
return dx >= 0
|
||||
? { sourceSide: 'right', targetSide: 'left' }
|
||||
: { sourceSide: 'left', targetSide: 'right' };
|
||||
}
|
||||
return dy >= 0
|
||||
? { sourceSide: 'bottom', targetSide: 'top' }
|
||||
: { sourceSide: 'top', targetSide: 'bottom' };
|
||||
}
|
||||
|
||||
function NpcNode({ data, selected }: NodeProps<NpcNodeData>) {
|
||||
const url = useAssetUrl(data.avatarAssetId);
|
||||
const sides: Side[] = ['left', 'right', 'top', 'bottom'];
|
||||
return (
|
||||
<div className={[styles.node, data.active || selected ? styles.nodeActive : ''].filter(Boolean).join(' ')}>
|
||||
{sides.map((side) => (
|
||||
<React.Fragment key={side}>
|
||||
<Handle
|
||||
type="source"
|
||||
position={sideToPosition(side)}
|
||||
id={`s-${side}`}
|
||||
className={styles.handle}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={sideToPosition(side)}
|
||||
id={`t-${side}`}
|
||||
className={styles.handle}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div className={styles.avatar}>
|
||||
{url ? <img className={styles.avatarImg} src={url} alt="" draggable={false} /> : null}
|
||||
</div>
|
||||
<div className={styles.name}>{data.name || '—'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Кривая с перпендикуляром в мировых координатах.
|
||||
* Базис берём от «меньшего» id к «большему», чтобы A→B и B→A с разными offset не совпадали.
|
||||
*/
|
||||
function parallelCubicPath(
|
||||
sourceNpcId: NpcId,
|
||||
targetNpcId: NpcId,
|
||||
sourceX: number,
|
||||
sourceY: number,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
worldOffset: number,
|
||||
): { path: string; labelX: number; labelY: number } {
|
||||
// Канонический вектор между концами (не зависит от направления стрелки).
|
||||
const [ax, ay, bx, by] =
|
||||
sourceNpcId < targetNpcId
|
||||
? [sourceX, sourceY, targetX, targetY]
|
||||
: [targetX, targetY, sourceX, sourceY];
|
||||
const cdx = bx - ax;
|
||||
const cdy = by - ay;
|
||||
const clen = Math.sqrt(cdx * cdx + cdy * cdy) || 1;
|
||||
const px = (-cdy / clen) * worldOffset;
|
||||
const py = (cdx / clen) * worldOffset;
|
||||
|
||||
// Сдвигаем всю кривую (включая концы у ручек), чтобы линии не сливались.
|
||||
const sx = sourceX + px;
|
||||
const sy = sourceY + py;
|
||||
const tx = targetX + px;
|
||||
const ty = targetY + py;
|
||||
const dx = tx - sx;
|
||||
const dy = ty - sy;
|
||||
const c1x = sx + dx * 0.35;
|
||||
const c1y = sy + dy * 0.35;
|
||||
const c2x = sx + dx * 0.65;
|
||||
const c2y = sy + dy * 0.65;
|
||||
return {
|
||||
path: `M ${sx},${sy} C ${c1x},${c1y} ${c2x},${c2y} ${tx},${ty}`,
|
||||
labelX: (sx + tx) / 2,
|
||||
labelY: (sy + ty) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function LabeledNpcEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
style,
|
||||
markerEnd,
|
||||
data,
|
||||
}: EdgeProps<NpcEdgeData>) {
|
||||
const openEdgeMenu = useContext(OpenEdgeMenuContext);
|
||||
const selectSourceNpc = useContext(SelectSourceNpcContext);
|
||||
const worldOffset = data?.offset ?? 0;
|
||||
const sourceNpcId = data?.sourceNpcId;
|
||||
const targetNpcId = data?.targetNpcId;
|
||||
const { path, labelX, labelY } =
|
||||
sourceNpcId && targetNpcId
|
||||
? parallelCubicPath(
|
||||
sourceNpcId,
|
||||
targetNpcId,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
worldOffset,
|
||||
)
|
||||
: {
|
||||
path: `M ${sourceX},${sourceY} L ${targetX},${targetY}`,
|
||||
labelX: (sourceX + targetX) / 2,
|
||||
labelY: (sourceY + targetY) / 2,
|
||||
};
|
||||
const label = data?.label ?? '';
|
||||
const relationId = data?.relationId;
|
||||
const highlighted = Boolean(data?.highlighted);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={path}
|
||||
interactionWidth={24}
|
||||
{...(style ? { style } : {})}
|
||||
{...(markerEnd ? { markerEnd } : {})}
|
||||
/>
|
||||
{label && relationId ? (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
className={[
|
||||
styles.edgeLabel,
|
||||
highlighted ? styles.edgeLabelActive : '',
|
||||
'nodrag',
|
||||
'nopan',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: labelX,
|
||||
top: labelY,
|
||||
zIndex: highlighted ? 20 : 5,
|
||||
}}
|
||||
title={label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (sourceNpcId) selectSourceNpc?.(sourceNpcId);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openEdgeMenu?.(relationId, e.clientX, e.clientY);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ZoomToolbar({ ui }: { ui: NpcGraphUiStrings }) {
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||
return (
|
||||
<Panel position="bottom-right">
|
||||
<div className={styles.zoomBar} role="toolbar" aria-label={ui.zoomBar}>
|
||||
<button type="button" className={styles.zoomBtn} onClick={() => zoomIn()} aria-label={ui.zoomIn}>
|
||||
+
|
||||
</button>
|
||||
<button type="button" className={styles.zoomBtn} onClick={() => zoomOut()} aria-label={ui.zoomOut}>
|
||||
−
|
||||
</button>
|
||||
<button type="button" className={styles.zoomBtn} onClick={() => fitView({ padding: 0.2 })} aria-label={ui.fitAll}>
|
||||
⤢
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export type NpcGraphProps = {
|
||||
npcs: ProjectNpc[];
|
||||
relations: ProjectNpcRelation[];
|
||||
selectedNpcId: NpcId | null;
|
||||
graphUi: NpcGraphUiStrings;
|
||||
onSelect: (npcId: NpcId) => void;
|
||||
onConnectRequest: (sourceNpcId: NpcId, targetNpcId: NpcId) => void;
|
||||
onNodePositionCommit: (npcId: NpcId, x: number, y: number) => void;
|
||||
onEditRelation: (relationId: NpcRelationId) => void;
|
||||
onDeleteRelation: (relationId: NpcRelationId) => void;
|
||||
};
|
||||
|
||||
function NpcGraphInner({
|
||||
npcs,
|
||||
relations,
|
||||
selectedNpcId,
|
||||
graphUi,
|
||||
onSelect,
|
||||
onConnectRequest,
|
||||
onNodePositionCommit,
|
||||
onEditRelation,
|
||||
onDeleteRelation,
|
||||
}: NpcGraphProps) {
|
||||
const [menu, setMenu] = useState<{ relationId: NpcRelationId; left: number; top: number } | null>(
|
||||
null,
|
||||
);
|
||||
/** Откуда реально начали тянуть связь (Loose mode может перевернуть source/target). */
|
||||
const connectFromRef = useRef<NpcId | null>(null);
|
||||
|
||||
const edgeTypes = useMemo(() => ({ npcRelation: LabeledNpcEdge }), []);
|
||||
const nodeTypes = useMemo(() => ({ npc: NpcNode }), []);
|
||||
|
||||
const menuPosition = useMemo(() => {
|
||||
if (!menu) return null;
|
||||
const pad = 8;
|
||||
const mw = 180;
|
||||
const mh = 88;
|
||||
return {
|
||||
left: Math.max(pad, Math.min(menu.left, window.innerWidth - mw - pad)),
|
||||
top: Math.max(pad, Math.min(menu.top, window.innerHeight - mh - pad)),
|
||||
};
|
||||
}, [menu]);
|
||||
|
||||
const initialNodes: Node<NpcNodeData>[] = useMemo(
|
||||
() =>
|
||||
npcs.map((n) => ({
|
||||
id: n.id,
|
||||
type: 'npc',
|
||||
position: { x: n.x, y: n.y },
|
||||
data: {
|
||||
name: n.name,
|
||||
avatarAssetId: n.avatarAssetId,
|
||||
active: n.id === selectedNpcId,
|
||||
},
|
||||
})),
|
||||
[npcs, selectedNpcId],
|
||||
);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||
|
||||
const builtEdges: Edge<NpcEdgeData>[] = useMemo(() => {
|
||||
const posById = new Map<string, { x: number; y: number }>();
|
||||
for (const n of nodes) posById.set(n.id, n.position);
|
||||
// Пока nodes ещё пуст/не синхронизирован — берём координаты из проекта.
|
||||
for (const n of npcs) {
|
||||
if (!posById.has(n.id)) posById.set(n.id, { x: n.x, y: n.y });
|
||||
}
|
||||
|
||||
// Группируем ВСЕ связи между парой НПС (оба направления), чтобы не накладывались.
|
||||
const groups = new Map<string, ProjectNpcRelation[]>();
|
||||
for (const r of relations) {
|
||||
const key = undirectedPairKey(r.sourceNpcId, r.targetNpcId);
|
||||
const list = groups.get(key) ?? [];
|
||||
list.push(r);
|
||||
groups.set(key, list);
|
||||
}
|
||||
const out: Edge<NpcEdgeData>[] = [];
|
||||
for (const group of groups.values()) {
|
||||
const sorted = [...group].sort((a, b) => a.id.localeCompare(b.id));
|
||||
const total = sorted.length;
|
||||
sorted.forEach((r, index) => {
|
||||
const sourcePos = posById.get(r.sourceNpcId) ?? { x: 0, y: 0 };
|
||||
const targetPos = posById.get(r.targetNpcId) ?? { x: 0, y: 0 };
|
||||
const { sourceSide, targetSide } = pickEndpointSides(sourcePos, targetPos);
|
||||
const offset = (index - (total - 1) / 2) * PARALLEL_EDGE_GAP;
|
||||
const highlighted = selectedNpcId !== null && r.sourceNpcId === selectedNpcId;
|
||||
const color = highlighted ? NPC_ACCENT : NPC_EDGE_IDLE;
|
||||
out.push({
|
||||
id: r.id,
|
||||
source: r.sourceNpcId,
|
||||
target: r.targetNpcId,
|
||||
sourceHandle: `s-${sourceSide}`,
|
||||
targetHandle: `t-${targetSide}`,
|
||||
type: 'npcRelation',
|
||||
zIndex: highlighted ? 10 : 0,
|
||||
data: {
|
||||
label: r.label,
|
||||
offset,
|
||||
relationId: r.id,
|
||||
sourceNpcId: r.sourceNpcId,
|
||||
targetNpcId: r.targetNpcId,
|
||||
highlighted,
|
||||
},
|
||||
style: { stroke: color, strokeWidth: highlighted ? 2.5 : 2 },
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 16,
|
||||
height: 16,
|
||||
color,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [nodes, npcs, relations, selectedNpcId]);
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(initialNodes);
|
||||
}, [initialNodes, setNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
setEdges(builtEdges);
|
||||
}, [builtEdges, setEdges]);
|
||||
|
||||
const onConnectStart = useCallback((_event: unknown, params: OnConnectStartParams) => {
|
||||
connectFromRef.current = params.nodeId ? (params.nodeId as NpcId) : null;
|
||||
}, []);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(conn: Connection) => {
|
||||
if (!conn.source || !conn.target || conn.source === conn.target) return;
|
||||
const from = connectFromRef.current;
|
||||
if (from && (from === conn.source || from === conn.target)) {
|
||||
const to = (from === conn.source ? conn.target : conn.source) as NpcId;
|
||||
onConnectRequest(from, to);
|
||||
return;
|
||||
}
|
||||
onConnectRequest(conn.source as NpcId, conn.target as NpcId);
|
||||
},
|
||||
[onConnectRequest],
|
||||
);
|
||||
|
||||
const openEdgeMenu = useCallback((relationId: NpcRelationId, x: number, y: number) => {
|
||||
setMenu({ relationId, left: x, top: y });
|
||||
}, []);
|
||||
|
||||
const selectSourceNpc = useCallback(
|
||||
(sourceNpcId: NpcId) => {
|
||||
setMenu(null);
|
||||
onSelect(sourceNpcId);
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<OpenEdgeMenuContext.Provider value={openEdgeMenu}>
|
||||
<SelectSourceNpcContext.Provider value={selectSourceNpc}>
|
||||
<div className={styles.wrap}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnectStart={onConnectStart}
|
||||
onConnect={onConnect}
|
||||
onConnectEnd={() => {
|
||||
connectFromRef.current = null;
|
||||
}}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
fitView
|
||||
proOptions={{ hideAttribution: true }}
|
||||
onNodeClick={(_e, node) => {
|
||||
setMenu(null);
|
||||
onSelect(node.id as NpcId);
|
||||
}}
|
||||
onNodeDragStop={(_e, node) => {
|
||||
onNodePositionCommit(node.id as NpcId, node.position.x, node.position.y);
|
||||
}}
|
||||
onEdgeClick={(e, edge) => {
|
||||
e.stopPropagation();
|
||||
setMenu(null);
|
||||
const sourceId =
|
||||
(edge.data as NpcEdgeData | undefined)?.sourceNpcId ?? (edge.source as NpcId);
|
||||
onSelect(sourceId);
|
||||
}}
|
||||
onEdgeContextMenu={(e, edge) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const relationId =
|
||||
(edge.data as NpcEdgeData | undefined)?.relationId ?? (edge.id as NpcRelationId);
|
||||
openEdgeMenu(relationId, e.clientX, e.clientY);
|
||||
}}
|
||||
onPaneClick={() => setMenu(null)}
|
||||
onPaneContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
<Background gap={18} size={1} color="#27272a" />
|
||||
<ZoomToolbar ui={graphUi} />
|
||||
</ReactFlow>
|
||||
{menu && menuPosition
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="close"
|
||||
className={styles.menuBackdrop}
|
||||
onClick={() => setMenu(null)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setMenu(null);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={styles.menu}
|
||||
style={{ left: menuPosition.left, top: menuPosition.top }}
|
||||
data-npc-edge-menu="1"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuItem}
|
||||
onClick={() => {
|
||||
onEditRelation(menu.relationId);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
{graphUi.editRelation}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={[styles.menuItem, styles.menuItemDanger].join(' ')}
|
||||
onClick={() => {
|
||||
onDeleteRelation(menu.relationId);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
{graphUi.deleteRelation}
|
||||
</button>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</SelectSourceNpcContext.Provider>
|
||||
</OpenEdgeMenuContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function NpcGraph(props: NpcGraphProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<NpcGraphInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
|
||||
type NpcRelationModalProps = {
|
||||
open: boolean;
|
||||
initialLabel?: string;
|
||||
onClose: () => void;
|
||||
onSave: (label: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function NpcRelationModal({ open, initialLabel = '', onClose, onSave }: NpcRelationModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const [label, setLabel] = useState(initialLabel);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLabel(initialLabel);
|
||||
setSaving(false);
|
||||
setError(null);
|
||||
}, [initialLabel, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open]);
|
||||
|
||||
const trimmed = label.trim();
|
||||
const canSave = trimmed.length >= 1 && !saving;
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={editorStyles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
|
||||
<div className={editorStyles.modalHeader}>
|
||||
<div className={editorStyles.modalTitle}>
|
||||
{initialLabel ? t('npcs.relationEditTitle') : t('npcs.relationCreateTitle')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={editorStyles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={editorStyles.fieldGrid}>
|
||||
<div className={editorStyles.fieldLabel}>{t('npcs.relationLabel')}</div>
|
||||
<Input
|
||||
value={label}
|
||||
onChange={setLabel}
|
||||
placeholder={t('npcs.relationLabelPlaceholder')}
|
||||
/>
|
||||
{trimmed.length < 1 ? (
|
||||
<div className={editorStyles.fieldError}>{t('npcs.relationLabelRequired')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
|
||||
|
||||
<div className={editorStyles.modalFooter}>
|
||||
<Button onClick={onClose} disabled={saving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canSave}
|
||||
onClick={() => {
|
||||
if (!canSave) return;
|
||||
void (async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave(trimmed);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
.page {
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
background: #09090b;
|
||||
color: var(--text1);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--stroke);
|
||||
background: #18181b;
|
||||
}
|
||||
|
||||
.toolbarRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbarHint {
|
||||
color: var(--text2);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 280px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.detail {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 14px 16px;
|
||||
border-right: 1px solid var(--stroke);
|
||||
background: #0f0f12;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.detailEmpty {
|
||||
color: var(--text2);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.detailName {
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.detailSectionTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.detailDesc {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
.detailDesc :is(h2, h3) {
|
||||
margin: 0.6em 0 0.35em;
|
||||
}
|
||||
|
||||
.detailDesc :is(p, ul, ol) {
|
||||
margin: 0.35em 0;
|
||||
}
|
||||
|
||||
.relationsList {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.relationItem {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--stroke);
|
||||
background: #18181b;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.listCol {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: #0f0f12;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--stroke);
|
||||
background: #18181b;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tileSelected {
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 0 0 1px #60a5fa;
|
||||
}
|
||||
|
||||
.tileActive {
|
||||
outline: 1px solid #fbbf24;
|
||||
}
|
||||
|
||||
.tileAvatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #09090b;
|
||||
}
|
||||
|
||||
.tileAvatarImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tileName {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text2);
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||
import type { NpcId, ProjectNpc } from '../../shared/types';
|
||||
import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import styles from './NpcsApp.module.css';
|
||||
|
||||
function ZoomInIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
|
||||
<circle cx="10.5" cy="10.5" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M15.2 15.2 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path
|
||||
d="M10.5 7.8v5.4M7.8 10.5h5.4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ZoomOutIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
|
||||
<circle cx="10.5" cy="10.5" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M15.2 15.2 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path d="M7.8 10.5h5.4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeNpcTile({
|
||||
npc,
|
||||
selected,
|
||||
active,
|
||||
onActivate,
|
||||
}: {
|
||||
npc: ProjectNpc;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onActivate: () => void;
|
||||
}) {
|
||||
const url = useAssetUrl(npc.avatarAssetId);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.tile,
|
||||
selected ? styles.tileSelected : '',
|
||||
active ? styles.tileActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={onActivate}
|
||||
>
|
||||
<div className={styles.tileAvatar}>
|
||||
{url ? <img className={styles.tileAvatarImg} src={url} alt="" draggable={false} /> : null}
|
||||
</div>
|
||||
<div className={styles.tileName}>{npc.name}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function NpcsApp() {
|
||||
const { t } = useEditorI18n();
|
||||
const api = getDndApi();
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [overlay, overlayApi] = useNpcsOverlayState();
|
||||
const [selectedId, setSelectedId] = useState<NpcId | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
|
||||
setSession({ project, currentSceneId: project?.currentSceneId ?? null });
|
||||
const list = project?.npcs ?? [];
|
||||
setSelectedId(list[0]?.id ?? null);
|
||||
});
|
||||
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
|
||||
setSession(state);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (overlay?.activeNpcId) {
|
||||
void overlayApi.dispatch({ kind: 'hide' });
|
||||
return;
|
||||
}
|
||||
if (overlay?.zoomTool) {
|
||||
void overlayApi.dispatch({ kind: 'zoomTool.set', tool: null });
|
||||
return;
|
||||
}
|
||||
window.close();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [overlay?.activeNpcId, overlay?.zoomTool, overlayApi]);
|
||||
|
||||
const npcs = session?.project?.npcs ?? [];
|
||||
const relations = session?.project?.npcRelations ?? [];
|
||||
const activeId = overlay?.activeNpcId ?? null;
|
||||
const zoomTool = overlay?.zoomTool ?? null;
|
||||
const selected = npcs.find((n) => n.id === selectedId) ?? null;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return npcs;
|
||||
return npcs.filter((n) => n.name.toLowerCase().includes(q));
|
||||
}, [npcs, query]);
|
||||
|
||||
const relationsForSelected = useMemo(() => {
|
||||
if (!selected) return [];
|
||||
return relations
|
||||
.filter((r) => r.sourceNpcId === selected.id)
|
||||
.map((r) => {
|
||||
const other = npcs.find((n) => n.id === r.targetNpcId);
|
||||
return { id: r.id, text: `${r.label} ${other?.name ?? '—'}` };
|
||||
});
|
||||
}, [npcs, relations, selected]);
|
||||
|
||||
const onSelectTile = useCallback(
|
||||
(id: NpcId) => {
|
||||
setSelectedId(id);
|
||||
void overlayApi.dispatch({ kind: 'toggle', npcId: id });
|
||||
},
|
||||
[overlayApi],
|
||||
);
|
||||
|
||||
const safeHtml = selected ? sanitizeSceneDescriptionHtml(selected.description) : '';
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.toolbarRow}>
|
||||
<Button
|
||||
variant={zoomTool === 'zoomIn' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title={t('npcs.zoomIn')}
|
||||
ariaLabel={t('npcs.zoomIn')}
|
||||
tooltipPlacement="bottom"
|
||||
onClick={() => {
|
||||
void overlayApi.dispatch({
|
||||
kind: 'zoomTool.set',
|
||||
tool: zoomTool === 'zoomIn' ? null : 'zoomIn',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ZoomInIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant={zoomTool === 'zoomOut' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title={t('npcs.zoomOut')}
|
||||
ariaLabel={t('npcs.zoomOut')}
|
||||
tooltipPlacement="bottom"
|
||||
onClick={() => {
|
||||
void overlayApi.dispatch({
|
||||
kind: 'zoomTool.set',
|
||||
tool: zoomTool === 'zoomOut' ? null : 'zoomOut',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ZoomOutIcon />
|
||||
</Button>
|
||||
{activeId ? (
|
||||
<Button
|
||||
title={t('npcs.closeOverlay')}
|
||||
ariaLabel={t('npcs.closeOverlay')}
|
||||
tooltipPlacement="bottom"
|
||||
onClick={() => {
|
||||
void overlayApi.dispatch({ kind: 'hide' });
|
||||
}}
|
||||
>
|
||||
{t('npcs.closeOverlay')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.toolbarHint}>
|
||||
{zoomTool === 'zoomIn'
|
||||
? t('npcs.zoomInHint')
|
||||
: zoomTool === 'zoomOut'
|
||||
? t('npcs.zoomOutHint')
|
||||
: t('npcs.zoomIdleHint')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div className={styles.detail}>
|
||||
{selected ? (
|
||||
<>
|
||||
<div className={styles.detailName}>{selected.name}</div>
|
||||
{safeHtml ? (
|
||||
<div>
|
||||
<div className={styles.detailSectionTitle}>{t('npcs.description')}</div>
|
||||
<div
|
||||
className={styles.detailDesc}
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.muted}>{t('npcs.descriptionEmpty')}</div>
|
||||
)}
|
||||
{relationsForSelected.length > 0 ? (
|
||||
<div>
|
||||
<div className={styles.detailSectionTitle}>{t('npcs.relations')}</div>
|
||||
<div className={styles.relationsList}>
|
||||
{relationsForSelected.map((r) => (
|
||||
<div key={r.id} className={styles.relationItem}>
|
||||
{r.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.detailEmpty}>{t('npcs.windowEmpty')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.listCol}>
|
||||
<Input value={query} onChange={setQuery} placeholder={t('npcs.search')} />
|
||||
<div className={styles.list}>
|
||||
{filtered.map((n) => (
|
||||
<RuntimeNpcTile
|
||||
key={n.id}
|
||||
npc={n}
|
||||
selected={n.id === selectedId}
|
||||
active={n.id === activeId}
|
||||
onActivate={() => onSelectTile(n.id)}
|
||||
/>
|
||||
))}
|
||||
{npcs.length === 0 ? <div className={styles.muted}>{t('npcs.windowEmpty')}</div> : null}
|
||||
{npcs.length > 0 && filtered.length === 0 ? (
|
||||
<div className={styles.muted}>{t('npcs.searchEmpty')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
.page {
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: 56px 1fr;
|
||||
background: #09090b;
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
.topBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--stroke);
|
||||
background: #18181b;
|
||||
}
|
||||
|
||||
.topTitle {
|
||||
font-weight: 900;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr 480px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.col {
|
||||
min-height: 0;
|
||||
border-right: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
.col:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.side {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
min-height: 0;
|
||||
background: #0f0f12;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--stroke);
|
||||
background: #18181b;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tileBody {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tileSelected {
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 0 0 1px #60a5fa;
|
||||
}
|
||||
|
||||
.tileDragging {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.tileDropBefore {
|
||||
box-shadow: inset 0 2px 0 #60a5fa;
|
||||
}
|
||||
|
||||
.tileDropAfter {
|
||||
box-shadow: inset 0 -2px 0 #60a5fa;
|
||||
}
|
||||
|
||||
.tileAvatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #09090b;
|
||||
}
|
||||
|
||||
.tileAvatarImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tileName {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tileMenuBtn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tileMenuBtn:hover {
|
||||
background: #27272a;
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
.inspector {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
min-height: 0;
|
||||
background: #0f0f12;
|
||||
}
|
||||
|
||||
.inspectorScroll {
|
||||
overflow: auto;
|
||||
padding: 14px 16px 24px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
color: var(--text2);
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.avatarPick {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.avatarPreview {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--stroke);
|
||||
background: #09090b;
|
||||
}
|
||||
|
||||
.avatarPreviewImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.descShell {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-height: 220px;
|
||||
border-radius: var(--radius-md, 10px);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-3, #0c0c0f);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.descContent {
|
||||
min-height: 160px;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.relationsTitle {
|
||||
font-weight: 900;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.relationsList {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.relationItem {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--stroke);
|
||||
background: #18181b;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.emptyInspector {
|
||||
color: var(--text2);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 80;
|
||||
min-width: 160px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
background: #18181b;
|
||||
border: 1px solid var(--stroke);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text1);
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.menuItem:hover {
|
||||
background: #27272a;
|
||||
}
|
||||
|
||||
.menuItemDanger {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-danger);
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.menuItemDanger:hover {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||
import type { NpcId, NpcRelationId, ProjectNpc, ProjectNpcRelation } from '../../shared/types';
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
import controlStyles from '../shared/ui/Controls.module.css';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import { NpcDescriptionField } from './NpcDescriptionField';
|
||||
import { NpcEditModal } from './NpcEditModal';
|
||||
import { NpcGraph } from './NpcGraph';
|
||||
import { NpcRelationModal } from './NpcRelationModal';
|
||||
import styles from './NpcsEditorApp.module.css';
|
||||
|
||||
const DND_NPC_ID_MIME = 'application/x-dnd-npc-id';
|
||||
|
||||
function NpcTile({
|
||||
npc,
|
||||
selected,
|
||||
dragging,
|
||||
dropPlace,
|
||||
onSelect,
|
||||
onMenu,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDropReorder,
|
||||
}: {
|
||||
npc: ProjectNpc;
|
||||
selected: boolean;
|
||||
dragging: boolean;
|
||||
dropPlace: 'before' | 'after' | null;
|
||||
onSelect: () => void;
|
||||
onMenu: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onDragStart: () => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (place: 'before' | 'after') => void;
|
||||
onDropReorder: () => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
const url = useAssetUrl(npc.avatarAssetId);
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
styles.tile,
|
||||
selected ? styles.tileSelected : '',
|
||||
dragging ? styles.tileDragging : '',
|
||||
dropPlace === 'before' ? styles.tileDropBefore : '',
|
||||
dropPlace === 'after' ? styles.tileDropAfter : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData(DND_NPC_ID_MIME, npc.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
onDragStart();
|
||||
}}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const mid = rect.top + rect.height / 2;
|
||||
onDragOver(e.clientY < mid ? 'before' : 'after');
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
onDropReorder();
|
||||
}}
|
||||
>
|
||||
<button type="button" className={styles.tileBody} onClick={onSelect}>
|
||||
<div className={styles.tileAvatar}>
|
||||
{url ? <img className={styles.tileAvatarImg} src={url} alt="" draggable={false} /> : null}
|
||||
</div>
|
||||
<div className={styles.tileName}>{npc.name}</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tileMenuBtn}
|
||||
data-npc-menu-root="1"
|
||||
aria-label={t('npcs.tileMenu')}
|
||||
onClick={onMenu}
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NpcsEditorApp() {
|
||||
const { t } = useEditorI18n();
|
||||
const api = getDndApi();
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<NpcId | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editInitial, setEditInitial] = useState<ProjectNpc | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<ProjectNpc | null>(null);
|
||||
const [menuFor, setMenuFor] = useState<NpcId | null>(null);
|
||||
const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const [dragId, setDragId] = useState<NpcId | null>(null);
|
||||
const [dropPlace, setDropPlace] = useState<{ id: NpcId; place: 'before' | 'after' } | null>(null);
|
||||
const [nameDraft, setNameDraft] = useState('');
|
||||
const [relationModal, setRelationModal] = useState<
|
||||
| { mode: 'create'; sourceNpcId: NpcId; targetNpcId: NpcId }
|
||||
| { mode: 'edit'; relationId: NpcRelationId; label: string }
|
||||
| null
|
||||
>(null);
|
||||
const [pendingDeleteRelation, setPendingDeleteRelation] = useState<ProjectNpcRelation | null>(null);
|
||||
const [avatarBusy, setAvatarBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
|
||||
setSession({ project, currentSceneId: project?.currentSceneId ?? null });
|
||||
const list = project?.npcs ?? [];
|
||||
setSelectedId(list[0]?.id ?? null);
|
||||
});
|
||||
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
|
||||
setSession(state);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const npcs = session?.project?.npcs ?? [];
|
||||
const relations = session?.project?.npcRelations ?? [];
|
||||
const selected = npcs.find((n) => n.id === selectedId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setNameDraft(selected?.name ?? '');
|
||||
}, [selected?.id, selected?.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId && npcs.some((n) => n.id === selectedId)) return;
|
||||
setSelectedId(npcs[0]?.id ?? null);
|
||||
}, [npcs, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuFor) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const tgt = e.target as HTMLElement | null;
|
||||
if (tgt?.closest('[data-npc-menu-root="1"]')) return;
|
||||
setMenuFor(null);
|
||||
setMenuPos(null);
|
||||
};
|
||||
window.addEventListener('mousedown', onDown);
|
||||
return () => window.removeEventListener('mousedown', onDown);
|
||||
}, [menuFor]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return npcs;
|
||||
return npcs.filter((n) => n.name.toLowerCase().includes(q));
|
||||
}, [npcs, query]);
|
||||
|
||||
const selectedUrl = useAssetUrl(selected?.avatarAssetId ?? null);
|
||||
|
||||
const relationsForSelected = useMemo(() => {
|
||||
if (!selected) return [];
|
||||
return relations
|
||||
.filter((r) => r.sourceNpcId === selected.id)
|
||||
.map((r) => {
|
||||
const other = npcs.find((n) => n.id === r.targetNpcId);
|
||||
return { relation: r, otherName: other?.name ?? '—' };
|
||||
});
|
||||
}, [npcs, relations, selected]);
|
||||
|
||||
const pickAvatar = useCallback(async () => {
|
||||
const res = await api.invoke(ipcChannels.project.pickNpcAvatar, {});
|
||||
if (res.canceled) return null;
|
||||
return { filePath: res.filePath, previewDataUrl: res.previewDataUrl };
|
||||
}, [api]);
|
||||
|
||||
const graphUi = useMemo(
|
||||
() => ({
|
||||
zoomBar: t('npcs.graphZoomBar'),
|
||||
zoomIn: t('npcs.graphZoomIn'),
|
||||
zoomOut: t('npcs.graphZoomOut'),
|
||||
fitAll: t('npcs.graphFitAll'),
|
||||
editRelation: t('npcs.relationEdit'),
|
||||
deleteRelation: t('npcs.relationDelete'),
|
||||
untitled: t('npcs.untitled'),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.topBar}>
|
||||
<div className={styles.topTitle}>{t('npcs.editorTitle')}</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
void api.invoke(ipcChannels.windows.closeNpcsEditor, {});
|
||||
}}
|
||||
>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div className={[styles.col, styles.side].join(' ')}>
|
||||
<Input value={query} onChange={setQuery} placeholder={t('npcs.search')} />
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setEditInitial(null);
|
||||
setEditOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('npcs.add')}
|
||||
</Button>
|
||||
<div className={styles.list}>
|
||||
{filtered.map((n) => (
|
||||
<NpcTile
|
||||
key={n.id}
|
||||
npc={n}
|
||||
selected={n.id === selectedId}
|
||||
dragging={dragId === n.id}
|
||||
dropPlace={dropPlace?.id === n.id ? dropPlace.place : null}
|
||||
onSelect={() => setSelectedId(n.id)}
|
||||
onMenu={(e) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
const menuW = 180;
|
||||
const menuH = 88;
|
||||
const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 8));
|
||||
const top =
|
||||
r.bottom + 8 + menuH > window.innerHeight - 8
|
||||
? Math.max(8, r.top - menuH - 8)
|
||||
: r.bottom + 8;
|
||||
setMenuPos({ left, top });
|
||||
setMenuFor((cur) => (cur === n.id ? null : n.id));
|
||||
}}
|
||||
onDragStart={() => setDragId(n.id)}
|
||||
onDragEnd={() => {
|
||||
setDragId(null);
|
||||
setDropPlace(null);
|
||||
}}
|
||||
onDragOver={(place) => {
|
||||
if (!dragId || dragId === n.id) {
|
||||
setDropPlace(null);
|
||||
return;
|
||||
}
|
||||
setDropPlace({ id: n.id, place });
|
||||
}}
|
||||
onDropReorder={() => {
|
||||
if (!dragId || !dropPlace || dragId === dropPlace.id) return;
|
||||
const ids = npcs.map((x) => x.id);
|
||||
const from = ids.indexOf(dragId);
|
||||
if (from < 0) return;
|
||||
ids.splice(from, 1);
|
||||
let to = ids.indexOf(dropPlace.id);
|
||||
if (to < 0) return;
|
||||
if (dropPlace.place === 'after') to += 1;
|
||||
ids.splice(to, 0, dragId);
|
||||
setDragId(null);
|
||||
setDropPlace(null);
|
||||
void api.invoke(ipcChannels.project.setNpcsOrder, { npcIds: ids });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{npcs.length === 0 ? <div className={styles.muted}>{t('npcs.empty')}</div> : null}
|
||||
{npcs.length > 0 && filtered.length === 0 ? (
|
||||
<div className={styles.muted}>{t('npcs.searchEmpty')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.col}>
|
||||
<NpcGraph
|
||||
npcs={npcs}
|
||||
relations={relations}
|
||||
selectedNpcId={selectedId}
|
||||
graphUi={graphUi}
|
||||
onSelect={setSelectedId}
|
||||
onConnectRequest={(sourceNpcId, targetNpcId) => {
|
||||
setRelationModal({ mode: 'create', sourceNpcId, targetNpcId });
|
||||
}}
|
||||
onNodePositionCommit={(npcId, x, y) => {
|
||||
void api.invoke(ipcChannels.project.updateNpcPosition, { npcId, x, y });
|
||||
}}
|
||||
onEditRelation={(relationId) => {
|
||||
const rel = relations.find((r) => r.id === relationId);
|
||||
if (!rel) return;
|
||||
setRelationModal({ mode: 'edit', relationId, label: rel.label });
|
||||
}}
|
||||
onDeleteRelation={(relationId) => {
|
||||
const rel = relations.find((r) => r.id === relationId);
|
||||
if (!rel) return;
|
||||
setPendingDeleteRelation(rel);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={[styles.col, styles.inspector].join(' ')}>
|
||||
<div className={styles.inspectorScroll}>
|
||||
{selected ? (
|
||||
<>
|
||||
<div>
|
||||
<div className={styles.fieldLabel}>{t('npcs.avatar')}</div>
|
||||
<div className={styles.avatarPick}>
|
||||
<div className={styles.avatarPreview}>
|
||||
{selectedUrl ? (
|
||||
<img className={styles.avatarPreviewImg} src={selectedUrl} alt="" />
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
disabled={avatarBusy}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setAvatarBusy(true);
|
||||
try {
|
||||
const picked = await pickAvatar();
|
||||
if (!picked) return;
|
||||
await api.invoke(ipcChannels.project.upsertNpc, {
|
||||
npcId: selected.id,
|
||||
name: selected.name,
|
||||
filePath: picked.filePath,
|
||||
});
|
||||
} finally {
|
||||
setAvatarBusy(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('npcs.chooseAvatar')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={styles.fieldLabel}>{t('npcs.name')}</div>
|
||||
<input
|
||||
className={controlStyles.input}
|
||||
value={nameDraft}
|
||||
onChange={(e) => setNameDraft(e.target.value)}
|
||||
onBlur={() => {
|
||||
const next = nameDraft.trim();
|
||||
if (!next || next === selected.name) {
|
||||
setNameDraft(selected.name);
|
||||
return;
|
||||
}
|
||||
void api
|
||||
.invoke(ipcChannels.project.updateNpcFields, {
|
||||
npcId: selected.id,
|
||||
name: next,
|
||||
})
|
||||
.catch(() => setNameDraft(selected.name));
|
||||
}}
|
||||
placeholder={t('npcs.namePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={styles.fieldLabel}>{t('npcs.description')}</div>
|
||||
<NpcDescriptionField
|
||||
html={selected.description}
|
||||
onCommit={(html) => {
|
||||
if (html === selected.description) return;
|
||||
void api.invoke(ipcChannels.project.updateNpcFields, {
|
||||
npcId: selected.id,
|
||||
description: html,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{relationsForSelected.length > 0 ? (
|
||||
<div>
|
||||
<div className={styles.relationsTitle}>{t('npcs.relations')}</div>
|
||||
<div className={styles.relationsList}>
|
||||
{relationsForSelected.map(({ relation, otherName }) => (
|
||||
<div key={relation.id} className={styles.relationItem}>
|
||||
{relation.label} {otherName}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.emptyInspector}>{t('npcs.selectPrompt')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NpcEditModal
|
||||
open={editOpen}
|
||||
initial={editInitial}
|
||||
existingNames={npcs.map((n) => n.name)}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onPickImage={pickAvatar}
|
||||
onSave={async (input) => {
|
||||
const res = await api.invoke(ipcChannels.project.upsertNpc, {
|
||||
...(editInitial ? { npcId: editInitial.id } : {}),
|
||||
name: input.name,
|
||||
...(input.filePath ? { filePath: input.filePath } : {}),
|
||||
});
|
||||
const created = res.project.npcs.find((n) => n.name === input.name.trim());
|
||||
if (created) setSelectedId(created.id);
|
||||
}}
|
||||
/>
|
||||
|
||||
<NpcRelationModal
|
||||
open={Boolean(relationModal)}
|
||||
initialLabel={relationModal?.mode === 'edit' ? relationModal.label : ''}
|
||||
onClose={() => setRelationModal(null)}
|
||||
onSave={async (label) => {
|
||||
if (!relationModal) return;
|
||||
if (relationModal.mode === 'create') {
|
||||
await api.invoke(ipcChannels.project.upsertNpcRelation, {
|
||||
sourceNpcId: relationModal.sourceNpcId,
|
||||
targetNpcId: relationModal.targetNpcId,
|
||||
label,
|
||||
});
|
||||
} else {
|
||||
const rel = relations.find((r) => r.id === relationModal.relationId);
|
||||
if (!rel) return;
|
||||
await api.invoke(ipcChannels.project.upsertNpcRelation, {
|
||||
relationId: rel.id,
|
||||
sourceNpcId: rel.sourceNpcId,
|
||||
targetNpcId: rel.targetNpcId,
|
||||
label,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{menuFor && menuPos
|
||||
? createPortal(
|
||||
<div
|
||||
className={styles.menu}
|
||||
style={{ left: menuPos.left, top: menuPos.top }}
|
||||
data-npc-menu-root="1"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuItemDanger}
|
||||
onClick={() => {
|
||||
const npc = npcs.find((n) => n.id === menuFor);
|
||||
if (npc) setPendingDelete(npc);
|
||||
setMenuFor(null);
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
|
||||
{pendingDelete
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={editorStyles.modalBackdrop}
|
||||
onClick={() => setPendingDelete(null)}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
|
||||
<div className={editorStyles.modalHeader}>
|
||||
<div className={editorStyles.modalTitle}>{t('npcs.deleteTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
className={editorStyles.modalClose}
|
||||
onClick={() => setPendingDelete(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div>{t('npcs.deleteConfirm', { name: pendingDelete.name })}</div>
|
||||
<div className={editorStyles.modalFooter}>
|
||||
<Button onClick={() => setPendingDelete(null)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const id = pendingDelete.id;
|
||||
setPendingDelete(null);
|
||||
void api.invoke(ipcChannels.project.deleteNpc, { npcId: id });
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
|
||||
{pendingDeleteRelation
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={editorStyles.modalBackdrop}
|
||||
onClick={() => setPendingDeleteRelation(null)}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
|
||||
<div className={editorStyles.modalHeader}>
|
||||
<div className={editorStyles.modalTitle}>{t('npcs.relationDeleteTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
className={editorStyles.modalClose}
|
||||
onClick={() => setPendingDeleteRelation(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
{t('npcs.relationDeleteConfirm', { name: pendingDeleteRelation.label })}
|
||||
</div>
|
||||
<div className={editorStyles.modalFooter}>
|
||||
<Button onClick={() => setPendingDeleteRelation(null)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const id = pendingDeleteRelation.id;
|
||||
setPendingDeleteRelation(null);
|
||||
void api.invoke(ipcChannels.project.deleteNpcRelation, { relationId: id });
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||
|
||||
import { NpcsEditorApp } from './NpcsEditorApp';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) {
|
||||
throw new Error('Missing #root element');
|
||||
}
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<EditorI18nProvider>
|
||||
<NpcsEditorApp />
|
||||
</EditorI18nProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||
|
||||
import { NpcsApp } from './NpcsApp';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) {
|
||||
throw new Error('Missing #root element');
|
||||
}
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<EditorI18nProvider>
|
||||
<NpcsApp />
|
||||
</EditorI18nProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||
<title>TTRPG</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/npcs/npcsEditorMain.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,9 +7,10 @@ import { PixiEffectsOverlay } from './effects/PxiEffectsOverlay';
|
||||
import { SceneDarknessOverlay } from './effects/SceneDarknessOverlay';
|
||||
import { useEffectsState } from './effects/useEffectsState';
|
||||
import { useSceneDarknessState } from './effects/useSceneDarknessState';
|
||||
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../shared/types';
|
||||
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
|
||||
import { MaterialOverlay } from './materials/MaterialOverlay';
|
||||
import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
|
||||
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
|
||||
import styles from './PresentationView.module.css';
|
||||
import { RotatedImage } from './RotatedImage';
|
||||
import { useAssetUrl } from './useAssetImageUrl';
|
||||
@@ -32,6 +33,7 @@ export function PresentationView({
|
||||
const [fxState] = useEffectsState();
|
||||
const [sdState] = useSceneDarknessState();
|
||||
const [materialsOverlay] = useMaterialsOverlayState();
|
||||
const [npcsOverlay] = useNpcsOverlayState();
|
||||
const [vp] = useVideoPlaybackState();
|
||||
const videoElRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(
|
||||
@@ -43,6 +45,10 @@ export function PresentationView({
|
||||
session?.project && materialsOverlay?.activeMaterialId
|
||||
? (session.project.materials ?? []).find((m) => m.id === materialsOverlay.activeMaterialId)
|
||||
: undefined;
|
||||
const activeNpc =
|
||||
session?.project && npcsOverlay?.activeNpcId
|
||||
? (session.project.npcs ?? []).find((n) => n.id === npcsOverlay.activeNpcId)
|
||||
: undefined;
|
||||
const originalUrl = useAssetUrl(scene?.previewAssetId ?? null);
|
||||
const thumbUrl = useAssetUrl(scene?.previewThumbAssetId ?? null);
|
||||
const [shownImageUrl, setShownImageUrl] = useState<string | null>(null);
|
||||
@@ -153,6 +159,12 @@ export function PresentationView({
|
||||
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
|
||||
/>
|
||||
) : null}
|
||||
{activeNpc ? (
|
||||
<MaterialOverlay
|
||||
assetId={activeNpc.avatarAssetId}
|
||||
layout={npcsOverlay?.layout ?? DEFAULT_NPCS_OVERLAY_LAYOUT}
|
||||
/>
|
||||
) : null}
|
||||
{showTitle ? (
|
||||
<div className={styles.titleWrap}>
|
||||
<div className={compact ? styles.titleCompact : styles.titleFull}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { AssetId, MaterialsOverlayLayout, MaterialsZoomTool } from '../../../shared/types';
|
||||
import type { AssetId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
|
||||
import { useAssetUrl } from '../useAssetImageUrl';
|
||||
|
||||
import styles from './MaterialOverlay.module.css';
|
||||
@@ -12,7 +12,7 @@ type MaterialOverlayProps = {
|
||||
rotationDeg?: 0 | 90 | 180 | 270;
|
||||
layout: MaterialsOverlayLayout;
|
||||
editable?: boolean;
|
||||
zoomTool?: MaterialsZoomTool;
|
||||
zoomTool?: MaterialsZoomTool | NpcsZoomTool;
|
||||
showClose?: boolean;
|
||||
onClose?: () => void;
|
||||
closeLabel?: string;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type { NpcsOverlayEvent, NpcsOverlayState } from '../../../shared/types';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
export function useNpcsOverlayState(): readonly [
|
||||
NpcsOverlayState | null,
|
||||
{ dispatch: (event: NpcsOverlayEvent) => Promise<void> },
|
||||
] {
|
||||
const api = getDndApi();
|
||||
const [state, setState] = useState<NpcsOverlayState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.npcsOverlay.getState, {}).then((r) => {
|
||||
setState(r.state);
|
||||
});
|
||||
return api.on(ipcChannels.npcsOverlay.stateChanged, ({ state: next }) => {
|
||||
setState(next);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
return [
|
||||
state,
|
||||
{
|
||||
dispatch: async (event) => {
|
||||
await api.invoke(ipcChannels.npcsOverlay.dispatch, { event });
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
@@ -15,7 +15,15 @@ export function appDisplayNameForLocale(localeTag: string): string {
|
||||
/** Префикс заголовка окон: `TTRPG - Редактор`. */
|
||||
export const APP_WINDOW_BRAND = 'TTRPG';
|
||||
|
||||
export type AppWindowKind = 'editor' | 'presentation' | 'control' | 'boot' | 'sceneDescription' | 'materials';
|
||||
export type AppWindowKind =
|
||||
| 'editor'
|
||||
| 'presentation'
|
||||
| 'control'
|
||||
| 'boot'
|
||||
| 'sceneDescription'
|
||||
| 'materials'
|
||||
| 'npcsEditor'
|
||||
| 'npcs';
|
||||
|
||||
const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
|
||||
editor: { ru: 'Редактор', en: 'Editor' },
|
||||
@@ -24,6 +32,8 @@ const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
|
||||
boot: { ru: 'Загрузка', en: 'Loading' },
|
||||
sceneDescription: { ru: 'Описание сцены', en: 'Scene description' },
|
||||
materials: { ru: 'Материалы', en: 'Materials' },
|
||||
npcsEditor: { ru: 'НПС', en: 'NPCs' },
|
||||
npcs: { ru: 'НПС', en: 'NPCs' },
|
||||
};
|
||||
|
||||
export function windowChromeTitle(kind: AppWindowKind, localeTag: string): string {
|
||||
|
||||
@@ -61,13 +61,15 @@ function minimalProject(overrides: Partial<Project> = {}): Project {
|
||||
updatedAt: '2020-01-01T00:00:00.000Z',
|
||||
createdWithAppVersion: '1',
|
||||
appVersion: '1',
|
||||
schemaVersion: 7,
|
||||
schemaVersion: 8,
|
||||
},
|
||||
scenes: {},
|
||||
sceneListOrder: [],
|
||||
assets: {},
|
||||
campaignAudios: [],
|
||||
materials: [],
|
||||
npcs: [],
|
||||
npcRelations: [],
|
||||
currentSceneId: null,
|
||||
currentGraphNodeId: null,
|
||||
sceneGraphNodes: [],
|
||||
|
||||
@@ -14,7 +14,15 @@ import type {
|
||||
SceneId,
|
||||
} from '../types';
|
||||
import type { AssetId, ProjectId } from '../types/ids';
|
||||
import { asAssetId, asGraphNodeId, asMaterialId, asProjectId, asSceneId } from '../types/ids';
|
||||
import {
|
||||
asAssetId,
|
||||
asGraphNodeId,
|
||||
asMaterialId,
|
||||
asNpcId,
|
||||
asNpcRelationId,
|
||||
asProjectId,
|
||||
asSceneId,
|
||||
} from '../types/ids';
|
||||
|
||||
export type StorylineKind = 'main' | 'side';
|
||||
|
||||
@@ -274,6 +282,8 @@ export function buildPartialExportProject(
|
||||
assets,
|
||||
campaignAudios: source.campaignAudios.map((a) => ({ ...a })),
|
||||
materials: (source.materials ?? []).map((m) => ({ ...m })),
|
||||
npcs: (source.npcs ?? []).map((n) => ({ ...n })),
|
||||
npcRelations: (source.npcRelations ?? []).map((r) => ({ ...r })),
|
||||
currentSceneId: mainStart?.sceneId ?? firstSide?.sceneId ?? null,
|
||||
currentGraphNodeId: mainStart?.id ?? firstSide?.id ?? null,
|
||||
};
|
||||
@@ -289,6 +299,7 @@ function collectReferencedAssetIdsForProject(p: Project): Set<AssetId> {
|
||||
}
|
||||
for (const au of p.campaignAudios) refs.add(au.assetId);
|
||||
for (const m of p.materials ?? []) refs.add(m.assetId);
|
||||
for (const n of p.npcs ?? []) refs.add(n.avatarAssetId);
|
||||
return refs;
|
||||
}
|
||||
|
||||
@@ -376,6 +387,7 @@ export function mergeStorylinesIntoProject(
|
||||
}
|
||||
for (const au of source.campaignAudios) neededAssetIds.add(au.assetId);
|
||||
for (const m of source.materials ?? []) neededAssetIds.add(m.assetId);
|
||||
for (const n of source.npcs ?? []) neededAssetIds.add(n.avatarAssetId);
|
||||
|
||||
const assetMap = new Map<AssetId, AssetId>();
|
||||
const targetSha = new Map<string, AssetId>();
|
||||
@@ -509,12 +521,66 @@ export function mergeStorylinesIntoProject(
|
||||
materialAssetIds.add(mapped);
|
||||
}
|
||||
|
||||
const npcs = [...(target.npcs ?? [])];
|
||||
const npcNameKeys = new Set(npcs.map((n) => n.name.trim().toLowerCase()));
|
||||
const npcAssetIds = new Set(npcs.map((n) => n.avatarAssetId));
|
||||
const npcIdMap = new Map<string, string>();
|
||||
for (const n of source.npcs ?? []) {
|
||||
const mappedAvatar = assetMap.get(n.avatarAssetId) ?? n.avatarAssetId;
|
||||
const existingByAsset = npcs.find((x) => x.avatarAssetId === mappedAvatar);
|
||||
if (existingByAsset || npcAssetIds.has(mappedAvatar)) {
|
||||
if (existingByAsset) npcIdMap.set(n.id, existingByAsset.id);
|
||||
continue;
|
||||
}
|
||||
let name = n.name.trim();
|
||||
const baseKey = name.toLowerCase();
|
||||
if (npcNameKeys.has(baseKey)) {
|
||||
let i = 2;
|
||||
while (npcNameKeys.has(`${baseKey} (${String(i)})`)) i += 1;
|
||||
name = `${name} (${String(i)})`;
|
||||
}
|
||||
const newId = asNpcId(`npc_${generateId()}`);
|
||||
npcIdMap.set(n.id, newId);
|
||||
npcs.push({
|
||||
id: newId,
|
||||
name,
|
||||
avatarAssetId: mappedAvatar,
|
||||
description: n.description ?? '',
|
||||
x: n.x,
|
||||
y: n.y,
|
||||
});
|
||||
npcNameKeys.add(name.toLowerCase());
|
||||
npcAssetIds.add(mappedAvatar);
|
||||
}
|
||||
|
||||
const npcRelations = [...(target.npcRelations ?? [])];
|
||||
for (const r of source.npcRelations ?? []) {
|
||||
const sourceId = npcIdMap.get(r.sourceNpcId) ?? r.sourceNpcId;
|
||||
const targetId = npcIdMap.get(r.targetNpcId) ?? r.targetNpcId;
|
||||
if (!npcs.some((n) => n.id === sourceId) || !npcs.some((n) => n.id === targetId)) continue;
|
||||
const label = r.label.trim();
|
||||
if (!label) continue;
|
||||
const dup = npcRelations.some(
|
||||
(x) =>
|
||||
x.label === label && x.sourceNpcId === sourceId && x.targetNpcId === targetId,
|
||||
);
|
||||
if (dup) continue;
|
||||
npcRelations.push({
|
||||
id: asNpcRelationId(`nrel_${generateId()}`),
|
||||
sourceNpcId: asNpcId(sourceId),
|
||||
targetNpcId: asNpcId(targetId),
|
||||
label,
|
||||
});
|
||||
}
|
||||
|
||||
let merged: Project = {
|
||||
...target,
|
||||
scenes,
|
||||
assets,
|
||||
campaignAudios,
|
||||
materials,
|
||||
npcs,
|
||||
npcRelations,
|
||||
sceneGraphNodes: [...target.sceneGraphNodes, ...newGraphNodes],
|
||||
sceneGraphEdges: [...target.sceneGraphEdges, ...newEdges],
|
||||
};
|
||||
|
||||
@@ -8,6 +8,10 @@ import type {
|
||||
MaterialsOverlayEvent,
|
||||
MaterialsOverlayState,
|
||||
MediaAsset,
|
||||
NpcId,
|
||||
NpcRelationId,
|
||||
NpcsOverlayEvent,
|
||||
NpcsOverlayState,
|
||||
Project,
|
||||
ProjectId,
|
||||
Scene,
|
||||
@@ -54,6 +58,14 @@ export const ipcChannels = {
|
||||
deleteMaterial: 'project.deleteMaterial',
|
||||
setMaterialsOrder: 'project.setMaterialsOrder',
|
||||
pickMaterialImage: 'project.pickMaterialImage',
|
||||
upsertNpc: 'project.upsertNpc',
|
||||
updateNpcFields: 'project.updateNpcFields',
|
||||
updateNpcPosition: 'project.updateNpcPosition',
|
||||
deleteNpc: 'project.deleteNpc',
|
||||
setNpcsOrder: 'project.setNpcsOrder',
|
||||
pickNpcAvatar: 'project.pickNpcAvatar',
|
||||
upsertNpcRelation: 'project.upsertNpcRelation',
|
||||
deleteNpcRelation: 'project.deleteNpcRelation',
|
||||
importScenePreview: 'project.importScenePreview',
|
||||
clearScenePreview: 'project.clearScenePreview',
|
||||
assetFileUrl: 'project.assetFileUrl',
|
||||
@@ -95,6 +107,10 @@ export const ipcChannels = {
|
||||
sceneDescriptionContent: 'windows.sceneDescriptionContent',
|
||||
openMaterials: 'windows.openMaterials',
|
||||
closeMaterials: 'windows.closeMaterials',
|
||||
openNpcsEditor: 'windows.openNpcsEditor',
|
||||
closeNpcsEditor: 'windows.closeNpcsEditor',
|
||||
openNpcs: 'windows.openNpcs',
|
||||
closeNpcs: 'windows.closeNpcs',
|
||||
},
|
||||
session: {
|
||||
stateChanged: 'session.stateChanged',
|
||||
@@ -109,6 +125,11 @@ export const ipcChannels = {
|
||||
dispatch: 'materialsOverlay.dispatch',
|
||||
stateChanged: 'materialsOverlay.stateChanged',
|
||||
},
|
||||
npcsOverlay: {
|
||||
getState: 'npcsOverlay.getState',
|
||||
dispatch: 'npcsOverlay.dispatch',
|
||||
stateChanged: 'npcsOverlay.stateChanged',
|
||||
},
|
||||
sceneDarkness: {
|
||||
getState: 'sceneDarkness.getState',
|
||||
dispatch: 'sceneDarkness.dispatch',
|
||||
@@ -172,6 +193,7 @@ export type IpcEventMap = {
|
||||
[ipcChannels.session.stateChanged]: { state: SessionState };
|
||||
[ipcChannels.effects.stateChanged]: { state: EffectsState };
|
||||
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
|
||||
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
|
||||
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
|
||||
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
||||
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
|
||||
@@ -274,6 +296,45 @@ export type IpcInvokeMap = {
|
||||
| { canceled: true }
|
||||
| { canceled: false; filePath: string; previewDataUrl: string };
|
||||
};
|
||||
[ipcChannels.project.upsertNpc]: {
|
||||
req: { npcId?: NpcId; name: string; description?: string; filePath?: string };
|
||||
res: { project: Project };
|
||||
};
|
||||
[ipcChannels.project.updateNpcFields]: {
|
||||
req: { npcId: NpcId; name?: string; description?: string };
|
||||
res: { project: Project };
|
||||
};
|
||||
[ipcChannels.project.updateNpcPosition]: {
|
||||
req: { npcId: NpcId; x: number; y: number };
|
||||
res: { project: Project };
|
||||
};
|
||||
[ipcChannels.project.deleteNpc]: {
|
||||
req: { npcId: NpcId };
|
||||
res: { project: Project };
|
||||
};
|
||||
[ipcChannels.project.setNpcsOrder]: {
|
||||
req: { npcIds: NpcId[] };
|
||||
res: { project: Project };
|
||||
};
|
||||
[ipcChannels.project.pickNpcAvatar]: {
|
||||
req: Record<string, never>;
|
||||
res:
|
||||
| { canceled: true }
|
||||
| { canceled: false; filePath: string; previewDataUrl: string };
|
||||
};
|
||||
[ipcChannels.project.upsertNpcRelation]: {
|
||||
req: {
|
||||
relationId?: NpcRelationId;
|
||||
sourceNpcId: NpcId;
|
||||
targetNpcId: NpcId;
|
||||
label: string;
|
||||
};
|
||||
res: { project: Project };
|
||||
};
|
||||
[ipcChannels.project.deleteNpcRelation]: {
|
||||
req: { relationId: NpcRelationId };
|
||||
res: { project: Project };
|
||||
};
|
||||
[ipcChannels.project.importScenePreview]: {
|
||||
req: { sceneId: SceneId; filePath?: string };
|
||||
res: { project: Project; assetId: AssetId | null; background: boolean };
|
||||
@@ -436,6 +497,22 @@ export type IpcInvokeMap = {
|
||||
req: Record<string, never>;
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.windows.openNpcsEditor]: {
|
||||
req: Record<string, never>;
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.windows.closeNpcsEditor]: {
|
||||
req: Record<string, never>;
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.windows.openNpcs]: {
|
||||
req: Record<string, never>;
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.windows.closeNpcs]: {
|
||||
req: Record<string, never>;
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.materialsOverlay.getState]: {
|
||||
req: Record<string, never>;
|
||||
res: { state: MaterialsOverlayState };
|
||||
@@ -444,6 +521,14 @@ export type IpcInvokeMap = {
|
||||
req: { event: MaterialsOverlayEvent };
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.npcsOverlay.getState]: {
|
||||
req: Record<string, never>;
|
||||
res: { state: NpcsOverlayState };
|
||||
};
|
||||
[ipcChannels.npcsOverlay.dispatch]: {
|
||||
req: { event: NpcsOverlayEvent };
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.effects.getState]: {
|
||||
req: Record<string, never>;
|
||||
res: { state: EffectsState };
|
||||
@@ -495,6 +580,7 @@ export type LegacyIpcEventMap = {
|
||||
[ipcChannels.session.stateChanged]: { state: SessionState };
|
||||
[ipcChannels.effects.stateChanged]: { state: EffectsState };
|
||||
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
|
||||
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
|
||||
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
|
||||
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
||||
[ipcChannels.license.statusChanged]: Record<string, never>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AssetId, GraphNodeId, MaterialId, ProjectId, SceneId } from './ids';
|
||||
import type { AssetId, GraphNodeId, MaterialId, NpcId, NpcRelationId, ProjectId, SceneId } from './ids';
|
||||
|
||||
export const PROJECT_SCHEMA_VERSION = 7 as const;
|
||||
export const PROJECT_SCHEMA_VERSION = 8 as const;
|
||||
|
||||
/** Материал кампании: изображение, показываемое поверх сцены во время игры. */
|
||||
export type ProjectMaterial = {
|
||||
@@ -10,6 +10,26 @@ export type ProjectMaterial = {
|
||||
rotationDeg: 0 | 90 | 180 | 270;
|
||||
};
|
||||
|
||||
/** НПС кампании: персонаж с аватаром, описанием и связями на графе. */
|
||||
export type ProjectNpc = {
|
||||
id: NpcId;
|
||||
name: string;
|
||||
avatarAssetId: AssetId;
|
||||
/** HTML-описание (TipTap), как у сцены. */
|
||||
description: string;
|
||||
/** Позиция карточки на графе взаимосвязей. */
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
/** Однонаправленная связь: от `sourceNpcId` к `targetNpcId`; подпись на линии. */
|
||||
export type ProjectNpcRelation = {
|
||||
id: NpcRelationId;
|
||||
sourceNpcId: NpcId;
|
||||
targetNpcId: NpcId;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type IsoDateTimeString = string;
|
||||
|
||||
export type MediaAssetType = 'image' | 'video' | 'audio';
|
||||
@@ -145,6 +165,10 @@ export type Project = {
|
||||
campaignAudios: SceneAudioRef[];
|
||||
/** Материалы кампании: изображения для показа поверх сцены (порядок = порядок в списке). */
|
||||
materials: ProjectMaterial[];
|
||||
/** НПС кампании (порядок = порядок в списке редактора/пульта). */
|
||||
npcs: ProjectNpc[];
|
||||
/** Связи между НПС (однонаправленные; между одной парой направлений может быть несколько). */
|
||||
npcRelations: ProjectNpcRelation[];
|
||||
currentSceneId: SceneId | null;
|
||||
/** Текущая нода графа (важно, когда одна сцена имеет несколько нод). */
|
||||
currentGraphNodeId: GraphNodeId | null;
|
||||
|
||||
@@ -5,6 +5,8 @@ export type SceneId = Brand<string, 'SceneId'>;
|
||||
export type AssetId = Brand<string, 'AssetId'>;
|
||||
export type GraphNodeId = Brand<string, 'GraphNodeId'>;
|
||||
export type MaterialId = Brand<string, 'MaterialId'>;
|
||||
export type NpcId = Brand<string, 'NpcId'>;
|
||||
export type NpcRelationId = Brand<string, 'NpcRelationId'>;
|
||||
|
||||
export function asProjectId(value: string): ProjectId {
|
||||
return value as ProjectId;
|
||||
@@ -25,3 +27,11 @@ export function asGraphNodeId(value: string): GraphNodeId {
|
||||
export function asMaterialId(value: string): MaterialId {
|
||||
return value as MaterialId;
|
||||
}
|
||||
|
||||
export function asNpcId(value: string): NpcId {
|
||||
return value as NpcId;
|
||||
}
|
||||
|
||||
export function asNpcRelationId(value: string): NpcRelationId {
|
||||
return value as NpcRelationId;
|
||||
}
|
||||
|
||||
@@ -2,5 +2,6 @@ export * from './domain';
|
||||
export * from './effects';
|
||||
export * from './ids';
|
||||
export * from './materials';
|
||||
export * from './npcs';
|
||||
export * from './sceneDarkness';
|
||||
export * from './videoPlayback';
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { NpcId } from './ids';
|
||||
|
||||
/** Нормированная раскладка аватара НПС в области показа (0..1). */
|
||||
export type NpcsOverlayLayout = {
|
||||
cx: number;
|
||||
cy: number;
|
||||
scale: number;
|
||||
};
|
||||
|
||||
export type NpcsZoomTool = 'zoomIn' | 'zoomOut' | null;
|
||||
|
||||
/** Session-only: какой НПС сейчас показан поверх сцены (только аватар). */
|
||||
export type NpcsOverlayState = {
|
||||
revision: number;
|
||||
activeNpcId: NpcId | null;
|
||||
layout: NpcsOverlayLayout;
|
||||
zoomTool: NpcsZoomTool;
|
||||
};
|
||||
|
||||
export const DEFAULT_NPCS_OVERLAY_LAYOUT: NpcsOverlayLayout = {
|
||||
cx: 0.5,
|
||||
cy: 0.5,
|
||||
scale: 1,
|
||||
};
|
||||
|
||||
export type NpcsOverlayEvent =
|
||||
| { kind: 'show'; npcId: NpcId }
|
||||
| { kind: 'hide' }
|
||||
| { kind: 'toggle'; npcId: NpcId }
|
||||
| { kind: 'layout.set'; layout: NpcsOverlayLayout }
|
||||
| { kind: 'zoomTool.set'; tool: NpcsZoomTool }
|
||||
| { kind: 'zoomAt'; nx: number; ny: number };
|
||||
|
||||
export function clampNpcsLayout(layout: NpcsOverlayLayout): NpcsOverlayLayout {
|
||||
return {
|
||||
cx: Math.min(1.2, Math.max(-0.2, layout.cx)),
|
||||
cy: Math.min(1.2, Math.max(-0.2, layout.cy)),
|
||||
scale: Math.min(8, Math.max(0.15, layout.scale)),
|
||||
};
|
||||
}
|
||||
|
||||
export function zoomNpcsLayoutAt(
|
||||
layout: NpcsOverlayLayout,
|
||||
nx: number,
|
||||
ny: number,
|
||||
factor: number,
|
||||
): NpcsOverlayLayout {
|
||||
const nextScale = layout.scale * factor;
|
||||
const clamped = clampNpcsLayout({ ...layout, scale: nextScale });
|
||||
const ratio = clamped.scale / layout.scale;
|
||||
return clampNpcsLayout({
|
||||
...layout,
|
||||
cx: nx - (nx - layout.cx) * ratio,
|
||||
cy: ny - (ny - layout.cy) * ratio,
|
||||
scale: clamped.scale,
|
||||
});
|
||||
}
|
||||
@@ -55,6 +55,8 @@ export default defineConfig(({ mode }) => {
|
||||
control: path.resolve(__dirname, 'app/renderer/control.html'),
|
||||
sceneDescription: path.resolve(__dirname, 'app/renderer/sceneDescription.html'),
|
||||
materials: path.resolve(__dirname, 'app/renderer/materials.html'),
|
||||
npcsEditor: path.resolve(__dirname, 'app/renderer/npcsEditor.html'),
|
||||
npcs: path.resolve(__dirname, 'app/renderer/npcs.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user