feat: Add Visual Scripting system with node-based editor
Implements a complete visual scripting system using React Flow: Node System: - 30+ node types across 5 categories (Events, Logic, Actions, Data, References) - Event nodes: OnPlayerJoin, OnPartTouch, OnKeyPress, OnTimer, etc. - Logic nodes: If/Else, For Loop, While, Wait, ForEach - Action nodes: Print, SetProperty, CreatePart, Destroy, PlaySound, Tween - Data nodes: Number, String, Boolean, Vector3, Color, Math, Compare, Random - Reference nodes: GetPlayer, GetAllPlayers, FindChild, Workspace, GetService Code Generation: - Converts node graphs to platform-specific code - Supports Roblox (Lua), UEFN (Verse), and Spatial (TypeScript) - Validation with error/warning detection - Template-based code generation with proper nesting UI Features: - Drag-and-drop node palette with search - Category-based node organization with icons - Custom node rendering with input fields - Connection type validation (flow, data types) - Undo/redo history - MiniMap and controls - Code preview dialog with copy functionality State Management: - Zustand store with persistence - Auto-save to localStorage
This commit is contained in:
parent
4fa6d0c3ed
commit
6aff5ac183
9 changed files with 3120 additions and 30 deletions
716
package-lock.json
generated
716
package-lock.json
generated
|
|
@ -29,10 +29,15 @@
|
|||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@reactflow/background": "^11.3.14",
|
||||
"@reactflow/controls": "^11.2.14",
|
||||
"@reactflow/minimap": "^11.7.14",
|
||||
"@reactflow/node-toolbar": "^1.3.14",
|
||||
"@sentry/browser": "^10.34.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.15.0",
|
||||
"immer": "^11.1.3",
|
||||
"lucide-react": "^0.462.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"next": "^14.2.35",
|
||||
|
|
@ -42,11 +47,12 @@
|
|||
"react-dom": "^18.3.1",
|
||||
"react-error-boundary": "^6.1.0",
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"reactflow": "^11.11.4",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zustand": "^5.0.3"
|
||||
"zustand": "^5.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
|
|
@ -170,7 +176,6 @@
|
|||
"version": "7.28.6",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
|
|
@ -552,7 +557,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
|
|
@ -592,7 +596,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -1511,7 +1514,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
|
|
@ -3080,6 +3082,276 @@
|
|||
"version": "1.1.1",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@reactflow/background": {
|
||||
"version": "11.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
|
||||
"integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.11.4",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/background/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
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/controls": {
|
||||
"version": "11.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz",
|
||||
"integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.11.4",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/controls/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
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/core": {
|
||||
"version": "11.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz",
|
||||
"integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3": "^7.4.0",
|
||||
"@types/d3-drag": "^3.0.1",
|
||||
"@types/d3-selection": "^3.0.3",
|
||||
"@types/d3-zoom": "^3.0.1",
|
||||
"classcat": "^5.0.3",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/core/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
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/minimap": {
|
||||
"version": "11.7.14",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz",
|
||||
"integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.11.4",
|
||||
"@types/d3-selection": "^3.0.3",
|
||||
"@types/d3-zoom": "^3.0.1",
|
||||
"classcat": "^5.0.3",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/minimap/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
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/node-resizer": {
|
||||
"version": "2.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz",
|
||||
"integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.11.4",
|
||||
"classcat": "^5.0.4",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/node-resizer/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
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/node-toolbar": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz",
|
||||
"integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.11.4",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/node-toolbar/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
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.53",
|
||||
"dev": true,
|
||||
|
|
@ -3556,6 +3828,7 @@
|
|||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
|
|
@ -3647,7 +3920,8 @@
|
|||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
|
|
@ -3751,6 +4025,259 @@
|
|||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
||||
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "*",
|
||||
"@types/d3-axis": "*",
|
||||
"@types/d3-brush": "*",
|
||||
"@types/d3-chord": "*",
|
||||
"@types/d3-color": "*",
|
||||
"@types/d3-contour": "*",
|
||||
"@types/d3-delaunay": "*",
|
||||
"@types/d3-dispatch": "*",
|
||||
"@types/d3-drag": "*",
|
||||
"@types/d3-dsv": "*",
|
||||
"@types/d3-ease": "*",
|
||||
"@types/d3-fetch": "*",
|
||||
"@types/d3-force": "*",
|
||||
"@types/d3-format": "*",
|
||||
"@types/d3-geo": "*",
|
||||
"@types/d3-hierarchy": "*",
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-path": "*",
|
||||
"@types/d3-polygon": "*",
|
||||
"@types/d3-quadtree": "*",
|
||||
"@types/d3-random": "*",
|
||||
"@types/d3-scale": "*",
|
||||
"@types/d3-scale-chromatic": "*",
|
||||
"@types/d3-selection": "*",
|
||||
"@types/d3-shape": "*",
|
||||
"@types/d3-time": "*",
|
||||
"@types/d3-time-format": "*",
|
||||
"@types/d3-timer": "*",
|
||||
"@types/d3-transition": "*",
|
||||
"@types/d3-zoom": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-axis": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
|
||||
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-brush": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
|
||||
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-chord": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
|
||||
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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-contour": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
|
||||
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "*",
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-delaunay": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-dispatch": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
|
||||
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
|
||||
"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-dsv": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
|
||||
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-fetch": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
|
||||
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-dsv": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-force": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
|
||||
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-format": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
|
||||
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-geo": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-hierarchy": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
|
||||
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-polygon": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
|
||||
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-quadtree": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
|
||||
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-random": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
|
||||
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-scale-chromatic": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-time-format": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
|
||||
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"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/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"dev": true,
|
||||
|
|
@ -3761,6 +4288,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json5": {
|
||||
"version": "0.0.29",
|
||||
"dev": true,
|
||||
|
|
@ -3782,7 +4315,6 @@
|
|||
"version": "18.3.27",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
|
|
@ -3792,7 +4324,6 @@
|
|||
"version": "18.3.7",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
|
|
@ -3806,7 +4337,6 @@
|
|||
"version": "8.53.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.53.0",
|
||||
"@typescript-eslint/types": "8.53.0",
|
||||
|
|
@ -4412,7 +4942,6 @@
|
|||
"version": "8.15.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -5288,7 +5817,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
|
|
@ -5456,6 +5984,12 @@
|
|||
"url": "https://polar.sh/cva"
|
||||
}
|
||||
},
|
||||
"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/client-only": {
|
||||
"version": "0.0.1",
|
||||
"license": "MIT"
|
||||
|
|
@ -5579,6 +6113,111 @@
|
|||
"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",
|
||||
"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/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"dev": true,
|
||||
|
|
@ -5705,6 +6344,7 @@
|
|||
"version": "2.0.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
|
|
@ -5735,7 +6375,8 @@
|
|||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.1",
|
||||
|
|
@ -6054,7 +6695,6 @@
|
|||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
|
|
@ -6289,7 +6929,6 @@
|
|||
"version": "2.32.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
|
|
@ -7034,6 +7673,16 @@
|
|||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "11.1.3",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
|
||||
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"dev": true,
|
||||
|
|
@ -7525,7 +8174,6 @@
|
|||
"node_modules/jiti": {
|
||||
"version": "1.21.7",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
|
|
@ -7551,7 +8199,6 @@
|
|||
"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.28",
|
||||
"@asamuzakjp/dom-selector": "^6.7.6",
|
||||
|
|
@ -7741,6 +8388,7 @@
|
|||
"version": "1.5.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
|
|
@ -7825,8 +8473,7 @@
|
|||
"version": "0.52.2",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
|
||||
"integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "11.18.1",
|
||||
|
|
@ -8438,7 +9085,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
|
|
@ -8605,6 +9251,7 @@
|
|||
"version": "27.5.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
|
|
@ -8618,6 +9265,7 @@
|
|||
"version": "5.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
|
|
@ -8695,7 +9343,6 @@
|
|||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
|
|
@ -8706,7 +9353,6 @@
|
|||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
|
|
@ -8725,7 +9371,8 @@
|
|||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.18.0",
|
||||
|
|
@ -8806,6 +9453,24 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/reactflow": {
|
||||
"version": "11.11.4",
|
||||
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz",
|
||||
"integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/background": "11.3.14",
|
||||
"@reactflow/controls": "11.2.14",
|
||||
"@reactflow/core": "11.11.4",
|
||||
"@reactflow/minimap": "11.7.14",
|
||||
"@reactflow/node-resizer": "2.2.14",
|
||||
"@reactflow/node-toolbar": "1.3.14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
|
|
@ -9956,7 +10621,6 @@
|
|||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
|
|
@ -10063,7 +10727,6 @@
|
|||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -10266,7 +10929,6 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -10420,7 +11082,6 @@
|
|||
"version": "7.3.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
|
|
@ -10510,7 +11171,6 @@
|
|||
"version": "4.0.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -10914,6 +11574,8 @@
|
|||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.10",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz",
|
||||
"integrity": "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
|
|
|
|||
|
|
@ -34,10 +34,15 @@
|
|||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@reactflow/background": "^11.3.14",
|
||||
"@reactflow/controls": "^11.2.14",
|
||||
"@reactflow/minimap": "^11.7.14",
|
||||
"@reactflow/node-toolbar": "^1.3.14",
|
||||
"@sentry/browser": "^10.34.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.15.0",
|
||||
"immer": "^11.1.3",
|
||||
"lucide-react": "^0.462.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"next": "^14.2.35",
|
||||
|
|
@ -47,11 +52,12 @@
|
|||
"react-dom": "^18.3.1",
|
||||
"react-error-boundary": "^6.1.0",
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"reactflow": "^11.11.4",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zustand": "^5.0.3"
|
||||
"zustand": "^5.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
|
|
|
|||
29
src/App.tsx
29
src/App.tsx
|
|
@ -10,6 +10,7 @@ import { FileSearchModal } from './components/FileSearchModal';
|
|||
import { SearchInFilesPanel } from './components/SearchInFilesPanel';
|
||||
import { CommandPalette, createDefaultCommands } from './components/CommandPalette';
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from './components/ui/resizable';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './components/ui/dialog';
|
||||
import { useIsMobile } from './hooks/use-mobile';
|
||||
import { useKeyboardShortcuts } from './hooks/use-keyboard-shortcuts';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './components/ui/tabs';
|
||||
|
|
@ -32,6 +33,7 @@ const EducationPanel = lazy(() => import('./components/EducationPanel').then(m =
|
|||
const PassportLogin = lazy(() => import('./components/PassportLogin').then(m => ({ default: m.PassportLogin })));
|
||||
const TranslationPanel = lazy(() => import('./components/TranslationPanel').then(m => ({ default: m.TranslationPanel })));
|
||||
const AvatarToolkit = lazy(() => import('./components/AvatarToolkit'));
|
||||
const VisualScriptingCanvas = lazy(() => import('./components/visual-scripting/VisualScriptingCanvas'));
|
||||
|
||||
function App() {
|
||||
const [currentCode, setCurrentCode] = useState('');
|
||||
|
|
@ -43,6 +45,7 @@ function App() {
|
|||
const [showSearchInFiles, setShowSearchInFiles] = useState(false);
|
||||
const [showTranslation, setShowTranslation] = useState(false);
|
||||
const [showAvatarToolkit, setShowAvatarToolkit] = useState(false);
|
||||
const [showVisualScripting, setShowVisualScripting] = useState(false);
|
||||
const [code, setCode] = useState('');
|
||||
const [currentPlatform, setCurrentPlatform] = useState<PlatformId>('roblox');
|
||||
const isMobile = useIsMobile();
|
||||
|
|
@ -479,6 +482,7 @@ end)`,
|
|||
onPreviewClick={() => setShowPreview(true)}
|
||||
onNewProjectClick={() => setShowNewProject(true)}
|
||||
onAvatarToolkitClick={() => setShowAvatarToolkit(true)}
|
||||
onVisualScriptingClick={() => setShowVisualScripting(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -599,6 +603,31 @@ end)`,
|
|||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
{showVisualScripting && (
|
||||
<Dialog open={showVisualScripting} onOpenChange={setShowVisualScripting}>
|
||||
<DialogContent className="max-w-[95vw] max-h-[90vh] w-full h-[85vh] p-0">
|
||||
<DialogHeader className="px-4 py-2 border-b">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<svg className="h-5 w-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Visual Scripting
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 min-h-0">
|
||||
<VisualScriptingCanvas
|
||||
platform={currentPlatform === 'uefn' ? 'uefn' : currentPlatform === 'spatial' ? 'spatial' : 'roblox'}
|
||||
onCodeGenerated={(code) => {
|
||||
setCurrentCode(code);
|
||||
handleCodeChange(code);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<WelcomeDialog />
|
||||
</Suspense>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Copy, FileCode, Download, Info, Play, FolderPlus, User, SignOut, List, ArrowsLeftRight, UserCircle } from '@phosphor-icons/react';
|
||||
import { Copy, FileCode, Download, Info, Play, FolderPlus, User, SignOut, List, ArrowsLeftRight, UserCircle, GitBranch } from '@phosphor-icons/react';
|
||||
import { toast } from 'sonner';
|
||||
import { useState, useEffect, useCallback, memo } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
|
|
@ -25,9 +25,10 @@ interface ToolbarProps {
|
|||
onPlatformChange: (platform: PlatformId) => void;
|
||||
onTranslateClick?: () => void;
|
||||
onAvatarToolkitClick?: () => void;
|
||||
onVisualScriptingClick?: () => void;
|
||||
}
|
||||
|
||||
export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectClick, currentPlatform, onPlatformChange, onTranslateClick, onAvatarToolkitClick }: ToolbarProps) {
|
||||
export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectClick, currentPlatform, onPlatformChange, onTranslateClick, onAvatarToolkitClick, onVisualScriptingClick }: ToolbarProps) {
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const [user, setUser] = useState<{ login: string; avatarUrl: string; email: string } | null>(null);
|
||||
|
||||
|
|
@ -118,6 +119,25 @@ export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectCl
|
|||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Visual Scripting Button */}
|
||||
{onVisualScriptingClick && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onVisualScriptingClick}
|
||||
className="h-8 px-3 text-xs gap-1"
|
||||
aria-label="Visual Scripting"
|
||||
>
|
||||
<GitBranch size={14} />
|
||||
<span>Visual</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Visual Scripting (Node Editor)</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div className="h-6 w-px bg-border mx-1" />
|
||||
|
||||
<Tooltip>
|
||||
|
|
@ -238,6 +258,12 @@ export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectCl
|
|||
<span>Avatar Toolkit</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onVisualScriptingClick && (
|
||||
<DropdownMenuItem onClick={onVisualScriptingClick}>
|
||||
<GitBranch className="mr-2" size={16} />
|
||||
<span>Visual Scripting</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleCopy}>
|
||||
<Copy className="mr-2" size={16} />
|
||||
<span>Copy Code</span>
|
||||
|
|
|
|||
447
src/components/visual-scripting/VisualScriptingCanvas.tsx
Normal file
447
src/components/visual-scripting/VisualScriptingCanvas.tsx
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useRef, useState, useMemo } from 'react';
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
Node,
|
||||
Edge,
|
||||
Connection,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
Panel,
|
||||
BackgroundVariant,
|
||||
} from 'reactflow';
|
||||
import 'reactflow/dist/style.css';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Play,
|
||||
Code,
|
||||
Trash2,
|
||||
Undo,
|
||||
Redo,
|
||||
Save,
|
||||
Download,
|
||||
Upload,
|
||||
Search,
|
||||
Zap,
|
||||
GitBranch,
|
||||
Box,
|
||||
Hash,
|
||||
Globe,
|
||||
Settings,
|
||||
Copy,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useVisualScriptStore } from '@/stores/visual-script-store';
|
||||
import {
|
||||
ALL_NODES,
|
||||
CATEGORY_COLORS,
|
||||
NodeCategory,
|
||||
NodeDefinition,
|
||||
getNodesByCategory,
|
||||
} from '@/lib/visual-scripting/node-definitions';
|
||||
import {
|
||||
generateCode,
|
||||
validateScript,
|
||||
Platform,
|
||||
NodeData,
|
||||
} from '@/lib/visual-scripting/code-generator';
|
||||
import { CustomNode } from './nodes/CustomNode';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Custom node types
|
||||
const nodeTypes = {
|
||||
custom: CustomNode,
|
||||
};
|
||||
|
||||
// Category icons
|
||||
const CATEGORY_ICONS: Record<NodeCategory, React.ReactNode> = {
|
||||
events: <Zap className="h-4 w-4" />,
|
||||
logic: <GitBranch className="h-4 w-4" />,
|
||||
actions: <Play className="h-4 w-4" />,
|
||||
data: <Hash className="h-4 w-4" />,
|
||||
references: <Globe className="h-4 w-4" />,
|
||||
custom: <Settings className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
interface VisualScriptingCanvasProps {
|
||||
platform: Platform;
|
||||
onCodeGenerated?: (code: string) => void;
|
||||
}
|
||||
|
||||
function VisualScriptingCanvasInner({
|
||||
platform,
|
||||
onCodeGenerated,
|
||||
}: VisualScriptingCanvasProps) {
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
onNodesChange,
|
||||
onEdgesChange,
|
||||
onConnect,
|
||||
addNode,
|
||||
clearScript,
|
||||
undo,
|
||||
redo,
|
||||
setGeneratedCode,
|
||||
generatedCode,
|
||||
history,
|
||||
historyIndex,
|
||||
} = useVisualScriptStore();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showCodePreview, setShowCodePreview] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<{
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
} | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Filter nodes by search and category
|
||||
const filteredNodes = useMemo(() => {
|
||||
if (!searchQuery) return ALL_NODES;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return ALL_NODES.filter(
|
||||
(n) =>
|
||||
n.label.toLowerCase().includes(query) ||
|
||||
n.description.toLowerCase().includes(query) ||
|
||||
n.category.toLowerCase().includes(query)
|
||||
);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Handle node drop from palette
|
||||
const onDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const nodeType = event.dataTransfer.getData('application/reactflow');
|
||||
if (!nodeType) return;
|
||||
|
||||
const nodeDef = ALL_NODES.find((n) => n.type === nodeType);
|
||||
if (!nodeDef) return;
|
||||
|
||||
const position = screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
const newNode: Node<NodeData> = {
|
||||
id: `${nodeType}-${Date.now()}`,
|
||||
type: 'custom',
|
||||
position,
|
||||
data: {
|
||||
type: nodeType,
|
||||
label: nodeDef.label,
|
||||
values: {},
|
||||
},
|
||||
};
|
||||
|
||||
addNode(newNode);
|
||||
},
|
||||
[screenToFlowPosition, addNode]
|
||||
);
|
||||
|
||||
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}, []);
|
||||
|
||||
// Generate code from nodes
|
||||
const handleGenerateCode = useCallback(() => {
|
||||
const result = generateCode(nodes, edges, platform);
|
||||
setGeneratedCode(result.code);
|
||||
onCodeGenerated?.(result.code);
|
||||
|
||||
const validation = validateScript(nodes, edges, platform);
|
||||
setValidationResult(validation);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
toast.error(`Generation errors: ${result.errors.join(', ')}`);
|
||||
} else if (result.warnings.length > 0) {
|
||||
toast.warning(`Warnings: ${result.warnings.join(', ')}`);
|
||||
} else {
|
||||
toast.success('Code generated successfully!');
|
||||
}
|
||||
|
||||
setShowCodePreview(true);
|
||||
}, [nodes, edges, platform, setGeneratedCode, onCodeGenerated]);
|
||||
|
||||
// Copy code to clipboard
|
||||
const handleCopyCode = useCallback(async () => {
|
||||
await navigator.clipboard.writeText(generatedCode);
|
||||
setCopied(true);
|
||||
toast.success('Code copied to clipboard!');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [generatedCode]);
|
||||
|
||||
// Drag start from palette
|
||||
const onDragStart = (event: React.DragEvent, nodeType: string) => {
|
||||
event.dataTransfer.setData('application/reactflow', nodeType);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const categories: NodeCategory[] = [
|
||||
'events',
|
||||
'logic',
|
||||
'actions',
|
||||
'data',
|
||||
'references',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
{/* Node Palette (Left Panel) */}
|
||||
<div className="w-64 border-r bg-card flex flex-col">
|
||||
<div className="p-3 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search nodes..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="events" className="flex-1 flex flex-col">
|
||||
<TabsList className="grid grid-cols-5 mx-2 mt-2">
|
||||
{categories.map((cat) => (
|
||||
<TabsTrigger
|
||||
key={cat}
|
||||
value={cat}
|
||||
className="px-2"
|
||||
title={cat.charAt(0).toUpperCase() + cat.slice(1)}
|
||||
>
|
||||
{CATEGORY_ICONS[cat]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
{categories.map((category) => (
|
||||
<TabsContent key={category} value={category} className="mt-0 p-2">
|
||||
<div className="space-y-1">
|
||||
{(searchQuery
|
||||
? filteredNodes.filter((n) => n.category === category)
|
||||
: getNodesByCategory(category)
|
||||
)
|
||||
.filter((n) => n.platforms.includes(platform))
|
||||
.map((nodeDef) => (
|
||||
<NodePaletteItem
|
||||
key={nodeDef.type}
|
||||
node={nodeDef}
|
||||
onDragStart={onDragStart}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Main Canvas */}
|
||||
<div ref={reactFlowWrapper} className="flex-1 relative">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
snapToGrid
|
||||
snapGrid={[15, 15]}
|
||||
defaultEdgeOptions={{
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
}}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={15} size={1} />
|
||||
<Controls />
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
const def = ALL_NODES.find((n) => n.type === node.data?.type);
|
||||
return def?.color || '#6b7280';
|
||||
}}
|
||||
className="!bg-card"
|
||||
/>
|
||||
|
||||
{/* Top Toolbar */}
|
||||
<Panel position="top-center" className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={undo}
|
||||
disabled={historyIndex <= 0}
|
||||
>
|
||||
<Undo className="h-4 w-4 mr-1" />
|
||||
Undo
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={redo}
|
||||
disabled={historyIndex >= history.length - 1}
|
||||
>
|
||||
<Redo className="h-4 w-4 mr-1" />
|
||||
Redo
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="h-8" />
|
||||
<Button variant="outline" size="sm" onClick={clearScript}>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="h-8" />
|
||||
<Button variant="default" size="sm" onClick={handleGenerateCode}>
|
||||
<Code className="h-4 w-4 mr-1" />
|
||||
Generate Code
|
||||
</Button>
|
||||
</Panel>
|
||||
|
||||
{/* Platform Badge */}
|
||||
<Panel position="top-right">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{platform.toUpperCase()}
|
||||
</Badge>
|
||||
</Panel>
|
||||
|
||||
{/* Stats */}
|
||||
<Panel position="bottom-left" className="text-xs text-muted-foreground">
|
||||
{nodes.length} nodes • {edges.length} connections
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* Code Preview Dialog */}
|
||||
<Dialog open={showCodePreview} onOpenChange={setShowCodePreview}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Code className="h-5 w-5" />
|
||||
Generated Code
|
||||
{validationResult && (
|
||||
<Badge
|
||||
variant={validationResult.valid ? 'default' : 'destructive'}
|
||||
>
|
||||
{validationResult.valid ? 'Valid' : 'Has Issues'}
|
||||
</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{validationResult && validationResult.errors.length > 0 && (
|
||||
<div className="bg-destructive/10 border border-destructive/20 rounded-md p-3 mb-4">
|
||||
<p className="font-medium text-destructive text-sm flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Errors
|
||||
</p>
|
||||
<ul className="text-sm text-destructive/80 mt-1 list-disc list-inside">
|
||||
{validationResult.errors.map((err, i) => (
|
||||
<li key={i}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validationResult && validationResult.warnings.length > 0 && (
|
||||
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-md p-3 mb-4">
|
||||
<p className="font-medium text-yellow-600 text-sm flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Warnings
|
||||
</p>
|
||||
<ul className="text-sm text-yellow-600/80 mt-1 list-disc list-inside">
|
||||
{validationResult.warnings.map((warn, i) => (
|
||||
<li key={i}>{warn}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScrollArea className="h-[400px] w-full rounded-md border bg-muted/50">
|
||||
<pre className="p-4 text-sm font-mono whitespace-pre-wrap">
|
||||
{generatedCode}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button variant="outline" onClick={handleCopyCode}>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{copied ? 'Copied!' : 'Copy Code'}
|
||||
</Button>
|
||||
<Button onClick={() => setShowCodePreview(false)}>Done</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Node Palette Item Component
|
||||
function NodePaletteItem({
|
||||
node,
|
||||
onDragStart,
|
||||
}: {
|
||||
node: NodeDefinition;
|
||||
onDragStart: (event: React.DragEvent, nodeType: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, node.type)}
|
||||
className="flex items-center gap-2 p-2 rounded-md border bg-background hover:bg-accent cursor-grab active:cursor-grabbing transition-colors"
|
||||
style={{ borderLeftColor: node.color, borderLeftWidth: 3 }}
|
||||
>
|
||||
<div
|
||||
className="w-6 h-6 rounded flex items-center justify-center text-white"
|
||||
style={{ backgroundColor: node.color }}
|
||||
>
|
||||
{CATEGORY_ICONS[node.category]}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{node.label}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{node.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Export with provider wrapper
|
||||
export default function VisualScriptingCanvas(
|
||||
props: VisualScriptingCanvasProps
|
||||
) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<VisualScriptingCanvasInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
326
src/components/visual-scripting/nodes/CustomNode.tsx
Normal file
326
src/components/visual-scripting/nodes/CustomNode.tsx
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
'use client';
|
||||
|
||||
import { memo, useState } from 'react';
|
||||
import { Handle, Position, NodeProps } from 'reactflow';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import {
|
||||
getNodeDefinition,
|
||||
CATEGORY_COLORS,
|
||||
PORT_COLORS,
|
||||
NodePort,
|
||||
} from '@/lib/visual-scripting/node-definitions';
|
||||
import { NodeData } from '@/lib/visual-scripting/code-generator';
|
||||
import { useVisualScriptStore } from '@/stores/visual-script-store';
|
||||
|
||||
export const CustomNode = memo(({ id, data, selected }: NodeProps<NodeData>) => {
|
||||
const definition = getNodeDefinition(data.type);
|
||||
const { updateNodeValue, removeNode } = useVisualScriptStore();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
if (!definition) {
|
||||
return (
|
||||
<div className="bg-destructive/20 border border-destructive rounded p-2">
|
||||
<span className="text-xs text-destructive">Unknown node: {data.type}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleValueChange = (key: string, value: any) => {
|
||||
updateNodeValue(id, key, value);
|
||||
};
|
||||
|
||||
// Filter out flow inputs for the input section
|
||||
const editableInputs = definition.inputs.filter(
|
||||
(input) => input.type !== 'flow'
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-card rounded-lg border-2 shadow-lg min-w-[180px] max-w-[280px] transition-all ${
|
||||
selected ? 'border-primary ring-2 ring-primary/20' : 'border-border'
|
||||
}`}
|
||||
style={{
|
||||
borderTopColor: definition.color,
|
||||
borderTopWidth: 3,
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="px-3 py-2 rounded-t-md flex items-center justify-between"
|
||||
style={{ backgroundColor: `${definition.color}15` }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-5 h-5 rounded flex items-center justify-center text-white text-xs"
|
||||
style={{ backgroundColor: definition.color }}
|
||||
>
|
||||
{definition.category.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="font-medium text-sm">{definition.label}</span>
|
||||
</div>
|
||||
{isHovered && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeNode(id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input Handles (Left Side) */}
|
||||
<div className="relative">
|
||||
{definition.inputs.map((input, index) => (
|
||||
<div key={input.id} className="relative">
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id={input.id}
|
||||
className="!w-3 !h-3 !border-2 !border-background"
|
||||
style={{
|
||||
backgroundColor: PORT_COLORS[input.type],
|
||||
top: `${
|
||||
(editableInputs.length > 0 ? 45 : 20) +
|
||||
index * 28 +
|
||||
(input.type === 'flow' ? 0 : editableInputs.indexOf(input) * 36)
|
||||
}px`,
|
||||
}}
|
||||
isConnectable={true}
|
||||
/>
|
||||
{input.type === 'flow' && (
|
||||
<div
|
||||
className="absolute left-4 text-xs text-muted-foreground"
|
||||
style={{
|
||||
top: `${20 + index * 28}px`,
|
||||
}}
|
||||
>
|
||||
{input.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Editable Inputs */}
|
||||
{editableInputs.length > 0 && (
|
||||
<div className="px-3 py-2 space-y-2 border-t border-border/50">
|
||||
{editableInputs.map((input) => (
|
||||
<NodeInput
|
||||
key={input.id}
|
||||
input={input}
|
||||
value={data.values?.[input.id] ?? input.defaultValue}
|
||||
onChange={(value) => handleValueChange(input.id, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output Handles (Right Side) */}
|
||||
<div className="relative min-h-[20px]">
|
||||
{definition.outputs.map((output, index) => (
|
||||
<div key={output.id} className="relative">
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id={output.id}
|
||||
className="!w-3 !h-3 !border-2 !border-background"
|
||||
style={{
|
||||
backgroundColor: PORT_COLORS[output.type],
|
||||
top: `${10 + index * 24}px`,
|
||||
}}
|
||||
isConnectable={true}
|
||||
/>
|
||||
<div
|
||||
className="absolute right-4 text-xs text-muted-foreground text-right"
|
||||
style={{
|
||||
top: `${6 + index * 24}px`,
|
||||
}}
|
||||
>
|
||||
{output.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Spacer for outputs */}
|
||||
<div
|
||||
style={{
|
||||
height: Math.max(definition.outputs.length * 24, 20),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
CustomNode.displayName = 'CustomNode';
|
||||
|
||||
// Input field component for node properties
|
||||
function NodeInput({
|
||||
input,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
input: NodePort;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
}) {
|
||||
switch (input.type) {
|
||||
case 'number':
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{input.name}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'string':
|
||||
if (input.id === 'operation') {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{input.name}</Label>
|
||||
<Select value={value || 'add'} onValueChange={onChange}>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="add">+ Add</SelectItem>
|
||||
<SelectItem value="subtract">- Subtract</SelectItem>
|
||||
<SelectItem value="multiply">× Multiply</SelectItem>
|
||||
<SelectItem value="divide">÷ Divide</SelectItem>
|
||||
<SelectItem value="modulo">% Modulo</SelectItem>
|
||||
<SelectItem value="power">^ Power</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (input.id === 'comparison') {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{input.name}</Label>
|
||||
<Select value={value || 'equals'} onValueChange={onChange}>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="equals">= Equals</SelectItem>
|
||||
<SelectItem value="notEquals">≠ Not Equals</SelectItem>
|
||||
<SelectItem value="greater">> Greater</SelectItem>
|
||||
<SelectItem value="less">< Less</SelectItem>
|
||||
<SelectItem value="greaterEqual">≥ Greater or Equal</SelectItem>
|
||||
<SelectItem value="lessEqual">≤ Less or Equal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (input.id === 'key') {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{input.name}</Label>
|
||||
<Select value={value || 'E'} onValueChange={onChange}>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['E', 'F', 'G', 'Q', 'R', 'Space', 'Shift', 'One', 'Two', 'Three'].map(
|
||||
(key) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{key}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (input.id === 'service') {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{input.name}</Label>
|
||||
<Select value={value || 'Players'} onValueChange={onChange}>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[
|
||||
'Players',
|
||||
'ReplicatedStorage',
|
||||
'ServerStorage',
|
||||
'Lighting',
|
||||
'TweenService',
|
||||
'UserInputService',
|
||||
'RunService',
|
||||
'SoundService',
|
||||
'DataStoreService',
|
||||
].map((svc) => (
|
||||
<SelectItem key={svc} value={svc}>
|
||||
{svc}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{input.name}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
placeholder={input.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">{input.name}</Label>
|
||||
<Switch
|
||||
checked={value ?? input.defaultValue ?? false}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{input.name}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
placeholder={`${input.name} (${input.type})`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
553
src/lib/visual-scripting/code-generator.ts
Normal file
553
src/lib/visual-scripting/code-generator.ts
Normal file
|
|
@ -0,0 +1,553 @@
|
|||
/**
|
||||
* AeThex Visual Scripting - Code Generator
|
||||
* Converts visual script nodes into platform-specific code
|
||||
*/
|
||||
|
||||
import { Node, Edge } from 'reactflow';
|
||||
import {
|
||||
getNodeDefinition,
|
||||
NodeDefinition,
|
||||
PortType,
|
||||
ALL_NODES
|
||||
} from './node-definitions';
|
||||
|
||||
export type Platform = 'roblox' | 'uefn' | 'spatial';
|
||||
|
||||
export interface GenerationResult {
|
||||
success: boolean;
|
||||
code: string;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
nodeCount: number;
|
||||
connectionCount: number;
|
||||
}
|
||||
|
||||
export interface NodeData {
|
||||
type: string;
|
||||
label: string;
|
||||
values: Record<string, any>;
|
||||
}
|
||||
|
||||
// Track visited nodes to prevent infinite loops
|
||||
type VisitedSet = Set<string>;
|
||||
|
||||
/**
|
||||
* Generate code from visual script nodes
|
||||
*/
|
||||
export function generateCode(
|
||||
nodes: Node<NodeData>[],
|
||||
edges: Edge[],
|
||||
platform: Platform
|
||||
): GenerationResult {
|
||||
const result: GenerationResult = {
|
||||
success: true,
|
||||
code: '',
|
||||
errors: [],
|
||||
warnings: [],
|
||||
nodeCount: nodes.length,
|
||||
connectionCount: edges.length,
|
||||
};
|
||||
|
||||
if (nodes.length === 0) {
|
||||
result.code = getEmptyTemplate(platform);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Find all event nodes (entry points)
|
||||
const eventNodes = nodes.filter(n => {
|
||||
const def = getNodeDefinition(n.data.type);
|
||||
return def?.category === 'events';
|
||||
});
|
||||
|
||||
if (eventNodes.length === 0) {
|
||||
result.warnings.push('No event nodes found. Add an event node to start your script.');
|
||||
result.code = getEmptyTemplate(platform);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Build adjacency map for traversal
|
||||
const adjacencyMap = buildAdjacencyMap(edges);
|
||||
const reverseAdjacencyMap = buildReverseAdjacencyMap(edges);
|
||||
|
||||
// Generate code for each event node
|
||||
const codeBlocks: string[] = [];
|
||||
|
||||
for (const eventNode of eventNodes) {
|
||||
try {
|
||||
const visited: VisitedSet = new Set();
|
||||
const blockCode = generateNodeCode(
|
||||
eventNode,
|
||||
nodes,
|
||||
adjacencyMap,
|
||||
reverseAdjacencyMap,
|
||||
platform,
|
||||
visited,
|
||||
0
|
||||
);
|
||||
codeBlocks.push(blockCode);
|
||||
} catch (error: any) {
|
||||
result.errors.push(`Error in node "${eventNode.data.label}": ${error.message}`);
|
||||
result.success = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Combine code blocks
|
||||
result.code = wrapCode(codeBlocks.join('\n\n'), platform);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build adjacency map from edges (source -> targets)
|
||||
*/
|
||||
function buildAdjacencyMap(edges: Edge[]): Map<string, Edge[]> {
|
||||
const map = new Map<string, Edge[]>();
|
||||
|
||||
for (const edge of edges) {
|
||||
const key = `${edge.source}.${edge.sourceHandle}`;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, []);
|
||||
}
|
||||
map.get(key)!.push(edge);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build reverse adjacency map (target -> sources)
|
||||
*/
|
||||
function buildReverseAdjacencyMap(edges: Edge[]): Map<string, Edge[]> {
|
||||
const map = new Map<string, Edge[]>();
|
||||
|
||||
for (const edge of edges) {
|
||||
const key = `${edge.target}.${edge.targetHandle}`;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, []);
|
||||
}
|
||||
map.get(key)!.push(edge);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate code for a single node and its connected nodes
|
||||
*/
|
||||
function generateNodeCode(
|
||||
node: Node<NodeData>,
|
||||
allNodes: Node<NodeData>[],
|
||||
adjacencyMap: Map<string, Edge[]>,
|
||||
reverseMap: Map<string, Edge[]>,
|
||||
platform: Platform,
|
||||
visited: VisitedSet,
|
||||
depth: number
|
||||
): string {
|
||||
if (visited.has(node.id)) {
|
||||
return '-- [Circular reference detected]';
|
||||
}
|
||||
visited.add(node.id);
|
||||
|
||||
const definition = getNodeDefinition(node.data.type);
|
||||
if (!definition) {
|
||||
return `-- Unknown node type: ${node.data.type}`;
|
||||
}
|
||||
|
||||
const template = definition.codeTemplate[platform];
|
||||
if (!template) {
|
||||
return `-- Node "${definition.label}" not supported on ${platform}`;
|
||||
}
|
||||
|
||||
// Resolve input values
|
||||
const resolvedValues: Record<string, string> = {};
|
||||
|
||||
for (const input of definition.inputs) {
|
||||
// Check if there's a connection to this input
|
||||
const connectionKey = `${node.id}.${input.id}`;
|
||||
const incomingEdges = reverseMap.get(connectionKey) || [];
|
||||
|
||||
if (incomingEdges.length > 0 && input.type !== 'flow') {
|
||||
// Get value from connected node
|
||||
const sourceEdge = incomingEdges[0];
|
||||
const sourceNode = allNodes.find(n => n.id === sourceEdge.source);
|
||||
|
||||
if (sourceNode) {
|
||||
resolvedValues[input.id] = generateValueExpression(
|
||||
sourceNode,
|
||||
sourceEdge.sourceHandle || 'value',
|
||||
allNodes,
|
||||
reverseMap,
|
||||
platform,
|
||||
new Set(visited),
|
||||
depth + 1
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Use node's stored value or default
|
||||
const value = node.data.values?.[input.id] ?? input.defaultValue ?? getDefaultForType(input.type);
|
||||
resolvedValues[input.id] = formatValue(value, input.type, platform);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate body code (connected flow outputs)
|
||||
let code = template;
|
||||
|
||||
// Replace placeholders with values
|
||||
for (const [key, value] of Object.entries(resolvedValues)) {
|
||||
code = code.replace(new RegExp(`{{${key}}}`, 'g'), value);
|
||||
}
|
||||
|
||||
// Handle flow outputs (execution continues)
|
||||
for (const output of definition.outputs) {
|
||||
if (output.type === 'flow') {
|
||||
const connectionKey = `${node.id}.${output.id}`;
|
||||
const outgoingEdges = adjacencyMap.get(connectionKey) || [];
|
||||
|
||||
let bodyCode = '';
|
||||
for (const edge of outgoingEdges) {
|
||||
const targetNode = allNodes.find(n => n.id === edge.target);
|
||||
if (targetNode) {
|
||||
bodyCode += generateNodeCode(
|
||||
targetNode,
|
||||
allNodes,
|
||||
adjacencyMap,
|
||||
reverseMap,
|
||||
platform,
|
||||
visited,
|
||||
depth + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Replace body placeholder
|
||||
const bodyPlaceholder = getBodyPlaceholder(output.id);
|
||||
code = code.replace(new RegExp(bodyPlaceholder, 'g'), indent(bodyCode, depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any remaining placeholders
|
||||
code = code.replace(/{{[A-Z_]+_BODY}}/g, '');
|
||||
code = code.replace(/{{BODY}}/g, '');
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a value expression for a data node
|
||||
*/
|
||||
function generateValueExpression(
|
||||
node: Node<NodeData>,
|
||||
outputHandle: string,
|
||||
allNodes: Node<NodeData>[],
|
||||
reverseMap: Map<string, Edge[]>,
|
||||
platform: Platform,
|
||||
visited: VisitedSet,
|
||||
depth: number
|
||||
): string {
|
||||
if (visited.has(node.id)) {
|
||||
return 'nil';
|
||||
}
|
||||
visited.add(node.id);
|
||||
|
||||
const definition = getNodeDefinition(node.data.type);
|
||||
if (!definition) {
|
||||
return 'nil';
|
||||
}
|
||||
|
||||
const template = definition.codeTemplate[platform];
|
||||
if (!template) {
|
||||
return 'nil';
|
||||
}
|
||||
|
||||
// Resolve input values recursively
|
||||
let code = template;
|
||||
|
||||
for (const input of definition.inputs) {
|
||||
const connectionKey = `${node.id}.${input.id}`;
|
||||
const incomingEdges = reverseMap.get(connectionKey) || [];
|
||||
|
||||
let value: string;
|
||||
if (incomingEdges.length > 0 && input.type !== 'flow') {
|
||||
const sourceEdge = incomingEdges[0];
|
||||
const sourceNode = allNodes.find(n => n.id === sourceEdge.source);
|
||||
|
||||
if (sourceNode) {
|
||||
value = generateValueExpression(
|
||||
sourceNode,
|
||||
sourceEdge.sourceHandle || 'value',
|
||||
allNodes,
|
||||
reverseMap,
|
||||
platform,
|
||||
visited,
|
||||
depth + 1
|
||||
);
|
||||
} else {
|
||||
value = formatValue(input.defaultValue, input.type, platform);
|
||||
}
|
||||
} else {
|
||||
const nodeValue = node.data.values?.[input.id] ?? input.defaultValue ?? getDefaultForType(input.type);
|
||||
value = formatValue(nodeValue, input.type, platform);
|
||||
}
|
||||
|
||||
code = code.replace(new RegExp(`{{${input.id}}}`, 'g'), value);
|
||||
}
|
||||
|
||||
// Handle special operations
|
||||
if (node.data.type === 'math') {
|
||||
const op = node.data.values?.operation || 'add';
|
||||
const opSymbol = getOperationSymbol(op);
|
||||
code = code.replace('{{OPERATION}}', opSymbol);
|
||||
}
|
||||
|
||||
if (node.data.type === 'compare') {
|
||||
const comp = node.data.values?.comparison || 'equals';
|
||||
const compSymbol = getComparisonSymbol(comp, platform);
|
||||
code = code.replace('{{COMPARISON}}', compSymbol);
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value for the target platform
|
||||
*/
|
||||
function formatValue(value: any, type: PortType, platform: Platform): string {
|
||||
if (value === null || value === undefined) {
|
||||
return getDefaultForType(type);
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return `"${String(value).replace(/"/g, '\\"')}"`;
|
||||
case 'number':
|
||||
return String(Number(value) || 0);
|
||||
case 'boolean':
|
||||
if (platform === 'roblox') {
|
||||
return value ? 'true' : 'false';
|
||||
} else if (platform === 'uefn') {
|
||||
return value ? 'true' : 'false';
|
||||
}
|
||||
return value ? 'true' : 'false';
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default value for a port type
|
||||
*/
|
||||
function getDefaultForType(type: PortType): string {
|
||||
switch (type) {
|
||||
case 'number': return '0';
|
||||
case 'string': return '""';
|
||||
case 'boolean': return 'false';
|
||||
case 'object': return 'nil';
|
||||
case 'array': return '{}';
|
||||
default: return 'nil';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the body placeholder name for an output
|
||||
*/
|
||||
function getBodyPlaceholder(outputId: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
'flow': '{{BODY}}',
|
||||
'true': '{{TRUE_BODY}}',
|
||||
'false': '{{FALSE_BODY}}',
|
||||
'loop': '{{LOOP_BODY}}',
|
||||
'complete': '{{COMPLETE_BODY}}',
|
||||
};
|
||||
return mapping[outputId] || '{{BODY}}';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get math operation symbol
|
||||
*/
|
||||
function getOperationSymbol(operation: string): string {
|
||||
const symbols: Record<string, string> = {
|
||||
'add': '+',
|
||||
'subtract': '-',
|
||||
'multiply': '*',
|
||||
'divide': '/',
|
||||
'modulo': '%',
|
||||
'power': '^',
|
||||
};
|
||||
return symbols[operation] || '+';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comparison symbol for platform
|
||||
*/
|
||||
function getComparisonSymbol(comparison: string, platform: Platform): string {
|
||||
const symbols: Record<string, string> = {
|
||||
'equals': '==',
|
||||
'notEquals': platform === 'roblox' ? '~=' : '!=',
|
||||
'greater': '>',
|
||||
'less': '<',
|
||||
'greaterEqual': '>=',
|
||||
'lessEqual': '<=',
|
||||
};
|
||||
return symbols[comparison] || '==';
|
||||
}
|
||||
|
||||
/**
|
||||
* Indent code block
|
||||
*/
|
||||
function indent(code: string, level: number): string {
|
||||
const tab = '\t';
|
||||
const prefix = tab.repeat(level);
|
||||
return code
|
||||
.split('\n')
|
||||
.map(line => line.trim() ? prefix + line : line)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap generated code with platform boilerplate
|
||||
*/
|
||||
function wrapCode(code: string, platform: Platform): string {
|
||||
switch (platform) {
|
||||
case 'roblox':
|
||||
return `-- Generated by AeThex Visual Scripting
|
||||
-- Platform: Roblox (Lua)
|
||||
|
||||
${code}`;
|
||||
|
||||
case 'uefn':
|
||||
return `# Generated by AeThex Visual Scripting
|
||||
# Platform: UEFN (Verse)
|
||||
|
||||
using { /Fortnite.com/Devices }
|
||||
using { /Verse.org/Simulation }
|
||||
using { /UnrealEngine.com/Temporary/Diagnostics }
|
||||
|
||||
aethex_generated_device := class(creative_device):
|
||||
|
||||
${indent(code, 1)}`;
|
||||
|
||||
case 'spatial':
|
||||
return `// Generated by AeThex Visual Scripting
|
||||
// Platform: Spatial (TypeScript)
|
||||
|
||||
import { SpaceService, World } from '@spatial/core';
|
||||
|
||||
${code}`;
|
||||
|
||||
default:
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get empty template for a platform
|
||||
*/
|
||||
function getEmptyTemplate(platform: Platform): string {
|
||||
switch (platform) {
|
||||
case 'roblox':
|
||||
return `-- Generated by AeThex Visual Scripting
|
||||
-- Add event nodes to start building your script!
|
||||
|
||||
-- Example:
|
||||
-- 1. Drag an "On Player Join" event node
|
||||
-- 2. Connect it to an action like "Print"
|
||||
-- 3. Click "Generate Code"
|
||||
|
||||
print("Hello from AeThex!")`;
|
||||
|
||||
case 'uefn':
|
||||
return `# Generated by AeThex Visual Scripting
|
||||
# Add event nodes to start building your script!
|
||||
|
||||
using { /Fortnite.com/Devices }
|
||||
using { /Verse.org/Simulation }
|
||||
|
||||
aethex_generated_device := class(creative_device):
|
||||
OnBegin<override>(): void =
|
||||
Print("Hello from AeThex!")`;
|
||||
|
||||
case 'spatial':
|
||||
return `// Generated by AeThex Visual Scripting
|
||||
// Add event nodes to start building your script!
|
||||
|
||||
import { SpaceService } from '@spatial/core';
|
||||
|
||||
SpaceService.onSpaceReady.on(() => {
|
||||
console.log("Hello from AeThex!");
|
||||
});`;
|
||||
|
||||
default:
|
||||
return '// No code generated';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate visual script for errors
|
||||
*/
|
||||
export function validateScript(
|
||||
nodes: Node<NodeData>[],
|
||||
edges: Edge[],
|
||||
platform: Platform
|
||||
): { valid: boolean; errors: string[]; warnings: string[] } {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check for event nodes
|
||||
const eventNodes = nodes.filter(n => {
|
||||
const def = getNodeDefinition(n.data.type);
|
||||
return def?.category === 'events';
|
||||
});
|
||||
|
||||
if (eventNodes.length === 0) {
|
||||
warnings.push('No event nodes found. Your script needs at least one event to run.');
|
||||
}
|
||||
|
||||
// Check for disconnected nodes
|
||||
const connectedNodes = new Set<string>();
|
||||
for (const edge of edges) {
|
||||
connectedNodes.add(edge.source);
|
||||
connectedNodes.add(edge.target);
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!connectedNodes.has(node.id) && eventNodes.findIndex(e => e.id === node.id) === -1) {
|
||||
const def = getNodeDefinition(node.data.type);
|
||||
if (def?.category !== 'events') {
|
||||
warnings.push(`Node "${node.data.label || node.data.type}" is not connected.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for required inputs
|
||||
for (const node of nodes) {
|
||||
const def = getNodeDefinition(node.data.type);
|
||||
if (!def) continue;
|
||||
|
||||
for (const input of def.inputs) {
|
||||
if (input.required && input.type !== 'flow') {
|
||||
const hasConnection = edges.some(
|
||||
e => e.target === node.id && e.targetHandle === input.id
|
||||
);
|
||||
const hasValue = node.data.values?.[input.id] !== undefined;
|
||||
|
||||
if (!hasConnection && !hasValue) {
|
||||
errors.push(`Node "${node.data.label || def.label}" is missing required input: ${input.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for platform compatibility
|
||||
for (const node of nodes) {
|
||||
const def = getNodeDefinition(node.data.type);
|
||||
if (def && !def.platforms.includes(platform)) {
|
||||
errors.push(`Node "${def.label}" is not available on ${platform}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
828
src/lib/visual-scripting/node-definitions.ts
Normal file
828
src/lib/visual-scripting/node-definitions.ts
Normal file
|
|
@ -0,0 +1,828 @@
|
|||
/**
|
||||
* AeThex Visual Scripting - Node Definitions
|
||||
* All available nodes for the visual scripting system
|
||||
*/
|
||||
|
||||
import { Node, Edge } from 'reactflow';
|
||||
|
||||
// Node Categories
|
||||
export type NodeCategory =
|
||||
| 'events' // Entry points (green)
|
||||
| 'logic' // Control flow (blue)
|
||||
| 'actions' // Do things (purple)
|
||||
| 'data' // Values and operations (orange)
|
||||
| 'references' // Game objects (yellow)
|
||||
| 'custom'; // User-defined (gray)
|
||||
|
||||
// Port types for connections
|
||||
export type PortType =
|
||||
| 'flow' // Execution flow (white)
|
||||
| 'number' // Number values (blue)
|
||||
| 'string' // Text values (pink)
|
||||
| 'boolean' // True/false (red)
|
||||
| 'object' // Game objects (yellow)
|
||||
| 'array' // Lists (green)
|
||||
| 'any'; // Any type (gray)
|
||||
|
||||
export interface NodePort {
|
||||
id: string;
|
||||
name: string;
|
||||
type: PortType;
|
||||
defaultValue?: any;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface NodeDefinition {
|
||||
type: string;
|
||||
category: NodeCategory;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
inputs: NodePort[];
|
||||
outputs: NodePort[];
|
||||
platforms: ('roblox' | 'uefn' | 'spatial')[];
|
||||
codeTemplate: {
|
||||
roblox?: string;
|
||||
uefn?: string;
|
||||
spatial?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Color mapping for categories
|
||||
export const CATEGORY_COLORS: Record<NodeCategory, string> = {
|
||||
events: '#22c55e', // Green
|
||||
logic: '#3b82f6', // Blue
|
||||
actions: '#a855f7', // Purple
|
||||
data: '#f97316', // Orange
|
||||
references: '#eab308', // Yellow
|
||||
custom: '#6b7280', // Gray
|
||||
};
|
||||
|
||||
// Color mapping for port types
|
||||
export const PORT_COLORS: Record<PortType, string> = {
|
||||
flow: '#ffffff',
|
||||
number: '#3b82f6',
|
||||
string: '#ec4899',
|
||||
boolean: '#ef4444',
|
||||
object: '#eab308',
|
||||
array: '#22c55e',
|
||||
any: '#6b7280',
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// EVENT NODES - Entry points for scripts
|
||||
// ============================================
|
||||
|
||||
export const EVENT_NODES: NodeDefinition[] = [
|
||||
{
|
||||
type: 'onPlayerJoin',
|
||||
category: 'events',
|
||||
label: 'On Player Join',
|
||||
description: 'Triggered when a player joins the game',
|
||||
icon: 'UserPlus',
|
||||
color: CATEGORY_COLORS.events,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'player', name: 'Player', type: 'object' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `game.Players.PlayerAdded:Connect(function(player)\n{{BODY}}\nend)`,
|
||||
uefn: `OnPlayerAdded<public>(): void =\n{{BODY}}`,
|
||||
spatial: `SpaceService.onPlayerJoined.on((player) => {\n{{BODY}}\n});`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'onPlayerLeave',
|
||||
category: 'events',
|
||||
label: 'On Player Leave',
|
||||
description: 'Triggered when a player leaves the game',
|
||||
icon: 'UserMinus',
|
||||
color: CATEGORY_COLORS.events,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'player', name: 'Player', type: 'object' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `game.Players.PlayerRemoving:Connect(function(player)\n{{BODY}}\nend)`,
|
||||
uefn: `OnPlayerRemoved<public>(): void =\n{{BODY}}`,
|
||||
spatial: `SpaceService.onPlayerLeft.on((player) => {\n{{BODY}}\n});`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'onPartTouch',
|
||||
category: 'events',
|
||||
label: 'On Part Touched',
|
||||
description: 'Triggered when something touches a part',
|
||||
icon: 'Hand',
|
||||
color: CATEGORY_COLORS.events,
|
||||
inputs: [
|
||||
{ id: 'part', name: 'Part', type: 'object', required: true },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'otherPart', name: 'Other Part', type: 'object' },
|
||||
{ id: 'player', name: 'Player (if any)', type: 'object' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `{{part}}.Touched:Connect(function(otherPart)\n\tlocal player = game.Players:GetPlayerFromCharacter(otherPart.Parent)\n{{BODY}}\nend)`,
|
||||
uefn: `{{part}}.OnBeginOverlap.Subscribe(function(OtherActor: actor):\n{{BODY}}`,
|
||||
spatial: `{{part}}.onCollisionEnter.on((collision) => {\n{{BODY}}\n});`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'onKeyPress',
|
||||
category: 'events',
|
||||
label: 'On Key Press',
|
||||
description: 'Triggered when a key is pressed',
|
||||
icon: 'Keyboard',
|
||||
color: CATEGORY_COLORS.events,
|
||||
inputs: [
|
||||
{ id: 'key', name: 'Key', type: 'string', defaultValue: 'E' },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'player', name: 'Player', type: 'object' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `local UserInputService = game:GetService("UserInputService")\nUserInputService.InputBegan:Connect(function(input, gameProcessed)\n\tif not gameProcessed and input.KeyCode == Enum.KeyCode.{{key}} then\n{{BODY}}\n\tend\nend)`,
|
||||
uefn: `OnKeyPressed(Key: keycode_{{key}}): void =\n{{BODY}}`,
|
||||
spatial: `Input.onKeyDown("{{key}}", () => {\n{{BODY}}\n});`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'onTimer',
|
||||
category: 'events',
|
||||
label: 'On Timer',
|
||||
description: 'Triggers repeatedly at an interval',
|
||||
icon: 'Clock',
|
||||
color: CATEGORY_COLORS.events,
|
||||
inputs: [
|
||||
{ id: 'interval', name: 'Interval (sec)', type: 'number', defaultValue: 1 },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `while true do\n\ttask.wait({{interval}})\n{{BODY}}\nend`,
|
||||
uefn: `loop:\n\tSleep({{interval}})\n{{BODY}}`,
|
||||
spatial: `setInterval(() => {\n{{BODY}}\n}, {{interval}} * 1000);`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'onGameStart',
|
||||
category: 'events',
|
||||
label: 'On Game Start',
|
||||
description: 'Runs once when the game starts',
|
||||
icon: 'Play',
|
||||
color: CATEGORY_COLORS.events,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `-- Runs on game start\n{{BODY}}`,
|
||||
uefn: `OnBegin<override>(): void =\n{{BODY}}`,
|
||||
spatial: `SpaceService.onSpaceReady.on(() => {\n{{BODY}}\n});`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// LOGIC NODES - Control flow
|
||||
// ============================================
|
||||
|
||||
export const LOGIC_NODES: NodeDefinition[] = [
|
||||
{
|
||||
type: 'ifCondition',
|
||||
category: 'logic',
|
||||
label: 'If',
|
||||
description: 'Branch based on a condition',
|
||||
icon: 'GitBranch',
|
||||
color: CATEGORY_COLORS.logic,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'condition', name: 'Condition', type: 'boolean', required: true },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'true', name: 'True', type: 'flow' },
|
||||
{ id: 'false', name: 'False', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `if {{condition}} then\n{{TRUE_BODY}}\nelse\n{{FALSE_BODY}}\nend`,
|
||||
uefn: `if ({{condition}}):\n{{TRUE_BODY}}\nelse:\n{{FALSE_BODY}}`,
|
||||
spatial: `if ({{condition}}) {\n{{TRUE_BODY}}\n} else {\n{{FALSE_BODY}}\n}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'forLoop',
|
||||
category: 'logic',
|
||||
label: 'For Loop',
|
||||
description: 'Repeat a number of times',
|
||||
icon: 'Repeat',
|
||||
color: CATEGORY_COLORS.logic,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'start', name: 'Start', type: 'number', defaultValue: 1 },
|
||||
{ id: 'end', name: 'End', type: 'number', defaultValue: 10 },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'loop', name: 'Loop Body', type: 'flow' },
|
||||
{ id: 'index', name: 'Index', type: 'number' },
|
||||
{ id: 'complete', name: 'Completed', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `for i = {{start}}, {{end}} do\n{{LOOP_BODY}}\nend\n{{COMPLETE_BODY}}`,
|
||||
uefn: `for (Index := {{start}}..{{end}}):\n{{LOOP_BODY}}\n{{COMPLETE_BODY}}`,
|
||||
spatial: `for (let i = {{start}}; i <= {{end}}; i++) {\n{{LOOP_BODY}}\n}\n{{COMPLETE_BODY}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'forEach',
|
||||
category: 'logic',
|
||||
label: 'For Each',
|
||||
description: 'Loop through a list',
|
||||
icon: 'List',
|
||||
color: CATEGORY_COLORS.logic,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'array', name: 'Array', type: 'array', required: true },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'loop', name: 'Loop Body', type: 'flow' },
|
||||
{ id: 'item', name: 'Current Item', type: 'any' },
|
||||
{ id: 'index', name: 'Index', type: 'number' },
|
||||
{ id: 'complete', name: 'Completed', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `for index, item in ipairs({{array}}) do\n{{LOOP_BODY}}\nend\n{{COMPLETE_BODY}}`,
|
||||
uefn: `for (Index -> Item : {{array}}):\n{{LOOP_BODY}}\n{{COMPLETE_BODY}}`,
|
||||
spatial: `{{array}}.forEach((item, index) => {\n{{LOOP_BODY}}\n});\n{{COMPLETE_BODY}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'wait',
|
||||
category: 'logic',
|
||||
label: 'Wait',
|
||||
description: 'Pause execution for a duration',
|
||||
icon: 'Clock',
|
||||
color: CATEGORY_COLORS.logic,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'duration', name: 'Seconds', type: 'number', defaultValue: 1 },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Continue', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `task.wait({{duration}})\n{{BODY}}`,
|
||||
uefn: `Sleep({{duration}})\n{{BODY}}`,
|
||||
spatial: `await delay({{duration}} * 1000);\n{{BODY}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'whileLoop',
|
||||
category: 'logic',
|
||||
label: 'While',
|
||||
description: 'Loop while condition is true',
|
||||
icon: 'RefreshCw',
|
||||
color: CATEGORY_COLORS.logic,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'condition', name: 'Condition', type: 'boolean', required: true },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'loop', name: 'Loop Body', type: 'flow' },
|
||||
{ id: 'complete', name: 'Completed', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `while {{condition}} do\n{{LOOP_BODY}}\nend\n{{COMPLETE_BODY}}`,
|
||||
uefn: `loop:\n\tif (not {{condition}}): break\n{{LOOP_BODY}}`,
|
||||
spatial: `while ({{condition}}) {\n{{LOOP_BODY}}\n}\n{{COMPLETE_BODY}}`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// ACTION NODES - Do things
|
||||
// ============================================
|
||||
|
||||
export const ACTION_NODES: NodeDefinition[] = [
|
||||
{
|
||||
type: 'print',
|
||||
category: 'actions',
|
||||
label: 'Print',
|
||||
description: 'Print a message to the console',
|
||||
icon: 'MessageSquare',
|
||||
color: CATEGORY_COLORS.actions,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'message', name: 'Message', type: 'string', defaultValue: 'Hello!' },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Continue', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `print({{message}})\n{{BODY}}`,
|
||||
uefn: `Print({{message}})\n{{BODY}}`,
|
||||
spatial: `console.log({{message}});\n{{BODY}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'setProperty',
|
||||
category: 'actions',
|
||||
label: 'Set Property',
|
||||
description: 'Set a property on an object',
|
||||
icon: 'Settings',
|
||||
color: CATEGORY_COLORS.actions,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'object', name: 'Object', type: 'object', required: true },
|
||||
{ id: 'property', name: 'Property', type: 'string', required: true },
|
||||
{ id: 'value', name: 'Value', type: 'any', required: true },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Continue', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `{{object}}.{{property}} = {{value}}\n{{BODY}}`,
|
||||
uefn: `set {{object}}.{{property}} = {{value}}\n{{BODY}}`,
|
||||
spatial: `{{object}}.{{property}} = {{value}};\n{{BODY}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'createPart',
|
||||
category: 'actions',
|
||||
label: 'Create Part',
|
||||
description: 'Create a new part in the world',
|
||||
icon: 'Box',
|
||||
color: CATEGORY_COLORS.actions,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'position', name: 'Position', type: 'object' },
|
||||
{ id: 'size', name: 'Size', type: 'object' },
|
||||
{ id: 'color', name: 'Color', type: 'object' },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Continue', type: 'flow' },
|
||||
{ id: 'part', name: 'Created Part', type: 'object' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `local newPart = Instance.new("Part")\nnewPart.Position = {{position}}\nnewPart.Size = {{size}}\nnewPart.BrickColor = {{color}}\nnewPart.Parent = workspace\n{{BODY}}`,
|
||||
uefn: `var NewProp := SpawnProp(DefaultProp)\nNewProp.SetTransform({{position}})\n{{BODY}}`,
|
||||
spatial: `const newPart = createPart({{position}}, {{size}}, {{color}});\n{{BODY}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'destroy',
|
||||
category: 'actions',
|
||||
label: 'Destroy',
|
||||
description: 'Destroy an object',
|
||||
icon: 'Trash2',
|
||||
color: CATEGORY_COLORS.actions,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'object', name: 'Object', type: 'object', required: true },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Continue', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `{{object}}:Destroy()\n{{BODY}}`,
|
||||
uefn: `{{object}}.Dispose()\n{{BODY}}`,
|
||||
spatial: `{{object}}.destroy();\n{{BODY}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'playSound',
|
||||
category: 'actions',
|
||||
label: 'Play Sound',
|
||||
description: 'Play a sound effect',
|
||||
icon: 'Volume2',
|
||||
color: CATEGORY_COLORS.actions,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'soundId', name: 'Sound ID', type: 'string', required: true },
|
||||
{ id: 'volume', name: 'Volume', type: 'number', defaultValue: 1 },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Continue', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `local sound = Instance.new("Sound")\nsound.SoundId = "rbxassetid://{{soundId}}"\nsound.Volume = {{volume}}\nsound.Parent = workspace\nsound:Play()\n{{BODY}}`,
|
||||
uefn: `PlaySound({{soundId}}, {{volume}})\n{{BODY}}`,
|
||||
spatial: `playSound("{{soundId}}", { volume: {{volume}} });\n{{BODY}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'tween',
|
||||
category: 'actions',
|
||||
label: 'Tween Property',
|
||||
description: 'Smoothly animate a property',
|
||||
icon: 'Sparkles',
|
||||
color: CATEGORY_COLORS.actions,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'object', name: 'Object', type: 'object', required: true },
|
||||
{ id: 'property', name: 'Property', type: 'string', required: true },
|
||||
{ id: 'target', name: 'Target Value', type: 'any', required: true },
|
||||
{ id: 'duration', name: 'Duration', type: 'number', defaultValue: 1 },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'On Complete', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `local TweenService = game:GetService("TweenService")\nlocal tween = TweenService:Create({{object}}, TweenInfo.new({{duration}}), {{{property}} = {{target}}})\ntween:Play()\ntween.Completed:Wait()\n{{BODY}}`,
|
||||
uefn: `{{object}}.MoveAndRotateTo({{target}}, {{duration}})\n{{BODY}}`,
|
||||
spatial: `animate({{object}}, { {{property}}: {{target}} }, {{duration}} * 1000).then(() => {\n{{BODY}}\n});`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'teleport',
|
||||
category: 'actions',
|
||||
label: 'Teleport Player',
|
||||
description: 'Move a player to a position',
|
||||
icon: 'Zap',
|
||||
color: CATEGORY_COLORS.actions,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'player', name: 'Player', type: 'object', required: true },
|
||||
{ id: 'position', name: 'Position', type: 'object', required: true },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Continue', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `{{player}}.Character:SetPrimaryPartCFrame(CFrame.new({{position}}))\n{{BODY}}`,
|
||||
uefn: `{{player}}.Respawn({{position}}, {{player}}.GetRotation())\n{{BODY}}`,
|
||||
spatial: `{{player}}.teleportTo({{position}});\n{{BODY}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'giveItem',
|
||||
category: 'actions',
|
||||
label: 'Give Item',
|
||||
description: 'Give an item/tool to a player',
|
||||
icon: 'Gift',
|
||||
color: CATEGORY_COLORS.actions,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'player', name: 'Player', type: 'object', required: true },
|
||||
{ id: 'item', name: 'Item', type: 'object', required: true },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Continue', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `local itemClone = {{item}}:Clone()\nitemClone.Parent = {{player}}.Backpack\n{{BODY}}`,
|
||||
uefn: `GrantItem({{player}}, {{item}})\n{{BODY}}`,
|
||||
spatial: `{{player}}.inventory.add({{item}});\n{{BODY}}`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// DATA NODES - Values and operations
|
||||
// ============================================
|
||||
|
||||
export const DATA_NODES: NodeDefinition[] = [
|
||||
{
|
||||
type: 'number',
|
||||
category: 'data',
|
||||
label: 'Number',
|
||||
description: 'A number value',
|
||||
icon: 'Hash',
|
||||
color: CATEGORY_COLORS.data,
|
||||
inputs: [
|
||||
{ id: 'value', name: 'Value', type: 'number', defaultValue: 0 },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'value', name: 'Value', type: 'number' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `{{value}}`,
|
||||
uefn: `{{value}}`,
|
||||
spatial: `{{value}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
category: 'data',
|
||||
label: 'Text',
|
||||
description: 'A text value',
|
||||
icon: 'Type',
|
||||
color: CATEGORY_COLORS.data,
|
||||
inputs: [
|
||||
{ id: 'value', name: 'Value', type: 'string', defaultValue: '' },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'value', name: 'Value', type: 'string' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `"{{value}}"`,
|
||||
uefn: `"{{value}}"`,
|
||||
spatial: `"{{value}}"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
category: 'data',
|
||||
label: 'Boolean',
|
||||
description: 'True or False',
|
||||
icon: 'ToggleLeft',
|
||||
color: CATEGORY_COLORS.data,
|
||||
inputs: [
|
||||
{ id: 'value', name: 'Value', type: 'boolean', defaultValue: true },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'value', name: 'Value', type: 'boolean' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `{{value}}`,
|
||||
uefn: `{{value}}`,
|
||||
spatial: `{{value}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'vector3',
|
||||
category: 'data',
|
||||
label: 'Vector3',
|
||||
description: '3D position/direction',
|
||||
icon: 'Move3d',
|
||||
color: CATEGORY_COLORS.data,
|
||||
inputs: [
|
||||
{ id: 'x', name: 'X', type: 'number', defaultValue: 0 },
|
||||
{ id: 'y', name: 'Y', type: 'number', defaultValue: 0 },
|
||||
{ id: 'z', name: 'Z', type: 'number', defaultValue: 0 },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'vector', name: 'Vector', type: 'object' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `Vector3.new({{x}}, {{y}}, {{z}})`,
|
||||
uefn: `vector3{X := {{x}}, Y := {{y}}, Z := {{z}}}`,
|
||||
spatial: `new Vector3({{x}}, {{y}}, {{z}})`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'color',
|
||||
category: 'data',
|
||||
label: 'Color',
|
||||
description: 'RGB color value',
|
||||
icon: 'Palette',
|
||||
color: CATEGORY_COLORS.data,
|
||||
inputs: [
|
||||
{ id: 'r', name: 'Red', type: 'number', defaultValue: 255 },
|
||||
{ id: 'g', name: 'Green', type: 'number', defaultValue: 255 },
|
||||
{ id: 'b', name: 'Blue', type: 'number', defaultValue: 255 },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'color', name: 'Color', type: 'object' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `Color3.fromRGB({{r}}, {{g}}, {{b}})`,
|
||||
uefn: `MakeColor({{r}}, {{g}}, {{b}})`,
|
||||
spatial: `new Color({{r}}, {{g}}, {{b}})`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'math',
|
||||
category: 'data',
|
||||
label: 'Math',
|
||||
description: 'Mathematical operation',
|
||||
icon: 'Calculator',
|
||||
color: CATEGORY_COLORS.data,
|
||||
inputs: [
|
||||
{ id: 'a', name: 'A', type: 'number', required: true },
|
||||
{ id: 'b', name: 'B', type: 'number', required: true },
|
||||
{ id: 'operation', name: 'Operation', type: 'string', defaultValue: 'add' },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'result', name: 'Result', type: 'number' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `({{a}} {{OPERATION}} {{b}})`,
|
||||
uefn: `({{a}} {{OPERATION}} {{b}})`,
|
||||
spatial: `({{a}} {{OPERATION}} {{b}})`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'compare',
|
||||
category: 'data',
|
||||
label: 'Compare',
|
||||
description: 'Compare two values',
|
||||
icon: 'Scale',
|
||||
color: CATEGORY_COLORS.data,
|
||||
inputs: [
|
||||
{ id: 'a', name: 'A', type: 'any', required: true },
|
||||
{ id: 'b', name: 'B', type: 'any', required: true },
|
||||
{ id: 'comparison', name: 'Comparison', type: 'string', defaultValue: 'equals' },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'result', name: 'Result', type: 'boolean' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `({{a}} {{COMPARISON}} {{b}})`,
|
||||
uefn: `({{a}} {{COMPARISON}} {{b}})`,
|
||||
spatial: `({{a}} {{COMPARISON}} {{b}})`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'random',
|
||||
category: 'data',
|
||||
label: 'Random',
|
||||
description: 'Random number in range',
|
||||
icon: 'Dices',
|
||||
color: CATEGORY_COLORS.data,
|
||||
inputs: [
|
||||
{ id: 'min', name: 'Min', type: 'number', defaultValue: 1 },
|
||||
{ id: 'max', name: 'Max', type: 'number', defaultValue: 100 },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'value', name: 'Value', type: 'number' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `math.random({{min}}, {{max}})`,
|
||||
uefn: `GetRandomInt({{min}}, {{max}})`,
|
||||
spatial: `Math.floor(Math.random() * ({{max}} - {{min}} + 1)) + {{min}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'variable',
|
||||
category: 'data',
|
||||
label: 'Variable',
|
||||
description: 'Store and retrieve a value',
|
||||
icon: 'Variable',
|
||||
color: CATEGORY_COLORS.data,
|
||||
inputs: [
|
||||
{ id: 'name', name: 'Name', type: 'string', required: true },
|
||||
{ id: 'setValue', name: 'Set Value', type: 'any' },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'getValue', name: 'Get Value', type: 'any' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `{{name}}`,
|
||||
uefn: `{{name}}`,
|
||||
spatial: `{{name}}`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// REFERENCE NODES - Game objects
|
||||
// ============================================
|
||||
|
||||
export const REFERENCE_NODES: NodeDefinition[] = [
|
||||
{
|
||||
type: 'getPlayer',
|
||||
category: 'references',
|
||||
label: 'Get Player',
|
||||
description: 'Get a player by name or index',
|
||||
icon: 'User',
|
||||
color: CATEGORY_COLORS.references,
|
||||
inputs: [
|
||||
{ id: 'name', name: 'Name (optional)', type: 'string' },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'player', name: 'Player', type: 'object' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `game.Players:FindFirstChild("{{name}}")`,
|
||||
uefn: `GetPlayer[{{name}}]`,
|
||||
spatial: `SpaceService.getPlayerByName("{{name}}")`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'getAllPlayers',
|
||||
category: 'references',
|
||||
label: 'Get All Players',
|
||||
description: 'Get list of all players',
|
||||
icon: 'Users',
|
||||
color: CATEGORY_COLORS.references,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ id: 'players', name: 'Players', type: 'array' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `game.Players:GetPlayers()`,
|
||||
uefn: `GetPlayspace().GetPlayers()`,
|
||||
spatial: `SpaceService.getAllPlayers()`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'findChild',
|
||||
category: 'references',
|
||||
label: 'Find Child',
|
||||
description: 'Find a child object by name',
|
||||
icon: 'Search',
|
||||
color: CATEGORY_COLORS.references,
|
||||
inputs: [
|
||||
{ id: 'parent', name: 'Parent', type: 'object', required: true },
|
||||
{ id: 'name', name: 'Name', type: 'string', required: true },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'child', name: 'Child', type: 'object' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `{{parent}}:FindFirstChild("{{name}}")`,
|
||||
uefn: `{{parent}}.GetChildren().Find("{{name}}")`,
|
||||
spatial: `{{parent}}.getChildByName("{{name}}")`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'workspace',
|
||||
category: 'references',
|
||||
label: 'Workspace',
|
||||
description: 'The game workspace/world',
|
||||
icon: 'Globe',
|
||||
color: CATEGORY_COLORS.references,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ id: 'workspace', name: 'Workspace', type: 'object' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `workspace`,
|
||||
uefn: `GetPlayspace()`,
|
||||
spatial: `World`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'getService',
|
||||
category: 'references',
|
||||
label: 'Get Service',
|
||||
description: 'Get a game service',
|
||||
icon: 'Server',
|
||||
color: CATEGORY_COLORS.references,
|
||||
inputs: [
|
||||
{ id: 'service', name: 'Service Name', type: 'string', required: true },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'service', name: 'Service', type: 'object' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `game:GetService("{{service}}")`,
|
||||
uefn: `Get{{service}}Service()`,
|
||||
spatial: `{{service}}Service`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Combine all nodes
|
||||
export const ALL_NODES: NodeDefinition[] = [
|
||||
...EVENT_NODES,
|
||||
...LOGIC_NODES,
|
||||
...ACTION_NODES,
|
||||
...DATA_NODES,
|
||||
...REFERENCE_NODES,
|
||||
];
|
||||
|
||||
// Get nodes by category
|
||||
export function getNodesByCategory(category: NodeCategory): NodeDefinition[] {
|
||||
return ALL_NODES.filter(n => n.category === category);
|
||||
}
|
||||
|
||||
// Get node by type
|
||||
export function getNodeDefinition(type: string): NodeDefinition | undefined {
|
||||
return ALL_NODES.find(n => n.type === type);
|
||||
}
|
||||
|
||||
// Get nodes for a specific platform
|
||||
export function getNodesForPlatform(platform: 'roblox' | 'uefn' | 'spatial'): NodeDefinition[] {
|
||||
return ALL_NODES.filter(n => n.platforms.includes(platform));
|
||||
}
|
||||
213
src/stores/visual-script-store.ts
Normal file
213
src/stores/visual-script-store.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
/**
|
||||
* AeThex Visual Scripting - State Store
|
||||
* Zustand store for managing visual script state
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import {
|
||||
Node,
|
||||
Edge,
|
||||
Connection,
|
||||
addEdge,
|
||||
applyNodeChanges,
|
||||
applyEdgeChanges,
|
||||
NodeChange,
|
||||
EdgeChange,
|
||||
} from 'reactflow';
|
||||
import { NodeData } from '../lib/visual-scripting/code-generator';
|
||||
|
||||
interface VisualScriptState {
|
||||
// Current script data
|
||||
nodes: Node<NodeData>[];
|
||||
edges: Edge[];
|
||||
|
||||
// UI state
|
||||
selectedNodes: string[];
|
||||
selectedEdges: string[];
|
||||
|
||||
// Generated code
|
||||
generatedCode: string;
|
||||
|
||||
// History for undo/redo
|
||||
history: { nodes: Node<NodeData>[]; edges: Edge[] }[];
|
||||
historyIndex: number;
|
||||
|
||||
// Actions
|
||||
setNodes: (nodes: Node<NodeData>[]) => void;
|
||||
setEdges: (edges: Edge[]) => void;
|
||||
onNodesChange: (changes: NodeChange[]) => void;
|
||||
onEdgesChange: (changes: EdgeChange[]) => void;
|
||||
onConnect: (connection: Connection) => void;
|
||||
addNode: (node: Node<NodeData>) => void;
|
||||
removeNode: (nodeId: string) => void;
|
||||
updateNodeData: (nodeId: string, data: Partial<NodeData>) => void;
|
||||
updateNodeValue: (nodeId: string, key: string, value: any) => void;
|
||||
setSelectedNodes: (nodeIds: string[]) => void;
|
||||
setSelectedEdges: (edgeIds: string[]) => void;
|
||||
setGeneratedCode: (code: string) => void;
|
||||
clearScript: () => void;
|
||||
saveToHistory: () => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
loadScript: (nodes: Node<NodeData>[], edges: Edge[]) => void;
|
||||
}
|
||||
|
||||
export const useVisualScriptStore = create<VisualScriptState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
selectedNodes: [],
|
||||
selectedEdges: [],
|
||||
generatedCode: '',
|
||||
history: [],
|
||||
historyIndex: -1,
|
||||
|
||||
setNodes: (nodes) => set({ nodes }),
|
||||
|
||||
setEdges: (edges) => set({ edges }),
|
||||
|
||||
onNodesChange: (changes) => {
|
||||
set({
|
||||
nodes: applyNodeChanges(changes, get().nodes) as Node<NodeData>[],
|
||||
});
|
||||
},
|
||||
|
||||
onEdgesChange: (changes) => {
|
||||
set({
|
||||
edges: applyEdgeChanges(changes, get().edges),
|
||||
});
|
||||
},
|
||||
|
||||
onConnect: (connection) => {
|
||||
// Validate connection types could go here
|
||||
set({
|
||||
edges: addEdge(
|
||||
{
|
||||
...connection,
|
||||
id: `edge-${Date.now()}`,
|
||||
type: 'smoothstep',
|
||||
animated: connection.sourceHandle === 'flow',
|
||||
},
|
||||
get().edges
|
||||
),
|
||||
});
|
||||
get().saveToHistory();
|
||||
},
|
||||
|
||||
addNode: (node) => {
|
||||
set({ nodes: [...get().nodes, node] });
|
||||
get().saveToHistory();
|
||||
},
|
||||
|
||||
removeNode: (nodeId) => {
|
||||
set({
|
||||
nodes: get().nodes.filter((n) => n.id !== nodeId),
|
||||
edges: get().edges.filter(
|
||||
(e) => e.source !== nodeId && e.target !== nodeId
|
||||
),
|
||||
});
|
||||
get().saveToHistory();
|
||||
},
|
||||
|
||||
updateNodeData: (nodeId, data) => {
|
||||
set({
|
||||
nodes: get().nodes.map((node) =>
|
||||
node.id === nodeId
|
||||
? { ...node, data: { ...node.data, ...data } }
|
||||
: node
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
updateNodeValue: (nodeId, key, value) => {
|
||||
set({
|
||||
nodes: get().nodes.map((node) =>
|
||||
node.id === nodeId
|
||||
? {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
values: {
|
||||
...node.data.values,
|
||||
[key]: value,
|
||||
},
|
||||
},
|
||||
}
|
||||
: node
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
setSelectedNodes: (nodeIds) => set({ selectedNodes: nodeIds }),
|
||||
|
||||
setSelectedEdges: (edgeIds) => set({ selectedEdges: edgeIds }),
|
||||
|
||||
setGeneratedCode: (code) => set({ generatedCode: code }),
|
||||
|
||||
clearScript: () => {
|
||||
set({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
selectedNodes: [],
|
||||
selectedEdges: [],
|
||||
generatedCode: '',
|
||||
});
|
||||
get().saveToHistory();
|
||||
},
|
||||
|
||||
saveToHistory: () => {
|
||||
const { nodes, edges, history, historyIndex } = get();
|
||||
const newHistory = history.slice(0, historyIndex + 1);
|
||||
newHistory.push({ nodes: [...nodes], edges: [...edges] });
|
||||
|
||||
// Keep only last 50 states
|
||||
if (newHistory.length > 50) {
|
||||
newHistory.shift();
|
||||
}
|
||||
|
||||
set({
|
||||
history: newHistory,
|
||||
historyIndex: newHistory.length - 1,
|
||||
});
|
||||
},
|
||||
|
||||
undo: () => {
|
||||
const { history, historyIndex } = get();
|
||||
if (historyIndex > 0) {
|
||||
const prevState = history[historyIndex - 1];
|
||||
set({
|
||||
nodes: prevState.nodes,
|
||||
edges: prevState.edges,
|
||||
historyIndex: historyIndex - 1,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
redo: () => {
|
||||
const { history, historyIndex } = get();
|
||||
if (historyIndex < history.length - 1) {
|
||||
const nextState = history[historyIndex + 1];
|
||||
set({
|
||||
nodes: nextState.nodes,
|
||||
edges: nextState.edges,
|
||||
historyIndex: historyIndex + 1,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
loadScript: (nodes, edges) => {
|
||||
set({ nodes, edges });
|
||||
get().saveToHistory();
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'aethex-visual-script',
|
||||
partialize: (state) => ({
|
||||
nodes: state.nodes,
|
||||
edges: state.edges,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
Loading…
Reference in a new issue