[ISSUE-1] Add src/components/NodeCanvas.tsx - Modular NPC system
This commit is contained in:
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