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:
Claude 2026-01-23 22:53:59 +00:00
parent 4fa6d0c3ed
commit 6aff5ac183
No known key found for this signature in database
9 changed files with 3120 additions and 30 deletions

716
package-lock.json generated
View file

@ -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"

View file

@ -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",

View file

@ -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>

View file

@ -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>

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

View 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">&gt; Greater</SelectItem>
<SelectItem value="less">&lt; 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>
);
}
}

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

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

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