From 51babf770f59eb407a8bfdff68f21dc13ee37382 Mon Sep 17 00:00:00 2001 From: matthieu Date: Wed, 29 Oct 2025 16:28:42 +0100 Subject: [PATCH] [ISSUE-1] Add src/components/NodeCanvas.tsx - Modular NPC system --- src/components/NodeCanvas.tsx | 383 ++++++++++++++++++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 src/components/NodeCanvas.tsx diff --git a/src/components/NodeCanvas.tsx b/src/components/NodeCanvas.tsx new file mode 100644 index 0000000..33c9292 --- /dev/null +++ b/src/components/NodeCanvas.tsx @@ -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([]); + 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 ( +
+ + + + + + {/* Top control panel */} + +
+
+ +

Modular NPC Builder

+
+
+ +
+ + + {/* Node menu */} + {showNodeMenu && ( + +
+

Add Module

+ +
+
+ {nodeCategories.map((category) => ( +
+

+ {category.name} +

+
+ {category.nodes.map((node) => ( + + ))} +
+
+ ))} +
+
+ )} + + {/* Info panel */} + +
+
+ {nodes.length} modules connected +
+ + +
+ ); +}