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
+540
View File
@@ -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>
);
}