[ISSUE-1] Refactor NPC forms to modular node-based system #2
383
src/components/NodeCanvas.tsx
Normal file
383
src/components/NodeCanvas.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
addEdge,
|
||||
Panel,
|
||||
} from '@xyflow/react';
|
||||
import type { Connection, NodeTypes } from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { Plus, Sparkles } from 'lucide-react';
|
||||
import type { NPCConfiguration } from '../types/npc';
|
||||
import type { ModularNode, NodeType } from '../types/nodes';
|
||||
import { BasicInfoNode } from './nodes/BasicInfoNode';
|
||||
import { BattleConfigNode } from './nodes/BattleConfigNode';
|
||||
import { PartyNode } from './nodes/PartyNode';
|
||||
import { InteractionNode } from './nodes/InteractionNode';
|
||||
import { VariablesNode } from './nodes/VariablesNode';
|
||||
import { OutputNode } from './nodes/OutputNode';
|
||||
|
||||
interface NodeCanvasProps {
|
||||
npcConfig: NPCConfiguration;
|
||||
onConfigChange: (config: NPCConfiguration) => void;
|
||||
}
|
||||
|
||||
// Define custom node types for React Flow
|
||||
const nodeTypes: NodeTypes = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
basicInfo: BasicInfoNode as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
battleConfig: BattleConfigNode as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
party: PartyNode as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
interaction: InteractionNode as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
variables: VariablesNode as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
output: OutputNode as any,
|
||||
};
|
||||
|
||||
export function NodeCanvas({
|
||||
npcConfig,
|
||||
onConfigChange,
|
||||
}: NodeCanvasProps) {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<ModularNode>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||
const [showNodeMenu, setShowNodeMenu] = useState(false);
|
||||
|
||||
// Initialize with a basic setup
|
||||
useEffect(() => {
|
||||
const initialNodes: ModularNode[] = [
|
||||
{
|
||||
id: 'basic-1',
|
||||
type: 'basicInfo',
|
||||
position: { x: 50, y: 100 },
|
||||
data: {
|
||||
type: 'basicInfo',
|
||||
resourceIdentifier: npcConfig.resourceIdentifier || 'cobblemon:my_npc',
|
||||
names: npcConfig.names || ['My NPC'],
|
||||
hitbox: npcConfig.hitbox || 'player',
|
||||
modelScale: npcConfig.modelScale,
|
||||
aspects: npcConfig.aspects,
|
||||
isInvulnerable: npcConfig.isInvulnerable,
|
||||
canDespawn: npcConfig.canDespawn,
|
||||
isMovable: npcConfig.isMovable,
|
||||
isLeashable: npcConfig.isLeashable,
|
||||
allowProjectileHits: npcConfig.allowProjectileHits,
|
||||
hideNameTag: npcConfig.hideNameTag,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'output-1',
|
||||
type: 'output',
|
||||
position: { x: 800, y: 250 },
|
||||
data: {
|
||||
type: 'output',
|
||||
previewData: npcConfig,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
setNodes(initialNodes);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Update NPC config when nodes change
|
||||
useEffect(() => {
|
||||
const newConfig = { ...npcConfig };
|
||||
|
||||
nodes.forEach((node) => {
|
||||
switch (node.data.type) {
|
||||
case 'basicInfo': {
|
||||
const data = node.data;
|
||||
newConfig.resourceIdentifier = data.resourceIdentifier;
|
||||
newConfig.names = data.names;
|
||||
if (typeof data.hitbox === 'string' || (typeof data.hitbox === 'object' && data.hitbox !== null)) {
|
||||
newConfig.hitbox = data.hitbox as "player" | { width: number; height: number };
|
||||
}
|
||||
newConfig.modelScale = data.modelScale;
|
||||
newConfig.aspects = data.aspects;
|
||||
newConfig.isInvulnerable = data.isInvulnerable;
|
||||
newConfig.canDespawn = data.canDespawn;
|
||||
newConfig.isMovable = data.isMovable;
|
||||
newConfig.isLeashable = data.isLeashable;
|
||||
newConfig.allowProjectileHits = data.allowProjectileHits;
|
||||
newConfig.hideNameTag = data.hideNameTag;
|
||||
break;
|
||||
}
|
||||
case 'battleConfig': {
|
||||
const data = node.data;
|
||||
newConfig.battleConfiguration = {
|
||||
canBattle: data.canBattle,
|
||||
canChallenge: data.canChallenge,
|
||||
battleTheme: data.battleTheme,
|
||||
victoryTheme: data.victoryTheme,
|
||||
defeatTheme: data.defeatTheme,
|
||||
simultaneousBattles: data.simultaneousBattles,
|
||||
healAfterwards: data.healAfterwards,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'party': {
|
||||
const data = node.data;
|
||||
newConfig.party = data.partyConfig;
|
||||
break;
|
||||
}
|
||||
case 'interaction': {
|
||||
const data = node.data;
|
||||
newConfig.interactions = [{
|
||||
type: data.interactionType,
|
||||
data: data.interactionData,
|
||||
dialogue: data.dialogueReference,
|
||||
script: data.scriptReference,
|
||||
}];
|
||||
break;
|
||||
}
|
||||
case 'variables': {
|
||||
const data = node.data;
|
||||
newConfig.config = data.configVariables;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onConfigChange(newConfig);
|
||||
|
||||
// Update output node preview
|
||||
const outputNode = nodes.find(n => n.data.type === 'output');
|
||||
if (outputNode) {
|
||||
setNodes((nds) =>
|
||||
nds.map((node) =>
|
||||
node.id === outputNode.id
|
||||
? { ...node, data: { ...node.data, previewData: newConfig } }
|
||||
: node
|
||||
)
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [nodes]);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => setEdges((eds) => addEdge(connection, eds)),
|
||||
[setEdges]
|
||||
);
|
||||
|
||||
const addNode = (type: NodeType) => {
|
||||
const newNodeId = `${type}-${Date.now()}`;
|
||||
const position = {
|
||||
x: Math.random() * 400 + 100,
|
||||
y: Math.random() * 300 + 100,
|
||||
};
|
||||
|
||||
let newNode: ModularNode;
|
||||
|
||||
switch (type) {
|
||||
case 'basicInfo':
|
||||
newNode = {
|
||||
id: newNodeId,
|
||||
type: 'basicInfo',
|
||||
position,
|
||||
data: {
|
||||
type: 'basicInfo',
|
||||
resourceIdentifier: 'cobblemon:my_npc',
|
||||
names: ['My NPC'],
|
||||
hitbox: 'player',
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'battleConfig':
|
||||
newNode = {
|
||||
id: newNodeId,
|
||||
type: 'battleConfig',
|
||||
position,
|
||||
data: {
|
||||
type: 'battleConfig',
|
||||
canBattle: false,
|
||||
canChallenge: false,
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'party':
|
||||
newNode = {
|
||||
id: newNodeId,
|
||||
type: 'party',
|
||||
position,
|
||||
data: {
|
||||
type: 'party',
|
||||
partyConfig: { type: 'simple', pokemon: [''] },
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'interaction':
|
||||
newNode = {
|
||||
id: newNodeId,
|
||||
type: 'interaction',
|
||||
position,
|
||||
data: {
|
||||
type: 'interaction',
|
||||
interactionType: 'none',
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'variables':
|
||||
newNode = {
|
||||
id: newNodeId,
|
||||
type: 'variables',
|
||||
position,
|
||||
data: {
|
||||
type: 'variables',
|
||||
configVariables: [],
|
||||
},
|
||||
};
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
setNodes((nds) => [...nds, newNode]);
|
||||
setShowNodeMenu(false);
|
||||
};
|
||||
|
||||
const nodeCategories = [
|
||||
{
|
||||
name: 'Core',
|
||||
nodes: [
|
||||
{
|
||||
type: 'basicInfo' as NodeType,
|
||||
label: 'Basic Info',
|
||||
description: 'NPC name, model, and basic properties',
|
||||
color: 'bg-blue-500',
|
||||
icon: '⚙️',
|
||||
},
|
||||
{
|
||||
type: 'battleConfig' as NodeType,
|
||||
label: 'Battle Config',
|
||||
description: 'Battle settings and themes',
|
||||
color: 'bg-red-500',
|
||||
icon: '⚔️',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Content',
|
||||
nodes: [
|
||||
{
|
||||
type: 'party' as NodeType,
|
||||
label: 'Pokemon Party',
|
||||
description: 'Configure NPC Pokemon team',
|
||||
color: 'bg-green-500',
|
||||
icon: '👥',
|
||||
},
|
||||
{
|
||||
type: 'interaction' as NodeType,
|
||||
label: 'Interaction',
|
||||
description: 'Set up NPC interactions',
|
||||
color: 'bg-purple-500',
|
||||
icon: '💬',
|
||||
},
|
||||
{
|
||||
type: 'variables' as NodeType,
|
||||
label: 'Variables',
|
||||
description: 'MoLang config variables',
|
||||
color: 'bg-yellow-500',
|
||||
icon: '🔢',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen bg-gray-50">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
className="bg-gray-50"
|
||||
>
|
||||
<Background />
|
||||
<Controls />
|
||||
<MiniMap />
|
||||
|
||||
{/* Top control panel */}
|
||||
<Panel position="top-left" className="bg-white rounded-lg shadow-lg p-3 m-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-indigo-600" />
|
||||
<h2 className="font-semibold text-gray-900">Modular NPC Builder</h2>
|
||||
</div>
|
||||
<div className="h-6 w-px bg-gray-300" />
|
||||
<button
|
||||
onClick={() => setShowNodeMenu(!showNodeMenu)}
|
||||
className="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Module
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Node menu */}
|
||||
{showNodeMenu && (
|
||||
<Panel position="top-center" className="bg-white rounded-lg shadow-xl p-4 m-4 max-w-2xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900">Add Module</h3>
|
||||
<button
|
||||
onClick={() => setShowNodeMenu(false)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{nodeCategories.map((category) => (
|
||||
<div key={category.name}>
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">
|
||||
{category.name}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{category.nodes.map((node) => (
|
||||
<button
|
||||
key={node.type}
|
||||
onClick={() => addNode(node.type)}
|
||||
className="flex items-start gap-3 p-3 text-left border rounded-lg hover:border-indigo-500 hover:bg-indigo-50 transition-all"
|
||||
>
|
||||
<div className={`w-8 h-8 ${node.color} rounded-lg flex items-center justify-center text-white text-lg flex-shrink-0`}>
|
||||
{node.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900 text-sm">
|
||||
{node.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{node.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/* Info panel */}
|
||||
<Panel position="bottom-right" className="bg-white rounded-lg shadow-lg p-3 m-4 text-xs text-gray-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
<span>{nodes.length} modules connected</span>
|
||||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user