Files
Ivan Fontosh 101f595bac feat(players): app Players library and circular NPC tokens on scenes
Add userData players/teams, scene npcTokens with hex-inscribed sizing, session scale synced to presentation, and Playwright e2e coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:38:35 +08:00

617 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { collectDescendantGroupIds } from '../../shared/npcs/npcGroups';
import type {
NpcGroupId,
NpcId,
NpcRelationId,
ProjectNpc,
ProjectNpcGroup,
ProjectNpcRelation,
} from '../../shared/types';
import { Select } from '../shared/ui/controls';
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 GraphGroupFilter = 'all' | 'ungrouped' | NpcGroupId;
export type NpcGraphUiStrings = {
zoomBar: string;
zoomIn: string;
zoomOut: string;
fitAll: string;
editRelation: string;
deleteRelation: string;
untitled: string;
graphFilter: string;
graphFilterAll: string;
graphFilterUngrouped: string;
};
type NpcNodeData = {
name: string;
avatarAssetId: ProjectNpc['avatarAssetId'];
active: boolean;
groupColor: string | null;
dimmed: 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'];
const accent = data.groupColor ?? NPC_ACCENT;
return (
<div
className={[
styles.node,
data.active || selected ? styles.nodeActive : '',
data.dimmed ? styles.nodeDimmed : '',
]
.filter(Boolean)
.join(' ')}
style={
data.groupColor
? ({
'--npc-group-color': accent,
borderColor: data.active || selected ? accent : undefined,
} as React.CSSProperties)
: undefined
}
>
{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>
);
}
function FilterToolbar({
ui,
groups,
value,
onChange,
}: {
ui: NpcGraphUiStrings;
groups: ProjectNpcGroup[];
value: GraphGroupFilter;
onChange: (v: GraphGroupFilter) => void;
}) {
return (
<Panel position="top-left">
<Select
{...(styles.filterSelect ? { className: styles.filterSelect } : {})}
ariaLabel={ui.graphFilter}
value={value}
onChange={(next) => onChange(next as GraphGroupFilter)}
options={[
{ value: 'all', label: ui.graphFilterAll },
{ value: 'ungrouped', label: ui.graphFilterUngrouped },
...groups.map((g) => ({ value: g.id, label: g.name })),
]}
/>
</Panel>
);
}
export type NpcGraphProps = {
npcs: ProjectNpc[];
relations: ProjectNpcRelation[];
npcGroups: ProjectNpcGroup[];
selectedNpcId: NpcId | null;
graphFilter: GraphGroupFilter;
onGraphFilterChange: (filter: GraphGroupFilter) => void;
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,
npcGroups,
selectedNpcId,
graphFilter,
onGraphFilterChange,
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 groupColorById = useMemo(() => new Map(npcGroups.map((g) => [g.id, g.color])), [npcGroups]);
const filterGroupIds = useMemo(() => {
if (graphFilter === 'all' || graphFilter === 'ungrouped') return null;
return collectDescendantGroupIds(npcGroups, graphFilter);
}, [graphFilter, npcGroups]);
const isNpcDimmed = useCallback(
(npc: ProjectNpc) => {
if (graphFilter === 'all') return false;
if (graphFilter === 'ungrouped') return npc.groupId !== null;
if (!filterGroupIds) return true;
return npc.groupId === null || !filterGroupIds.has(npc.groupId);
},
[filterGroupIds, graphFilter],
);
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,
groupColor: n.groupId ? (groupColorById.get(n.groupId) ?? null) : null,
dimmed: isNpcDimmed(n),
},
})),
[groupColorById, isNpcDimmed, 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 sourceNpc = npcs.find((n) => n.id === r.sourceNpcId);
const targetNpc = npcs.find((n) => n.id === r.targetNpcId);
const edgeDimmed =
graphFilter !== 'all' &&
((sourceNpc && isNpcDimmed(sourceNpc)) || (targetNpc && isNpcDimmed(targetNpc)));
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,
opacity: edgeDimmed ? 0.2 : 1,
},
markerEnd: {
type: MarkerType.ArrowClosed,
width: 16,
height: 16,
color,
},
});
});
}
return out;
}, [graphFilter, isNpcDimmed, 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" />
<FilterToolbar
ui={graphUi}
groups={npcGroups}
value={graphFilter}
onChange={onGraphFilterChange}
/>
<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>
);
}