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:
Ivan Fontosh
2026-07-17 13:13:58 +08:00
parent 61875be857
commit 37ba855faf
40 changed files with 3655 additions and 17 deletions
+11 -1
View File
@@ -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: [],
+67 -1
View File
@@ -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],
};
+86
View File
@@ -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>;
+26 -2
View File
@@ -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;
+10
View File
@@ -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;
}
+1
View File
@@ -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';
+57
View File
@@ -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,
});
}