Working on dialog editor, on dialogs, on phone
This commit is contained in:
1 parent
0e27521bea
commit
8f7797789d
63 files changed
+9502
-1459
No files matched your search
@@ -0,0 +1,185 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
NodeChange,
|
||||
Node,
|
||||
Edge,
|
||||
Connection,
|
||||
MarkerType,
|
||||
} from '@xyflow/react';
|
||||
import { useDialogueStore } from '../../store/dialogueStore';
|
||||
import { nodeTypes } from '../nodes/nodeTypes';
|
||||
import { DialogueNode, ChoiceNode } from '../../types/dialogue';
|
||||
import { GraphToolbar } from './GraphToolbar';
|
||||
import styles from './GraphPanel.module.css';
|
||||
|
||||
function buildEdges(nodes: DialogueNode[]): Edge[] {
|
||||
const edges: Edge[] = [];
|
||||
for (const node of nodes) {
|
||||
const base = {
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#6c7086' },
|
||||
style: { stroke: '#6c7086', strokeWidth: 1.5 },
|
||||
animated: false,
|
||||
};
|
||||
|
||||
if (node.type === 'Line' || node.type === 'SetFlag' || node.type === 'CutsceneStart') {
|
||||
if (node.next) {
|
||||
edges.push({ ...base, id: `${node.id}->source`, source: node.id, target: node.next, sourceHandle: 'source', targetHandle: 'target' });
|
||||
}
|
||||
} else if (node.type === 'Choice') {
|
||||
for (const choice of node.choices) {
|
||||
if (choice.next) {
|
||||
edges.push({ ...base, id: `${node.id}->${choice.id}`, source: node.id, target: choice.next, sourceHandle: choice.id, targetHandle: 'target', label: choice.text.slice(0, 20), labelStyle: { fontSize: 10, fill: '#cdd6f4' }, labelBgStyle: { fill: '#181825' } });
|
||||
}
|
||||
}
|
||||
} else if (node.type === 'Condition') {
|
||||
if (node.trueNext) {
|
||||
edges.push({ ...base, id: `${node.id}->true`, source: node.id, target: node.trueNext, sourceHandle: 'true', targetHandle: 'target', label: 'TRUE', labelStyle: { fontSize: 10, fill: '#a6e3a1' }, labelBgStyle: { fill: '#181825' }, style: { ...base.style, stroke: '#a6e3a1' }, markerEnd: { type: MarkerType.ArrowClosed, color: '#a6e3a1' } });
|
||||
}
|
||||
if (node.falseNext) {
|
||||
edges.push({ ...base, id: `${node.id}->false`, source: node.id, target: node.falseNext, sourceHandle: 'false', targetHandle: 'target', label: 'FALSE', labelStyle: { fontSize: 10, fill: '#f38ba8' }, labelBgStyle: { fill: '#181825' }, style: { ...base.style, stroke: '#f38ba8' }, markerEnd: { type: MarkerType.ArrowClosed, color: '#f38ba8' } });
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
export function GraphPanel() {
|
||||
const {
|
||||
file,
|
||||
selectedDialogueId,
|
||||
selectedNodeId,
|
||||
positions,
|
||||
selectNode,
|
||||
setNodePosition,
|
||||
applyAutoLayout,
|
||||
updateNode,
|
||||
} = useDialogueStore();
|
||||
|
||||
const dialogue = file?.dialogues.find(d => d.id === selectedDialogueId);
|
||||
const dialoguePositions = positions[selectedDialogueId ?? ''] ?? {};
|
||||
|
||||
const rfNodes: Node[] = useMemo(() => {
|
||||
if (!dialogue) return [];
|
||||
|
||||
return dialogue.nodes.map(node => ({
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
data: node as unknown as Record<string, unknown>,
|
||||
position: dialoguePositions[node.id] ?? { x: 0, y: 0 },
|
||||
selected: node.id === selectedNodeId,
|
||||
}));
|
||||
}, [dialogue, dialoguePositions, selectedNodeId]);
|
||||
|
||||
const rfEdges: Edge[] = useMemo(() => {
|
||||
if (!dialogue) return [];
|
||||
return buildEdges(dialogue.nodes);
|
||||
}, [dialogue]);
|
||||
|
||||
const onNodesChange = useCallback((changes: NodeChange[]) => {
|
||||
if (!selectedDialogueId) return;
|
||||
for (const change of changes) {
|
||||
if (change.type === 'position' && change.position) {
|
||||
setNodePosition(selectedDialogueId, change.id, change.position);
|
||||
}
|
||||
}
|
||||
}, [selectedDialogueId, setNodePosition]);
|
||||
|
||||
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
|
||||
selectNode(node.id);
|
||||
}, [selectNode]);
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
selectNode(null);
|
||||
}, [selectNode]);
|
||||
|
||||
const onConnect = useCallback((connection: Connection) => {
|
||||
if (!selectedDialogueId || !connection.source || !connection.target) return;
|
||||
const dialogue = file?.dialogues.find(d => d.id === selectedDialogueId);
|
||||
if (!dialogue) return;
|
||||
const sourceNode = dialogue.nodes.find(n => n.id === connection.source);
|
||||
if (!sourceNode) return;
|
||||
|
||||
const handle = connection.sourceHandle;
|
||||
|
||||
if (sourceNode.type === 'Line' || sourceNode.type === 'SetFlag' || sourceNode.type === 'CutsceneStart') {
|
||||
updateNode(selectedDialogueId, sourceNode.id, { next: connection.target } as Partial<DialogueNode>);
|
||||
} else if (sourceNode.type === 'Condition') {
|
||||
if (handle === 'true') {
|
||||
updateNode(selectedDialogueId, sourceNode.id, { trueNext: connection.target } as Partial<DialogueNode>);
|
||||
} else if (handle === 'false') {
|
||||
updateNode(selectedDialogueId, sourceNode.id, { falseNext: connection.target } as Partial<DialogueNode>);
|
||||
}
|
||||
} else if (sourceNode.type === 'Choice') {
|
||||
const choices = (sourceNode as ChoiceNode).choices.map(c =>
|
||||
c.id === handle ? { ...c, next: connection.target! } : c
|
||||
);
|
||||
updateNode(selectedDialogueId, sourceNode.id, { choices } as Partial<DialogueNode>);
|
||||
}
|
||||
}, [selectedDialogueId, file, updateNode]);
|
||||
|
||||
// Trigger auto-layout when dialogue is first loaded with no positions
|
||||
useEffect(() => {
|
||||
if (dialogue && dialogue.nodes.length > 0 && !Object.keys(dialoguePositions).length && selectedDialogueId) {
|
||||
applyAutoLayout(selectedDialogueId);
|
||||
}
|
||||
}, [selectedDialogueId]);
|
||||
|
||||
if (!file) {
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
<div className={styles.emptyMsg}>Load a dialogue JSON file to get started</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dialogue) {
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
<div className={styles.emptyMsg}>Select a dialogue from the left panel</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<GraphToolbar />
|
||||
<div className={styles.flow}>
|
||||
<ReactFlow
|
||||
key={selectedDialogueId}
|
||||
nodes={rfNodes}
|
||||
edges={rfEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
deleteKeyCode={null}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background color="#313244" gap={20} />
|
||||
<Controls />
|
||||
<MiniMap
|
||||
nodeColor={(n) => {
|
||||
switch (n.type) {
|
||||
case 'Line': return '#1e66f5';
|
||||
case 'Choice': return '#df8e1d';
|
||||
case 'Condition': return '#8839ef';
|
||||
case 'SetFlag': return '#179299';
|
||||
case 'CutsceneStart': return '#4a4a5a';
|
||||
case 'End': return '#f38ba8';
|
||||
default: return '#6c7086';
|
||||
}
|
||||
}}
|
||||
style={{ background: '#181825', border: '1px solid #313244' }}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user