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
+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,
});
}