[ISSUE-1] Refactor NPC forms to modular node-based system #2

Open
matthieu wants to merge 12 commits from feature/ISSUE-1-modular-npc-forms into trunk
Showing only changes of commit 51babf770f - Show all commits

View 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>
);
}