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:
@@ -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