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:
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user