feat(npcs): add groups, storyline bindings, and Foundry import
Nested NPC groups with color, graph filter, and scene/storyline binding; Foundry worlds/modules import actors into groups; storyline merge asks on NPC name conflicts and reports NPC counts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+206
-128
@@ -22,7 +22,15 @@ import ReactFlow, {
|
||||
} from 'reactflow';
|
||||
import 'reactflow/dist/style.css';
|
||||
|
||||
import type { NpcId, NpcRelationId, ProjectNpc, ProjectNpcRelation } from '../../shared/types';
|
||||
import { collectDescendantGroupIds } from '../../shared/npcs/npcGroups';
|
||||
import type {
|
||||
NpcGroupId,
|
||||
NpcId,
|
||||
NpcRelationId,
|
||||
ProjectNpc,
|
||||
ProjectNpcGroup,
|
||||
ProjectNpcRelation,
|
||||
} from '../../shared/types';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import styles from './NpcGraph.module.css';
|
||||
@@ -32,6 +40,8 @@ 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;
|
||||
@@ -40,12 +50,17 @@ export type NpcGraphUiStrings = {
|
||||
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';
|
||||
@@ -102,30 +117,35 @@ function pickEndpointSides(
|
||||
? { sourceSide: 'right', targetSide: 'left' }
|
||||
: { sourceSide: 'left', targetSide: 'right' };
|
||||
}
|
||||
return dy >= 0
|
||||
? { sourceSide: 'bottom', targetSide: 'top' }
|
||||
: { sourceSide: 'top', targetSide: 'bottom' };
|
||||
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 : ''].filter(Boolean).join(' ')}>
|
||||
<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}
|
||||
/>
|
||||
<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}>
|
||||
@@ -151,9 +171,7 @@ function parallelCubicPath(
|
||||
): { path: string; labelX: number; labelY: number } {
|
||||
// Канонический вектор между концами (не зависит от направления стрелки).
|
||||
const [ax, ay, bx, by] =
|
||||
sourceNpcId < targetNpcId
|
||||
? [sourceX, sourceY, targetX, targetY]
|
||||
: [targetX, targetY, sourceX, sourceY];
|
||||
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;
|
||||
@@ -195,15 +213,7 @@ function LabeledNpcEdge({
|
||||
const targetNpcId = data?.targetNpcId;
|
||||
const { path, labelX, labelY } =
|
||||
sourceNpcId && targetNpcId
|
||||
? parallelCubicPath(
|
||||
sourceNpcId,
|
||||
targetNpcId,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
worldOffset,
|
||||
)
|
||||
? parallelCubicPath(sourceNpcId, targetNpcId, sourceX, sourceY, targetX, targetY, worldOffset)
|
||||
: {
|
||||
path: `M ${sourceX},${sourceY} L ${targetX},${targetY}`,
|
||||
labelX: (sourceX + targetX) / 2,
|
||||
@@ -225,12 +235,7 @@ function LabeledNpcEdge({
|
||||
{label && relationId ? (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
className={[
|
||||
styles.edgeLabel,
|
||||
highlighted ? styles.edgeLabelActive : '',
|
||||
'nodrag',
|
||||
'nopan',
|
||||
]
|
||||
className={[styles.edgeLabel, highlighted ? styles.edgeLabelActive : '', 'nodrag', 'nopan']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{
|
||||
@@ -269,7 +274,12 @@ function ZoomToolbar({ ui }: { ui: NpcGraphUiStrings }) {
|
||||
<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
|
||||
type="button"
|
||||
className={styles.zoomBtn}
|
||||
onClick={() => fitView({ padding: 0.2 })}
|
||||
aria-label={ui.fitAll}
|
||||
>
|
||||
⤢
|
||||
</button>
|
||||
</div>
|
||||
@@ -277,10 +287,44 @@ function ZoomToolbar({ ui }: { ui: NpcGraphUiStrings }) {
|
||||
);
|
||||
}
|
||||
|
||||
function FilterToolbar({
|
||||
ui,
|
||||
groups,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
ui: NpcGraphUiStrings;
|
||||
groups: ProjectNpcGroup[];
|
||||
value: GraphGroupFilter;
|
||||
onChange: (v: GraphGroupFilter) => void;
|
||||
}) {
|
||||
return (
|
||||
<Panel position="top-left">
|
||||
<select
|
||||
className={styles.filterSelect}
|
||||
aria-label={ui.graphFilter}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as GraphGroupFilter)}
|
||||
>
|
||||
<option value="all">{ui.graphFilterAll}</option>
|
||||
<option value="ungrouped">{ui.graphFilterUngrouped}</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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;
|
||||
@@ -292,7 +336,10 @@ export type NpcGraphProps = {
|
||||
function NpcGraphInner({
|
||||
npcs,
|
||||
relations,
|
||||
npcGroups,
|
||||
selectedNpcId,
|
||||
graphFilter,
|
||||
onGraphFilterChange,
|
||||
graphUi,
|
||||
onSelect,
|
||||
onConnectRequest,
|
||||
@@ -300,9 +347,7 @@ function NpcGraphInner({
|
||||
onEditRelation,
|
||||
onDeleteRelation,
|
||||
}: NpcGraphProps) {
|
||||
const [menu, setMenu] = useState<{ relationId: NpcRelationId; left: number; top: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [menu, setMenu] = useState<{ relationId: NpcRelationId; left: number; top: number } | null>(null);
|
||||
/** Откуда реально начали тянуть связь (Loose mode может перевернуть source/target). */
|
||||
const connectFromRef = useRef<NpcId | null>(null);
|
||||
|
||||
@@ -320,6 +365,23 @@ function NpcGraphInner({
|
||||
};
|
||||
}, [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) => ({
|
||||
@@ -330,9 +392,11 @@ function NpcGraphInner({
|
||||
name: n.name,
|
||||
avatarAssetId: n.avatarAssetId,
|
||||
active: n.id === selectedNpcId,
|
||||
groupColor: n.groupId ? (groupColorById.get(n.groupId) ?? null) : null,
|
||||
dimmed: isNpcDimmed(n),
|
||||
},
|
||||
})),
|
||||
[npcs, selectedNpcId],
|
||||
[groupColorById, isNpcDimmed, npcs, selectedNpcId],
|
||||
);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
@@ -364,6 +428,11 @@ function NpcGraphInner({
|
||||
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,
|
||||
@@ -381,7 +450,11 @@ function NpcGraphInner({
|
||||
targetNpcId: r.targetNpcId,
|
||||
highlighted,
|
||||
},
|
||||
style: { stroke: color, strokeWidth: highlighted ? 2.5 : 2 },
|
||||
style: {
|
||||
stroke: color,
|
||||
strokeWidth: highlighted ? 2.5 : 2,
|
||||
opacity: edgeDimmed ? 0.2 : 1,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 16,
|
||||
@@ -392,7 +465,7 @@ function NpcGraphInner({
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [nodes, npcs, relations, selectedNpcId]);
|
||||
}, [graphFilter, isNpcDimmed, nodes, npcs, relations, selectedNpcId]);
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(initialNodes);
|
||||
@@ -435,97 +508,102 @@ function NpcGraphInner({
|
||||
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()}
|
||||
>
|
||||
<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"
|
||||
className={styles.menuItem}
|
||||
onClick={() => {
|
||||
onEditRelation(menu.relationId);
|
||||
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()}
|
||||
>
|
||||
{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>
|
||||
<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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user