diff --git a/MODULAR_ARCHITECTURE.md b/MODULAR_ARCHITECTURE.md new file mode 100644 index 0000000..4a2b9df --- /dev/null +++ b/MODULAR_ARCHITECTURE.md @@ -0,0 +1,401 @@ +# Modular NPC Builder - Architecture Documentation + +## Overview + +The Cobblemon NPC Creator has been refactored from a traditional tab-based form interface to a modern, modular node-based system inspired by tools like n8n, ComfyUI, and Unreal Engine's Blueprint system. This new approach provides a more intuitive, visual way to create and customize NPCs through connectable modules. + +## Why This Refactor? + +### Problems with the Old System +- Complex, multi-tab forms were overwhelming for users +- Hard to see the complete NPC configuration at a glance +- Difficult to understand relationships between different components +- Linear workflow didn't match the non-linear nature of NPC creation + +### Benefits of the New System +- Visual representation of NPC structure +- Drag-and-drop modular components +- Clear data flow through connections +- Easier to add/remove features on demand +- More scalable and extensible architecture +- Better separation of concerns + +## Technology Stack + +### Core Library: React Flow (@xyflow/react) + +**Why React Flow?** +- Production-ready, battle-tested in many applications +- Excellent TypeScript support +- Highly customizable node and edge rendering +- Built-in features: minimap, controls, background grid +- Great performance with many nodes +- Active development and community +- MIT licensed + +### Other Options Considered: +- **jsPlumb**: Older library, less React-friendly +- **Vue Flow**: Excellent but Vue-specific +- **React Diagrams**: Less maintained, fewer features +- **Rete.js**: Good but more complex API + +React Flow was chosen for its perfect balance of features, performance, and ease of use. + +## Architecture + +### File Structure + +``` +src/ +├── components/ +│ ├── NodeCanvas.tsx # Main workspace component +│ └── nodes/ # Individual node modules +│ ├── BasicInfoNode.tsx # Basic NPC settings module +│ ├── BattleConfigNode.tsx # Battle configuration module +│ ├── PartyNode.tsx # Pokemon party builder module +│ ├── InteractionNode.tsx # Interaction system module +│ ├── VariablesNode.tsx # MoLang variables module +│ └── OutputNode.tsx # JSON preview and export module +├── types/ +│ ├── npc.ts # Original NPC type definitions +│ └── nodes.ts # New node-based type definitions +└── App.tsx # Updated main app with modular view +``` + +### Module Types + +#### 1. BasicInfoNode (Blue) +**Purpose:** Configure core NPC properties +**Inputs:** None (root node) +**Outputs:** Basic info data +**Fields:** +- Resource Identifier +- Names (comma-separated) +- Model Scale +- Aspects +- Behavior flags (invulnerable, persistent, movable, etc.) + +#### 2. BattleConfigNode (Red) +**Purpose:** Set up battle-related configurations +**Inputs:** Connection from any module +**Outputs:** Battle config data +**Fields:** +- Can Battle / Can Challenge toggles +- Battle Theme +- Victory Theme +- Defeat Theme +- Simultaneous Battles option +- Heal After Battle option + +#### 3. PartyNode (Green) +**Purpose:** Configure Pokemon party +**Inputs:** Connection from any module +**Outputs:** Party data +**Fields:** +- Party Type selector (Simple/Pool/Script) +- Pokemon list with add/remove +- Static Party toggle +**Features:** +- Add/remove Pokemon +- Inline Pokemon string editing +- Support for different party provider types + +#### 4. InteractionNode (Purple) +**Purpose:** Define NPC interaction behavior +**Inputs:** Connection from any module +**Outputs:** Interaction data +**Fields:** +- Interaction Type (None/Dialogue/Script/Shop/Heal) +- Dialogue Reference (for dialogue type) +- Script Reference (for script type) + +#### 5. VariablesNode (Yellow) +**Purpose:** Manage MoLang configuration variables +**Inputs:** Connection from any module +**Outputs:** Variables data +**Fields:** +- Variable list with add/remove +- Per-variable configuration: + - Variable Name + - Display Name + - Type (Number/Text/Boolean) + - Default Value + +#### 6. OutputNode (Indigo) +**Purpose:** Preview and export final NPC JSON +**Inputs:** Collects data from all modules +**Outputs:** None (terminal node) +**Features:** +- NPC summary view +- Live JSON preview +- Download JSON button +- Character count display + +### Data Flow + +``` +BasicInfoNode ─┐ + ├─► OutputNode (JSON Preview) +BattleNode ────┤ + ├─► [Collects all module data] +PartyNode ─────┤ + ├─► [Generates final NPC config] +InteractionNode┤ + │ +VariablesNode ─┘ +``` + +### Key Components + +#### NodeCanvas.tsx +The main workspace component that: +- Manages the React Flow instance +- Handles node and edge state +- Provides "Add Module" functionality +- Aggregates node data into final NPC configuration +- Updates OutputNode preview in real-time +- Renders control panels and minimap + +**Key Features:** +- Drag-and-drop node positioning +- Visual connections between modules +- Module palette for adding new nodes +- Real-time configuration updates +- Responsive design + +#### Individual Node Components +Each node module follows a consistent pattern: +- Collapsible header with icon and color coding +- Input/output handles for connections +- Expandable/collapsible content area +- Form fields for configuration +- Real-time data updates + +**Design Principles:** +- Self-contained logic +- Minimal external dependencies +- Consistent UI patterns +- Color-coded by category +- Icon-based identification + +## Usage Patterns + +### Basic Workflow + +1. **Start with BasicInfoNode** (automatically created) + - Set NPC name and identifier + - Configure basic properties + +2. **Add Required Modules** via "Add Module" button + - Click desired module from palette + - Module appears on canvas + +3. **Configure Each Module** + - Click to expand/collapse + - Fill in relevant fields + - Data updates automatically + +4. **Connect Modules** (optional, visual only) + - Drag from output handle to input handle + - Creates visual relationship + +5. **Preview Output** in OutputNode + - View NPC summary + - Check JSON preview + - Download when ready + +### Adding New Module Types + +To add a new module type: + +1. **Define types** in `src/types/nodes.ts`: +```typescript +export interface NewModuleNodeData { + type: 'newModule'; + field1: string; + field2: number; +} +``` + +2. **Create component** in `src/components/nodes/NewModuleNode.tsx`: +```typescript +export const NewModuleNode = memo(({ data }: NodeProps) => { + // Implementation +}); +``` + +3. **Register in NodeCanvas.tsx**: +```typescript +const nodeTypes: NodeTypes = { + // ... existing types + newModule: NewModuleNode, +}; +``` + +4. **Add to palette** in NodeCanvas.tsx: +```typescript +{ + type: 'newModule', + label: 'New Module', + description: 'Description here', + color: 'bg-color-500', + icon: '🎨', +} +``` + +## State Management + +### Current Approach +- React Flow manages node positions and connections +- Each node stores its own data in `node.data` +- NodeCanvas aggregates node data into final NPC config +- Parent App.tsx maintains final state + +### Data Updates +- Node changes trigger immediate updates +- NodeCanvas effect listens for node changes +- Aggregates all node data into NPCConfiguration +- Updates OutputNode preview automatically +- Parent receives final config via callback + +## Extensibility + +### Future Enhancements + +1. **Dialogue Editor Module** + - Visual dialogue tree within a node + - Inline page editing + - Speaker management + +2. **Advanced Party Builder** + - Pokemon selector modal + - Visual Pokemon cards + - Type-based team generation + +3. **Quest Module** + - Quest objectives + - Reward configuration + - Quest chain visualization + +4. **AI Behavior Module** + - Pathfinding settings + - Custom behaviors + - Schedule configuration + +5. **Import/Export Module** + - Load from datapack + - Save workflow templates + - Share configurations + +### API Integration Opportunities +- Pokemon data from PokeAPI +- Cobblemon mod documentation links +- Community preset sharing +- Validation against mod version + +## Performance Considerations + +- React Flow handles virtualization automatically +- Memoized components prevent unnecessary re-renders +- Debounced updates for text inputs (can be added) +- Lazy loading for heavy modules (can be added) + +## Styling + +### Design System +- Tailwind CSS for utility-first styling +- Color-coded modules for easy identification: + - Blue: Core settings + - Red: Battle/Combat + - Green: Pokemon/Party + - Purple: Interactions + - Yellow: Configuration/Variables + - Indigo: Output/Export + +### Responsive Design +- Nodes have minimum widths +- Collapsible sections for small screens +- Touch-friendly controls +- Mobile-optimized panels + +## Testing Strategy + +### Manual Testing Checklist +- [ ] Can add all module types +- [ ] Can delete nodes +- [ ] Can connect nodes (visual) +- [ ] Can collapse/expand nodes +- [ ] Data persists when nodes are moved +- [ ] OutputNode shows correct preview +- [ ] Can download valid JSON +- [ ] All form fields work correctly +- [ ] Checkbox states persist +- [ ] Number inputs validate properly + +### Automated Testing (Future) +- Unit tests for node components +- Integration tests for data flow +- E2E tests for complete workflows +- Visual regression tests for UI + +## Migration Guide + +### For Users +- Old tab-based view is still accessible (Classic mode) +- Existing save files remain compatible +- New modular view is opt-in +- Can switch between modes + +### For Developers +- Old components still exist in codebase +- New modular system is additive +- Can maintain both systems +- Gradual migration path + +## Known Limitations + +1. **Connection Logic**: Connections are currently visual only and don't enforce data flow +2. **Undo/Redo**: Not yet implemented +3. **Copy/Paste Nodes**: Not yet implemented +4. **Node Templates**: Can't save/load node configurations +5. **Mobile Experience**: Best on desktop/tablet + +## Troubleshooting + +### Common Issues + +**Nodes not appearing:** +- Check if nodeTypes are registered in NodeCanvas +- Verify component import paths +- Check for TypeScript errors + +**Data not updating:** +- Ensure node data is being mutated correctly +- Check useEffect dependencies in NodeCanvas +- Verify onChange callbacks are called + +**Styling issues:** +- Ensure @xyflow/react styles are imported +- Check Tailwind CSS configuration +- Verify color classes exist + +## Contributing + +When adding new features: +1. Follow existing node component patterns +2. Use TypeScript for type safety +3. Add appropriate color coding +4. Include descriptions in module palette +5. Test with various configurations +6. Update this documentation + +## Resources + +- [React Flow Documentation](https://reactflow.dev/) +- [Cobblemon Mod Documentation](https://wiki.cobblemon.com/) +- [Tailwind CSS Documentation](https://tailwindcss.com/) +- [TypeScript Documentation](https://www.typescriptlang.org/) + +## Conclusion + +The modular node-based architecture provides a more intuitive and scalable approach to NPC creation. By leveraging React Flow's powerful features and maintaining clean separation of concerns, the system is both user-friendly and developer-friendly. The visual nature of the interface makes complex configurations more approachable while maintaining the full power of the underlying Cobblemon NPC system. diff --git a/package-lock.json b/package-lock.json index 95f8322..7c1b603 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@tailwindcss/forms": "^0.5.10", + "@xyflow/react": "^12.9.1", "file-saver": "^2.0.5", "jszip": "^3.10.1", "lucide-react": "^0.539.0", @@ -90,6 +91,7 @@ "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1444,6 +1446,55 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1468,8 +1519,9 @@ "version": "19.1.9", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz", "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1530,6 +1582,7 @@ "integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/types": "8.39.1", @@ -1776,12 +1829,45 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.9.1", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.9.1.tgz", + "integrity": "sha512-JRPCT5p7NnPdVSIh15AFvUSSm+8GUyz2I6iuBEC1LG2lKgig/L48AM/ImMHCc3ZUCg+AgTOJDaX2fcRyPA9BTA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.72", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.72", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.72.tgz", + "integrity": "sha512-WBI5Aau0fXTXwxHPzceLNS6QdXggSWnGjDtj/gG669crApN8+SCmEtkBth1m7r6pStNo/5fI9McEi7Dk0ymCLA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1974,6 +2060,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001733", "electron-to-chromium": "^1.5.199", @@ -2080,6 +2167,12 @@ "node": ">= 6" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2156,9 +2249,115 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -2191,7 +2390,6 @@ "dev": true, "license": "Apache-2.0", "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -2298,6 +2496,7 @@ "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -2896,18 +3095,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/jiti": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", - "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3018,37 +3205,6 @@ "immediate": "~3.0.5" } }, - "node_modules/lightningcss": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", - "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", - "dev": true, - "license": "MPL-2.0", - "optional": true, - "peer": true, - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-darwin-arm64": "1.30.1", - "lightningcss-darwin-x64": "1.30.1", - "lightningcss-freebsd-x64": "1.30.1", - "lightningcss-linux-arm-gnueabihf": "1.30.1", - "lightningcss-linux-arm64-gnu": "1.30.1", - "lightningcss-linux-arm64-musl": "1.30.1", - "lightningcss-linux-x64-gnu": "1.30.1", - "lightningcss-linux-x64-musl": "1.30.1", - "lightningcss-win32-arm64-msvc": "1.30.1", - "lightningcss-win32-x64-msvc": "1.30.1" - } - }, "node_modules/lightningcss-darwin-arm64": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", @@ -3062,7 +3218,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3084,7 +3239,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3106,7 +3260,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3128,7 +3281,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3150,7 +3302,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3172,7 +3323,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3194,7 +3344,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3216,7 +3365,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3238,7 +3386,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3260,7 +3407,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3645,6 +3791,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3819,6 +3966,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3828,6 +3976,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -4220,6 +4369,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -4320,6 +4470,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4377,6 +4528,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4450,6 +4602,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -4462,6 +4623,7 @@ "integrity": "sha512-yJ+Mp7OyV+4S+afWo+QyoL9jFWD11QFH0i5i7JypnfTcA1rmgxCbiA8WwAICDEtZ1Z1hzrVhN8R8rGTqkTY8ZQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", @@ -4552,6 +4714,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4703,6 +4866,34 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 1c12bc1..2bd2759 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@tailwindcss/forms": "^0.5.10", + "@xyflow/react": "^12.9.1", "file-saver": "^2.0.5", "jszip": "^3.10.1", "lucide-react": "^0.539.0", diff --git a/src/App.tsx b/src/App.tsx index 3e73d65..2cdd73b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,20 +1,11 @@ -import { useState, useEffect } from 'react'; -import { Settings, MessageSquare, Sword, Users, Code, FileText, Upload } from 'lucide-react'; -import type { NPCConfiguration, DialogueConfiguration } from './types/npc'; -import { NPCBasicSettings } from './components/NPCBasicSettings'; -import { NPCBattleConfiguration } from './components/NPCBattleConfiguration'; -import { NPCPartyBuilder } from './components/NPCPartyBuilder'; -import { NPCInteractionEditor } from './components/NPCInteractionEditor'; -import { ConfigVariablesEditor } from './components/ConfigVariablesEditor'; -import { DialogueEditor } from './components/DialogueEditor'; -import { JSONPreview } from './components/JSONPreview'; -import { ImportExport } from './components/ImportExport'; - -type Tab = 'basic' | 'battle' | 'party' | 'interaction' | 'variables' | 'dialogue' | 'preview' | 'import'; +import { useState } from 'react'; +import { Sparkles, Grid } from 'lucide-react'; +import type { NPCConfiguration } from './types/npc'; +import { NodeCanvas } from './components/NodeCanvas'; function App() { - const [activeTab, setActiveTab] = useState('basic'); - + const [viewMode, setViewMode] = useState<'modular' | 'classic'>('modular'); + const [npcConfig, setNpcConfig] = useState({ id: 'my_npc', name: 'My NPC', @@ -26,173 +17,74 @@ function App() { resourceIdentifier: 'cobblemon:my_npc' }); - const [dialogueConfig, setDialogueConfig] = useState(null); - - useEffect(() => { - if (npcConfig.interactions && npcConfig.interactions.length > 0 && npcConfig.interactions[0].type === 'dialogue' && !dialogueConfig) { - const newDialogue: DialogueConfiguration = { - speakers: { - npc: { - name: { type: 'expression', expression: 'q.npc.name' }, - face: 'q.npc.face(false);' - }, - player: { - name: { type: 'expression', expression: 'q.player.username' }, - face: 'q.player.face();' - } - }, - pages: [{ - id: 'greeting', - speaker: 'npc', - lines: ['Hello there!'], - input: 'q.dialogue.close();' - }] - }; - setDialogueConfig(newDialogue); - } - }, [npcConfig.interactions, dialogueConfig]); - - const tabs = [ - { id: 'basic', name: 'Basic Settings', icon: Settings }, - { id: 'battle', name: 'Battle Config', icon: Sword }, - { id: 'party', name: 'Pokemon Party', icon: Users }, - { id: 'interaction', name: 'Interaction', icon: MessageSquare }, - { id: 'variables', name: 'Variables', icon: Code }, - { id: 'dialogue', name: 'Dialogue', icon: MessageSquare }, - { id: 'preview', name: 'JSON Preview', icon: FileText }, - { id: 'import', name: 'Import/Export', icon: Upload } - ]; - - const renderActiveTab = () => { - switch (activeTab) { - case 'basic': - return ; - case 'battle': - return ; - case 'party': - return ; - case 'interaction': - return ; - case 'variables': - return ; - case 'dialogue': - return ; - case 'preview': - return ; - case 'import': - return ( - { - if (configs.length > 0) { - setNpcConfig(configs[0]); - } - }} - /> - ); - default: - return null; - } - }; - return (
{/* Header */} -
+
-
+

Cobblemon NPC Creator

+
+ + Modular Edition +
-
- Create and customize NPCs for your Cobblemon mod +
+
+ Build NPCs with connectable modules +
+
+ + +
-
-
- {/* Sidebar Navigation */} -
- - - {/* Quick Info Panel */} -
-

Current NPC

-
-
-
Name
-
{npcConfig.names[0] || 'Unnamed'}
-
-
-
Type
-
- {npcConfig.battleConfiguration?.canChallenge ? 'Trainer' : 'NPC'} -
-
-
-
Interaction
-
- {npcConfig.interactions && npcConfig.interactions.length > 0 - ? npcConfig.interactions[0].type - : 'none'} -
-
-
-
Variables
-
{npcConfig.config?.length || 0}
-
-
-
-
- - {/* Main Content */} -
-
-
- {renderActiveTab()} -
-
+ {/* Main Content - Full Screen Canvas */} + {viewMode === 'modular' ? ( + + ) : ( +
+
+

Classic View

+

Coming soon...

-
- - {/* Footer */} -
-
-

- Built for the Cobblemon Minecraft mod - Create amazing NPCs for your world! -

-
-
+ )}
); } diff --git a/src/components/NodeCanvas.tsx b/src/components/NodeCanvas.tsx new file mode 100644 index 0000000..49cb9a0 --- /dev/null +++ b/src/components/NodeCanvas.tsx @@ -0,0 +1,388 @@ +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(() => { + // Skip if no nodes yet + if (nodes.length === 0) return; + + 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; + } + } + }); + + // Update parent config + onConfigChange(newConfig); + + // Update output node preview with the new config + // Check if output node exists and if its preview data needs updating + const outputNode = nodes.find(n => n.data.type === 'output'); + if (outputNode && JSON.stringify(outputNode.data.previewData) !== JSON.stringify(newConfig)) { + setNodes((nds) => + nds.map((node) => + node.data.type === 'output' + ? { ...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 +
+ + +
+ ); +} diff --git a/src/components/nodes/BasicInfoNode.tsx b/src/components/nodes/BasicInfoNode.tsx new file mode 100644 index 0000000..082b614 --- /dev/null +++ b/src/components/nodes/BasicInfoNode.tsx @@ -0,0 +1,148 @@ +import { memo, useState } from 'react'; +import { Handle, Position } from '@xyflow/react'; +import type { NodeProps } from '@xyflow/react'; +import { Settings, ChevronDown, ChevronUp } from 'lucide-react'; +import type { BasicInfoNodeData } from '../../types/nodes'; + +export const BasicInfoNode = memo(({ data }: NodeProps) => { + const [isExpanded, setIsExpanded] = useState(true); + const nodeData = data as BasicInfoNodeData; + + const handleChange = (field: keyof BasicInfoNodeData, value: string | number | boolean | string[] | undefined | { width: number; height: number }) => { + // Update node data - in a real implementation, this would use a store or context + // For now, we'll use data mutation which React Flow handles + Object.assign(nodeData, { [field]: value }); + }; + + const handleNamesChange = (names: string) => { + handleChange('names', names.split(',').map(name => name.trim()).filter(name => name)); + }; + + const handleAspectsChange = (aspects: string) => { + handleChange('aspects', aspects ? aspects.split(',').map(aspect => aspect.trim()).filter(aspect => aspect) : undefined); + }; + + return ( +
+ {/* Node Header */} +
+
+ + Basic Info +
+ +
+ + {/* Node Content */} + {isExpanded && ( +
+
+ + handleChange('resourceIdentifier', e.target.value)} + className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="cobblemon:npc_name" + /> +
+ +
+ + handleNamesChange(e.target.value)} + className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="NPC Name 1, NPC Name 2" + /> +
+ +
+ + handleChange('modelScale', parseFloat(e.target.value))} + className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+ +
+ + handleAspectsChange(e.target.value)} + className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="aspect1, aspect2" + /> +
+ +
+
Behavior
+
+ + + + +
+
+
+ )} + + {/* Output Handle */} + +
+ ); +}); + +BasicInfoNode.displayName = 'BasicInfoNode'; diff --git a/src/components/nodes/BattleConfigNode.tsx b/src/components/nodes/BattleConfigNode.tsx new file mode 100644 index 0000000..1c691ee --- /dev/null +++ b/src/components/nodes/BattleConfigNode.tsx @@ -0,0 +1,137 @@ +import { memo, useState } from 'react'; +import { Handle, Position } from '@xyflow/react'; +import type { NodeProps } from '@xyflow/react'; +import { Sword, ChevronDown, ChevronUp } from 'lucide-react'; +import type { BattleConfigNodeData } from '../../types/nodes'; + +export const BattleConfigNode = memo(({ data }: NodeProps) => { + const [isExpanded, setIsExpanded] = useState(true); + const nodeData = data as BattleConfigNodeData; + + const handleChange = (field: keyof BattleConfigNodeData, value: string | boolean | undefined) => { + Object.assign(nodeData, { [field]: value }); + }; + + return ( +
+ {/* Node Header */} +
+
+ + Battle Configuration +
+ +
+ + {/* Input Handle */} + + + {/* Node Content */} + {isExpanded && ( +
+
+ + +
+ +
+ + handleChange('battleTheme', e.target.value)} + className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-red-500 focus:border-red-500" + placeholder="cobblemon:battle_theme" + /> +
+ +
+ + handleChange('victoryTheme', e.target.value)} + className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-red-500 focus:border-red-500" + placeholder="cobblemon:victory_theme" + /> +
+ +
+ + handleChange('defeatTheme', e.target.value)} + className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-red-500 focus:border-red-500" + placeholder="cobblemon:defeat_theme" + /> +
+ +
+
Options
+
+ + +
+
+
+ )} + + {/* Output Handle */} + +
+ ); +}); + +BattleConfigNode.displayName = 'BattleConfigNode'; diff --git a/src/components/nodes/InteractionNode.tsx b/src/components/nodes/InteractionNode.tsx new file mode 100644 index 0000000..b164103 --- /dev/null +++ b/src/components/nodes/InteractionNode.tsx @@ -0,0 +1,120 @@ +import { memo, useState } from 'react'; +import { Handle, Position } from '@xyflow/react'; +import type { NodeProps } from '@xyflow/react'; +import { MessageSquare, ChevronDown, ChevronUp } from 'lucide-react'; +import type { InteractionNodeData } from '../../types/nodes'; + +export const InteractionNode = memo(({ data }: NodeProps) => { + const [isExpanded, setIsExpanded] = useState(true); + const nodeData = data as InteractionNodeData; + + const handleChange = (field: keyof InteractionNodeData, value: string | undefined) => { + Object.assign(nodeData, { [field]: value }); + }; + + return ( +
+ {/* Node Header */} +
+
+ + Interaction +
+ +
+ + {/* Input Handle */} + + + {/* Node Content */} + {isExpanded && ( +
+
+ + +
+ + {nodeData.interactionType === 'dialogue' && ( +
+ + handleChange('dialogueReference', e.target.value)} + className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-purple-500 focus:border-purple-500" + placeholder="cobblemon:my_dialogue" + /> +

+ Reference to a dialogue configuration +

+
+ )} + + {nodeData.interactionType === 'script' && ( +
+ + handleChange('scriptReference', e.target.value)} + className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-purple-500 focus:border-purple-500" + placeholder="cobblemon:interaction_script" + /> +

+ MoLang script for custom interactions +

+
+ )} + + {nodeData.interactionType === 'none' && ( +
+ No interaction configured +
+ )} + + {(nodeData.interactionType === 'shop' || nodeData.interactionType === 'heal') && ( +
+ {nodeData.interactionType === 'shop' + ? 'Shop interaction - configure items in data' + : 'Heal interaction - heals player Pokemon'} +
+ )} +
+ )} + + {/* Output Handle */} + +
+ ); +}); + +InteractionNode.displayName = 'InteractionNode'; diff --git a/src/components/nodes/OutputNode.tsx b/src/components/nodes/OutputNode.tsx new file mode 100644 index 0000000..c1e9476 --- /dev/null +++ b/src/components/nodes/OutputNode.tsx @@ -0,0 +1,124 @@ +import { memo, useState } from 'react'; +import { Handle, Position } from '@xyflow/react'; +import type { NodeProps } from '@xyflow/react'; +import { FileText, ChevronDown, ChevronUp, Download } from 'lucide-react'; +import type { OutputNodeData } from '../../types/nodes'; + +export const OutputNode = memo(({ data }: NodeProps) => { + const [isExpanded, setIsExpanded] = useState(true); + const nodeData = data as OutputNodeData; + + const downloadJSON = () => { + if (!nodeData.previewData) return; + + const jsonString = JSON.stringify(nodeData.previewData, null, 2); + const blob = new Blob([jsonString], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${nodeData.previewData.id || 'npc'}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + return ( +
+ {/* Node Header */} +
+
+ + Output Preview +
+
+ + +
+
+ + {/* Input Handle */} + + + {/* Node Content */} + {isExpanded && ( +
+
+

NPC Summary

+
+
+
Name:
+
+ {nodeData.previewData?.names?.[0] || 'Unnamed'} +
+
+
+
ID:
+
+ {nodeData.previewData?.id || 'N/A'} +
+
+
+
Battle:
+
+ {nodeData.previewData?.battleConfiguration?.canChallenge + ? 'Enabled' + : 'Disabled'} +
+
+
+
Party:
+
+ {nodeData.previewData?.party?.type || 'None'} +
+
+
+
Variables:
+
+ {nodeData.previewData?.config?.length || 0} +
+
+
+
+ +
+
+

JSON Preview

+ + {JSON.stringify(nodeData.previewData).length} chars + +
+
+              {JSON.stringify(nodeData.previewData, null, 2)}
+            
+
+ + +
+ )} +
+ ); +}); + +OutputNode.displayName = 'OutputNode'; diff --git a/src/components/nodes/PartyNode.tsx b/src/components/nodes/PartyNode.tsx new file mode 100644 index 0000000..2b495e1 --- /dev/null +++ b/src/components/nodes/PartyNode.tsx @@ -0,0 +1,184 @@ +import { memo, useState } from 'react'; +import { Handle, Position } from '@xyflow/react'; +import type { NodeProps } from '@xyflow/react'; +import { Users, Plus, Trash2, ChevronDown, ChevronUp } from 'lucide-react'; +import type { PartyNodeData } from '../../types/nodes'; +import type { SimplePartyProvider, PartyProvider } from '../../types/npc'; + +export const PartyNode = memo(({ data }: NodeProps) => { + const [isExpanded, setIsExpanded] = useState(true); + const nodeData = data as PartyNodeData; + + const handleChange = (newPartyConfig: PartyProvider) => { + Object.assign(nodeData, { partyConfig: newPartyConfig }); + }; + + const renderSimpleParty = () => { + const party = nodeData.partyConfig as SimplePartyProvider; + + if (!party || !party.pokemon) { + return null; + } + + const addPokemon = () => { + handleChange({ + ...party, + pokemon: [...party.pokemon, ''], + }); + }; + + const removePokemon = (index: number) => { + handleChange({ + ...party, + pokemon: party.pokemon.filter((_, i) => i !== index), + }); + }; + + const updatePokemon = (index: number, value: string) => { + const newPokemon = [...party.pokemon]; + newPokemon[index] = value; + handleChange({ + ...party, + pokemon: newPokemon, + }); + }; + + return ( +
+
+ + Pokemon ({party.pokemon.length}/6) + + +
+ +
+ {party.pokemon.map((pokemon, index) => ( +
+ updatePokemon(index, e.target.value)} + className="flex-1 px-2 py-1 text-xs border border-gray-300 rounded focus:ring-2 focus:ring-green-500 focus:border-green-500" + placeholder="pikachu level=50 shiny=true" + /> + +
+ ))} +
+ + +
+ ); + }; + + return ( +
+ {/* Node Header */} +
+
+ + Pokemon Party +
+ +
+ + {/* Input Handle */} + + + {/* Node Content */} + {isExpanded && ( +
+
+ + +
+ + {nodeData.partyConfig?.type === 'simple' && renderSimpleParty()} + + {nodeData.partyConfig?.type === 'pool' && ( +
+ Pool configuration available - add Pokemon to the pool +
+ )} + + {nodeData.partyConfig?.type === 'script' && ( +
+ + + handleChange({ type: 'script', script: e.target.value }) + } + className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-green-500 focus:border-green-500" + placeholder="cobblemon:party_script" + /> +
+ )} +
+ )} + + {/* Output Handle */} + +
+ ); +}); + +PartyNode.displayName = 'PartyNode'; diff --git a/src/components/nodes/VariablesNode.tsx b/src/components/nodes/VariablesNode.tsx new file mode 100644 index 0000000..a13f2c0 --- /dev/null +++ b/src/components/nodes/VariablesNode.tsx @@ -0,0 +1,211 @@ +import { memo, useState } from 'react'; +import { Handle, Position } from '@xyflow/react'; +import type { NodeProps } from '@xyflow/react'; +import { Code, Plus, Trash2, ChevronDown, ChevronUp } from 'lucide-react'; +import type { VariablesNodeData } from '../../types/nodes'; +import type { MoLangConfigVariable } from '../../types/npc'; + +export const VariablesNode = memo(({ data }: NodeProps) => { + const [isExpanded, setIsExpanded] = useState(true); + const nodeData = data as VariablesNodeData; + + const handleChange = (variables: MoLangConfigVariable[]) => { + Object.assign(nodeData, { configVariables: variables }); + }; + + const addVariable = () => { + const newVariable: MoLangConfigVariable = { + variableName: `var_${Date.now()}`, + displayName: 'New Variable', + description: 'Description', + type: 'NUMBER', + defaultValue: 0, + }; + handleChange([...(nodeData.configVariables || []), newVariable]); + }; + + const removeVariable = (index: number) => { + const newVariables = [...(nodeData.configVariables || [])]; + newVariables.splice(index, 1); + handleChange(newVariables); + }; + + const updateVariable = ( + index: number, + field: keyof MoLangConfigVariable, + value: string | number | boolean + ) => { + const newVariables = [...(nodeData.configVariables || [])]; + newVariables[index] = { ...newVariables[index], [field]: value }; + handleChange(newVariables); + }; + + return ( +
+ {/* Node Header */} +
+
+ + Config Variables +
+ +
+ + {/* Input Handle */} + + + {/* Node Content */} + {isExpanded && ( +
+
+ + Variables ({nodeData.configVariables?.length || 0}) + + +
+ +
+ {(!nodeData.configVariables || nodeData.configVariables.length === 0) && ( +
+ No variables defined +
+ )} + + {nodeData.configVariables?.map((variable, index) => ( +
+
+ + Variable {index + 1} + + +
+ +
+ + + updateVariable(index, 'variableName', e.target.value) + } + className="w-full px-2 py-1 text-xs border border-gray-300 rounded focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500" + placeholder="my_variable" + /> +
+ +
+ + + updateVariable(index, 'displayName', e.target.value) + } + className="w-full px-2 py-1 text-xs border border-gray-300 rounded focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500" + placeholder="My Variable" + /> +
+ +
+ + +
+ +
+ + {variable.type === 'BOOLEAN' ? ( + + ) : ( + + updateVariable( + index, + 'defaultValue', + variable.type === 'NUMBER' + ? parseFloat(e.target.value) || 0 + : e.target.value + ) + } + className="w-full px-2 py-1 text-xs border border-gray-300 rounded focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500" + /> + )} +
+
+ ))} +
+
+ )} + + {/* Output Handle */} + +
+ ); +}); + +VariablesNode.displayName = 'VariablesNode'; diff --git a/src/types/nodes.ts b/src/types/nodes.ts new file mode 100644 index 0000000..39e0eca --- /dev/null +++ b/src/types/nodes.ts @@ -0,0 +1,98 @@ +import type { Node, Edge } from '@xyflow/react'; +import type { NPCConfiguration, DialogueConfiguration } from './npc'; + +// Define the types of nodes available in the modular system +export type NodeType = + | 'basicInfo' + | 'battleConfig' + | 'party' + | 'interaction' + | 'variables' + | 'dialogue' + | 'output'; + +// Data structure for each node type +export interface BasicInfoNodeData extends Record { + type: 'basicInfo'; + resourceIdentifier: string; + names: string[]; + hitbox: string | { width: number; height: number }; + modelScale?: number; + aspects?: string[]; + isInvulnerable?: boolean; + canDespawn?: boolean; + isMovable?: boolean; + isLeashable?: boolean; + allowProjectileHits?: boolean; + hideNameTag?: boolean; +} + +export interface BattleConfigNodeData extends Record { + type: 'battleConfig'; + canBattle?: boolean; + canChallenge?: boolean; + battleTheme?: string; + victoryTheme?: string; + defeatTheme?: string; + simultaneousBattles?: boolean; + healAfterwards?: boolean; +} + +export interface PartyNodeData extends Record { + type: 'party'; + partyConfig: NPCConfiguration['party']; +} + +export interface InteractionNodeData extends Record { + type: 'interaction'; + interactionType: string; + interactionData?: Record; + dialogueReference?: string; + scriptReference?: string; +} + +export interface VariablesNodeData extends Record { + type: 'variables'; + configVariables: NPCConfiguration['config']; +} + +export interface DialogueNodeData extends Record { + type: 'dialogue'; + dialogueConfig: DialogueConfiguration | null; +} + +export interface OutputNodeData extends Record { + type: 'output'; + previewData?: NPCConfiguration; +} + +// Union type for all node data types +export type ModularNodeData = + | BasicInfoNodeData + | BattleConfigNodeData + | PartyNodeData + | InteractionNodeData + | VariablesNodeData + | DialogueNodeData + | OutputNodeData; + +// Extended Node type with our custom data +export type ModularNode = Node; + +// Workflow state that contains all nodes and edges +export interface WorkflowState { + nodes: ModularNode[]; + edges: Edge[]; + npcConfig: NPCConfiguration; + dialogueConfig: DialogueConfiguration | null; +} + +// Node template for creating new nodes +export interface NodeTemplate { + type: NodeType; + label: string; + icon: string; + description: string; + color: string; + defaultData: Partial; +}