Compare commits

...

8 commits

Author SHA1 Message Date
Claude
ffd3140fc8
docs: Add comprehensive visual feature guide with ASCII mockups
- Document main interface layout and components
- Add visual representations for all major features:
  - Visual Scripting node editor
  - Asset Library with grid/list views
  - 3D Live Preview with console
  - AI Code Generator with templates
  - Real-time Collaboration panel
- Include color scheme documentation
- Show responsive design breakpoints
- Provide ASCII art mockups for each feature
2026-01-24 04:08:16 +00:00
Claude
c08627a561
feat: Add Real-time Collaboration infrastructure
- Create collaboration types (CollaboratorInfo, Session, Chat, Permissions)
- Build collaboration store with Zustand and socket.io integration
- Add cursor/selection sharing, chat messaging, and typing indicators
- Include permission system (viewer, editor, admin, owner)
- Add mock mode for demo/testing without server
- Support follow mode and session settings
2026-01-24 02:57:50 +00:00
Claude
9c54fb3386
feat: Add AI Code Generation v2 with system templates and snippets
- Create comprehensive system templates (inventory, quests, currency, combat, friends)
- Add platform-specific code patterns and snippets for Roblox/UEFN/Spatial
- Build AIGenerationPanel component with template browser and parameter editor
- Add configurable generation with sliders, switches, and dropdowns
- Include code snippets library with copy/insert functionality
- Integrate AI Generate button into Toolbar (desktop and mobile)
- Support custom AI generation prompts (placeholder for future AI integration)
2026-01-23 23:12:28 +00:00
Claude
159e40f02c
feat: Add Live Game Preview with 3D viewport and Lua interpreter
- Create mock Roblox API (Vector3, Color3, CFrame, TweenService, RunService)
- Implement Lua-to-JavaScript transpiler for basic Roblox script execution
- Build 3D viewport using React Three Fiber with shadows, grid, and controls
- Add preview console with filtering, search, and output types
- Create LivePreview component with run/stop/pause controls and settings
- Add 3D Preview button to Toolbar (desktop and mobile)
- Fix pre-existing syntax error in FileTree.tsx toggleFolder function
2026-01-23 23:06:16 +00:00
Claude
5feb186c05
feat: Add Asset Library system for models, textures, and audio management
- Add asset types, categories, and file format mappings (src/lib/assets/types.ts)
- Create Zustand store with filtering, sorting, favorites support (src/stores/asset-store.ts)
- Build full Asset Library UI with grid/list views, drag-drop upload, details panel
- Integrate Asset Library button into Toolbar (desktop and mobile menus)
- Add lazy-loaded AssetLibrary modal to App.tsx
2026-01-23 22:58:30 +00:00
Claude
6aff5ac183
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
2026-01-23 22:53:59 +00:00
Claude
4fa6d0c3ed
docs: Add comprehensive product roadmap and feature architecture
- Full product vision from basic IDE to game dev ecosystem
- 8-phase roadmap covering visual scripting, marketplace, AI, analytics
- Detailed architecture for priority features
- Revenue model and competitive positioning
- Tech stack evolution plan
- Success metrics and timeline
2026-01-23 22:38:27 +00:00
Claude
96163c8256
feat: Add AeThex cross-platform avatar toolkit
Implement comprehensive avatar import/export/rigging system supporting:
- Roblox (R6, R15, Rthro), VRChat, RecRoom, Spatial, Sandbox
- Decentraland, Meta Horizon, NeosVR, Resonite, ChilloutVR
- Universal AeThex format for lossless cross-platform conversion

Features:
- Platform-specific skeleton specs and bone mappings
- Auto-rig detection and universal bone name resolution
- Format handlers for GLB, GLTF, FBX, VRM, OBJ, PMX
- Validation against platform constraints (polygons, bones, textures)
- Avatar templates and optimization presets
- Compatibility scoring between platforms
2026-01-23 22:09:25 +00:00
35 changed files with 15469 additions and 141 deletions

503
ROADMAP.md Normal file
View file

@ -0,0 +1,503 @@
# AeThex Studio - Product Roadmap & Vision
## Current State: Basic IDE
What we have now is essentially a code editor with some templates. That's table stakes.
## Vision: The Unity/Unreal for Cross-Platform Metaverse Development
AeThex should be the **one-stop platform** where creators build once and deploy everywhere - with visual tools, AI assistance, marketplace, collaboration, and analytics.
---
## PHASE 1: Core IDE Enhancement (Foundation)
### 1.1 Visual Scripting System
**Why**: 70% of Roblox creators are visual learners / non-coders
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ On Player │───▶│ Check if │───▶│ Give Item │
│ Touch Part │ │ Has Pass │ │ to Player │
└─────────────┘ └─────────────┘ └─────────────┘
```
- Node-based editor (like Unreal Blueprints)
- Drag-and-drop logic blocks
- Auto-generates Lua/Verse/TS code
- Bi-directional: edit nodes OR code
### 1.2 Live Game Preview
**Why**: Can't test code without leaving the IDE
- Embedded Roblox-like 3D viewport
- Hot-reload code changes
- Console output in real-time
- Breakpoints and step debugging
- Mobile device preview mode
### 1.3 Real-time Collaboration
**Why**: Teams need to work together
- Google Docs-style multiplayer editing
- Cursor presence (see teammates typing)
- Voice/video chat integration
- Code review comments inline
- Conflict resolution
### 1.4 Version Control (Built-in Git)
**Why**: Professional teams need proper VCS
- Visual git interface (no command line needed)
- Branch visualization
- Pull request workflow
- Auto-commit on save (optional)
- Integration with GitHub/GitLab
---
## PHASE 2: Asset Pipeline & Creation Tools
### 2.1 3D Scene Editor
**Why**: Visual level design is essential
```
┌────────────────────────────────────────────────┐
│ Scene View │ Inspector │
│ ┌──────────────────────────┐ │ ┌────────────┐ │
│ │ 🎮 │ │ │ Transform │ │
│ │ ┌───┐ │ │ │ X: 0 Y: 5 │ │
│ │ │ │ 👤 │ │ │ Z: 10 │ │
│ │ └───┘ │ │ ├────────────┤ │
│ │ ████████████ │ │ │ Scripts │ │
│ └──────────────────────────┘ │ │ + Add │ │
│ Hierarchy │ Assets │ └────────────┘ │
└────────────────────────────────────────────────┘
```
- Drag-drop object placement
- Terrain editor
- Lighting system
- Physics simulation
- Cross-platform export (Roblox .rbxl, UEFN .umap)
### 2.2 Asset Library & Manager
**Why**: Games need models, textures, sounds
- Built-in asset browser
- Import: FBX, OBJ, GLB, PNG, WAV, MP3
- Auto-optimization per platform
- Asset variants (LODs, mobile versions)
- AI-powered asset generation (text-to-3D, text-to-texture)
### 2.3 Animation Editor
**Why**: Bring characters to life
- Timeline-based animation
- Skeletal animation support
- Blend trees and state machines
- Animation retargeting (apply human anim to any rig)
- Procedural animation tools
### 2.4 Particle System Editor
**Why**: Visual effects are crucial
- Visual particle editor
- Real-time preview
- Presets library (fire, smoke, magic, etc.)
- Cross-platform particle export
### 2.5 Audio Workstation (Basic)
**Why**: Sound design matters
- Waveform editor
- Spatial audio setup
- Music sequencer (simple)
- Sound effect library
- Adaptive audio triggers
---
## PHASE 3: AI-Powered Development
### 3.1 AI Code Generation (Not Just Translation)
**Why**: "Build me a combat system" should work
```
User: "Create a round-based game system with 3 minute rounds,
team scoring, and a lobby between rounds"
AI: [Generates complete system with:]
- RoundManager.lua
- TeamScoring.lua
- LobbySystem.lua
- UI components
- All connected and working
```
### 3.2 AI Game Designer
**Why**: Help with game design, not just code
- "What mechanics would make this more fun?"
- "Balance these weapon stats"
- "Suggest monetization that isn't predatory"
- Playtest analysis and suggestions
### 3.3 AI Asset Generation
**Why**: Not everyone can do art
- Text-to-3D model generation
- Text-to-texture
- AI music generation
- AI sound effects
- Style transfer (make assets match your game's style)
### 3.4 AI Bug Detection
**Why**: Find issues before players do
- Static analysis with AI reasoning
- "This loop might cause lag because..."
- Security vulnerability detection
- Memory leak detection
- Cross-platform compatibility warnings
### 3.5 AI Documentation
**Why**: Nobody likes writing docs
- Auto-generate code comments
- API documentation from code
- Tutorial generation from codebase
- Changelog generation from commits
---
## PHASE 4: Marketplace & Economy
### 4.1 AeThex Marketplace
**Why**: Creators want to monetize; buyers want shortcuts
```
┌─────────────────────────────────────────────────────┐
│ 🏪 AeThex Marketplace [Search] │
├─────────────────────────────────────────────────────┤
│ Featured │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Combat │ │ Vehicle │ │ Fantasy │ │ UI Kit │ │
│ │ System │ │ Physics │ │ Assets │ │ Pro │ │
│ │ ⭐4.9 │ │ ⭐4.7 │ │ ⭐4.8 │ │ ⭐5.0 │ │
│ │ $15 │ │ $25 │ │ $40 │ │ $10 │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ Categories: Scripts | 3D Models | UI | Audio | FX │
└─────────────────────────────────────────────────────┘
```
- Sell scripts, assets, templates, full game kits
- Revenue split (80/20 creator/AeThex)
- Ratings and reviews
- License management
- Cross-platform asset conversion included
### 4.2 Creator Subscriptions
**Why**: Recurring revenue for top creators
- Creators can offer subscription access
- Patreon-style tiers
- Early access to new content
- Direct support/consulting
### 4.3 Commission System
**Why**: Connect creators with developers who need custom work
- Post job: "Need inventory system, $500 budget"
- Verified creators can bid
- Escrow payment system
- Portfolio and reviews
---
## PHASE 5: Deployment & Analytics
### 5.1 One-Click Deploy
**Why**: Getting code into games is painful
```
┌─────────────────────────────────────────┐
│ 🚀 Deploy to Platform │
├─────────────────────────────────────────┤
│ ☑ Roblox [Connected] [Deploy] │
│ ☑ UEFN [Connected] [Deploy] │
│ ☐ Spatial [Connect Account] │
│ ☐ Core Games [Connect Account] │
├─────────────────────────────────────────┤
│ Deploy Options: │
│ ☑ Run validation checks │
│ ☑ Optimize for platform │
│ ☐ Deploy to staging first │
└─────────────────────────────────────────┘
```
- OAuth with each platform
- Direct publish to Roblox experiences
- UEFN island deployment
- Automatic platform optimization
- Rollback support
### 5.2 CI/CD Pipeline
**Why**: Professional development workflow
- Automated testing on commit
- Lint and format checks
- Cross-platform compatibility tests
- Staging → Production workflow
- Scheduled deployments
### 5.3 Analytics Dashboard
**Why**: Data-driven game development
```
┌─────────────────────────────────────────────────────┐
│ 📊 Game Analytics - "Epic Battle Sim" │
├─────────────────────────────────────────────────────┤
│ Players (7 days) Revenue (7 days) │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ 📈 +23% │ │ 📈 +15% │ │
│ │ ╲ │ │ ╱╲ │ │
│ │ ╲│ │ ╲ │ │
│ │╱ │ │ ╲ │ │
│ └─────────────────┘ └─────────────────┘ │
│ 45.2K daily active $12.4K this week │
├─────────────────────────────────────────────────────┤
│ Retention │ Monetization │ Performance │
│ D1: 42% │ ARPU: $0.28 │ Avg FPS: 58 │
│ D7: 18% │ Conv: 3.2% │ Crash rate: 0.1% │
│ D30: 8% │ LTV: $1.85 │ Load time: 2.3s │
└─────────────────────────────────────────────────────┘
```
- Cross-platform unified analytics
- Player behavior tracking
- Funnel analysis
- A/B testing built-in
- Revenue tracking
- Performance monitoring
- Error tracking (Sentry integration)
### 5.4 Performance Profiler
**Why**: Optimize before deploying
- CPU/GPU usage graphs
- Memory allocation tracking
- Script execution time
- Network usage (for multiplayer)
- Platform-specific bottleneck detection
- "This function runs 500x/second, consider caching"
---
## PHASE 6: Multiplayer & Backend
### 6.1 Backend-as-a-Service
**Why**: Multiplayer games need servers
- Managed game servers
- Real-time databases (like Firebase)
- Player authentication
- Leaderboards
- Matchmaking
- Cloud saves
- No server code needed (visual configuration)
### 6.2 Multiplayer Testing
**Why**: Can't test multiplayer alone
- Spawn multiple test clients
- Simulate latency/packet loss
- Record and replay sessions
- Bot players for testing
### 6.3 Economy Management
**Why**: In-game currencies are complex
- Virtual currency management
- Item database
- Trading system
- Anti-cheat for economy
- Cross-platform wallet
---
## PHASE 7: Community & Learning
### 7.1 Learning Platform
**Why**: Grow the next generation
```
┌─────────────────────────────────────────┐
│ 🎓 AeThex Academy │
├─────────────────────────────────────────┤
│ Your Progress: Level 12 | 2,400 XP │
│ ████████████░░░░░░░░ 60% to Level 13 │
├─────────────────────────────────────────┤
│ Current Course: Combat Systems │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ ✅ │ │ ✅ │ │ 🔵 │ │ ⚪ │ │
│ │ 1 │ │ 2 │ │ 3 │ │ 4 │ │
│ └─────┘ └─────┘ └─────┘ └─────┘ │
│ Lesson 3: Hitboxes and Damage │
├─────────────────────────────────────────┤
│ 🏆 Achievements │
│ [First Script] [100 Lines] [Published] │
└─────────────────────────────────────────┘
```
- Interactive coding lessons
- Video tutorials
- Challenges with XP rewards
- Certifications
- Mentorship matching
### 7.2 Community Hub
**Why**: Creators need community
- Project showcases
- Forums / Discord integration
- Game jams (hosted by AeThex)
- Creator spotlights
- Job board
### 7.3 Creator Certification Program
**Why**: Verified expertise matters
- Skill assessments
- Platform-specific certifications
- Verified badges on marketplace
- Enterprise hiring pipeline
---
## PHASE 8: Enterprise & Teams
### 8.1 Team Workspaces
**Why**: Studios need organization
- Organization accounts
- Role-based permissions
- Project organization
- Shared asset libraries
- Team analytics
### 8.2 Enterprise Features
**Why**: Big studios have big needs
- SSO/SAML integration
- Audit logs
- Compliance tools
- Dedicated support
- Custom SLAs
- On-premise option
### 8.3 White-Label Solution
**Why**: Some companies want their own version
- Branded IDE for game studios
- Custom template libraries
- Internal marketplaces
- Integration with existing tools
---
## Revenue Model
### Free Tier
- Full IDE access
- 3 projects
- Community support
- Basic templates
- 1GB asset storage
### Pro ($15/mo)
- Unlimited projects
- AI code generation (100 requests/day)
- Advanced analytics
- Priority support
- 50GB asset storage
- Marketplace selling (85/15 split)
### Team ($40/user/mo)
- Everything in Pro
- Real-time collaboration
- Team workspaces
- Version control
- CI/CD pipelines
- 200GB shared storage
### Enterprise (Custom)
- Everything in Team
- SSO/SAML
- Dedicated support
- Custom SLAs
- Unlimited storage
- On-premise option
---
## Tech Stack Evolution
### Current
```
Next.js → React → Monaco Editor → localStorage
```
### Target
```
┌─────────────────────────────────────────────────────┐
│ FRONTEND │
│ Next.js │ React │ Three.js │ Monaco │ Y.js (CRDT) │
├─────────────────────────────────────────────────────┤
│ BACKEND │
│ Node.js │ tRPC │ Prisma │ PostgreSQL │ Redis │
├─────────────────────────────────────────────────────┤
│ INFRASTRUCTURE │
│ Vercel │ AWS S3 │ CloudFlare │ Planetscale │
├─────────────────────────────────────────────────────┤
│ SERVICES │
│ Stripe │ Clerk Auth │ Liveblocks │ Sentry │ Posthog│
├─────────────────────────────────────────────────────┤
│ AI │
│ Claude API │ OpenAI │ Replicate (3D Gen) │
└─────────────────────────────────────────────────────┘
```
---
## Priority Implementation Order
### NOW (Next 2-3 months)
1. ✅ Avatar Toolkit (DONE)
2. 🔄 Visual Scripting MVP
3. 🔄 Live Game Preview (embedded)
4. 🔄 Asset Library v1
### NEXT (3-6 months)
5. Real-time Collaboration
6. Built-in Git
7. AI Code Generation v2
8. Marketplace MVP
### LATER (6-12 months)
9. Scene Editor
10. Animation Editor
11. Analytics Dashboard
12. One-Click Deploy
13. Backend-as-a-Service
### FUTURE (12+ months)
14. Enterprise Features
15. Learning Platform
16. Full Marketplace
17. Mobile App
---
## Competitive Positioning
| Feature | Roblox Studio | Unity | Unreal | AeThex |
|---------|--------------|-------|--------|--------|
| Browser-based | ❌ | ❌ | ❌ | ✅ |
| Cross-platform export | ❌ | Partial | ❌ | ✅ |
| AI code translation | ❌ | ❌ | ❌ | ✅ |
| Visual scripting | ❌ | ✅ | ✅ | 🔄 |
| Built-in marketplace | ✅ | ✅ | ✅ | 🔄 |
| Real-time collab | ❌ | Partial | ❌ | 🔄 |
| Free tier | ✅ | ✅ | ✅ | ✅ |
| Learning built-in | Partial | ❌ | ❌ | 🔄 |
**Our edge**: Browser-based + Cross-platform + AI-native
---
## Success Metrics
### Year 1
- 50,000 registered users
- 5,000 monthly active creators
- 500 marketplace listings
- $100K GMV on marketplace
### Year 2
- 250,000 registered users
- 25,000 monthly active creators
- 5,000 marketplace listings
- $1M GMV on marketplace
- 100 paying teams
### Year 3
- 1M registered users
- 100,000 monthly active creators
- 25,000 marketplace listings
- $10M GMV on marketplace
- 500 paying teams
- 10 enterprise clients

View file

@ -0,0 +1,464 @@
# AeThex Studio - Feature Architecture
## Immediate Priority Features (Next Sprint)
---
## 1. VISUAL SCRIPTING SYSTEM
### Why It's Critical
- 70% of Roblox creators prefer visual tools
- Lowers barrier to entry massively
- Differentiator from text-only IDEs
### Architecture
```
src/
├── components/
│ └── visual-scripting/
│ ├── VisualScriptingCanvas.tsx # Main React Flow canvas
│ ├── NodePalette.tsx # Draggable node library
│ ├── nodes/
│ │ ├── EventNodes.tsx # OnPlayerJoin, OnTouch, etc.
│ │ ├── LogicNodes.tsx # If, Loop, Wait, etc.
│ │ ├── ActionNodes.tsx # SetProperty, Destroy, etc.
│ │ ├── DataNodes.tsx # Variables, Math, etc.
│ │ └── CustomNodes.tsx # User-defined functions
│ ├── NodeInspector.tsx # Edit node properties
│ ├── ConnectionLine.tsx # Custom edge rendering
│ └── CodePreview.tsx # Live Lua/Verse output
├── lib/
│ └── visual-scripting/
│ ├── node-definitions.ts # All node type definitions
│ ├── code-generator.ts # Nodes → Code conversion
│ ├── code-parser.ts # Code → Nodes (reverse)
│ └── validation.ts # Check for errors
```
### Node Types
```typescript
// Event Nodes (Green) - Entry points
- OnPlayerJoin
- OnPlayerLeave
- OnPartTouch
- OnClick
- OnKeyPress
- OnTimer
- OnValueChange
// Logic Nodes (Blue) - Control flow
- If/Else
- For Loop
- While Loop
- Wait/Delay
- Random Branch
- Switch/Case
// Action Nodes (Purple) - Do things
- Print/Log
- SetProperty
- CreatePart
- DestroyObject
- PlaySound
- TweenProperty
- FireEvent
- CallFunction
// Data Nodes (Orange) - Values
- Number
- String
- Boolean
- Variable (Get/Set)
- MathOperation
- StringOperation
- TableOperation
// Reference Nodes (Yellow) - Game objects
- GetPlayer
- GetPart
- FindFirstChild
- GetService
- GetChildren
```
### Tech Stack
- **React Flow** - Node canvas library
- **Zustand** - State management for nodes
- **Monaco** - Side-by-side code preview
---
## 2. LIVE GAME PREVIEW
### Why It's Critical
- Can't test without leaving IDE = friction
- Immediate feedback loop is essential
- See changes in real-time
### Architecture
```
src/
├── components/
│ └── preview/
│ ├── GamePreview.tsx # Main preview container
│ ├── PreviewCanvas.tsx # Three.js 3D viewport
│ ├── PreviewControls.tsx # Play/Pause/Reset
│ ├── PreviewConsole.tsx # Output logs
│ ├── DeviceFrame.tsx # Phone/tablet mockup
│ └── PlayerSimulator.tsx # Fake player for testing
├── lib/
│ └── preview/
│ ├── lua-interpreter.ts # Run Lua in browser (Fengari)
│ ├── roblox-api-mock.ts # Mock Roblox APIs
│ ├── scene-manager.ts # 3D scene setup
│ └── hot-reload.ts # Update without restart
```
### Capabilities
```
┌─────────────────────────────────────────────────────┐
│ Preview [▶️] [⏸️] [🔄] │
├─────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 3D Game View │ │
│ │ │ │
│ │ [Cube] [Player] │ │
│ │ │ │
│ └─────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────┤
│ Console: │
│ > Player touched Part1 │
│ > Score updated: 10 │
│ > [Error] Line 15: attempt to index nil │
└─────────────────────────────────────────────────────┘
```
### Tech Stack
- **Three.js** - 3D rendering
- **Fengari** - Lua interpreter in JS
- **Roblox API Mocks** - Simulate game.Players, workspace, etc.
---
## 3. ASSET LIBRARY
### Why It's Critical
- Games need more than code
- Models, textures, sounds
- Professional workflow
### Architecture
```
src/
├── components/
│ └── assets/
│ ├── AssetLibrary.tsx # Main panel
│ ├── AssetBrowser.tsx # Grid/list view
│ ├── AssetUploader.tsx # Drag-drop upload
│ ├── AssetPreview.tsx # 3D/2D/audio preview
│ ├── AssetInspector.tsx # Metadata, tags
│ └── AssetSearch.tsx # Search + filters
├── lib/
│ └── assets/
│ ├── asset-types.ts # Type definitions
│ ├── asset-processor.ts # Optimize on upload
│ ├── format-converter.ts # GLB→FBX, etc.
│ └── storage.ts # IndexedDB + cloud sync
```
### Supported Formats
```
3D Models: .glb, .gltf, .fbx, .obj
Textures: .png, .jpg, .webp, .svg
Audio: .mp3, .wav, .ogg
Data: .json, .csv
Animations: .fbx (with anims), .bvh
```
### Features
- Drag-drop upload
- Auto-optimization per platform
- AI tagging (auto-detect "sword", "tree", etc.)
- Version history
- Cloud sync across devices
- Platform-specific export (Roblox mesh → UEFN static mesh)
---
## 4. REAL-TIME COLLABORATION
### Why It's Critical
- Teams are the norm now
- Remote work is standard
- Google Docs set the expectation
### Architecture
```
src/
├── components/
│ └── collaboration/
│ ├── PresenceCursors.tsx # See teammate cursors
│ ├── CollabPanel.tsx # Who's online
│ ├── VoiceChat.tsx # Audio (optional)
│ ├── ChatSidebar.tsx # Text chat
│ └── CommentThread.tsx # Inline comments
├── lib/
│ └── collaboration/
│ ├── crdt.ts # Conflict-free sync (Yjs)
│ ├── presence.ts # Cursor positions
│ ├── awareness.ts # Who's editing what
│ └── websocket.ts # Real-time connection
```
### Tech Stack
- **Yjs** - CRDT for conflict-free editing
- **Liveblocks** or **PartyKit** - Real-time infrastructure
- **WebRTC** - Voice chat (optional)
---
## 5. MARKETPLACE
### Why It's Critical
- Monetization for creators
- Shortcuts for developers
- Platform stickiness (they stay for the ecosystem)
### Architecture
```
src/
├── components/
│ └── marketplace/
│ ├── MarketplaceBrowser.tsx # Browse listings
│ ├── ListingCard.tsx # Product preview
│ ├── ListingDetail.tsx # Full product page
│ ├── SellerDashboard.tsx # For sellers
│ ├── PurchaseFlow.tsx # Buy flow
│ └── ReviewSection.tsx # Ratings
├── lib/
│ └── marketplace/
│ ├── types.ts # Listing, Purchase, etc.
│ ├── search.ts # Algolia/Meilisearch
│ ├── payments.ts # Stripe integration
│ └── licensing.ts # License management
```
### Categories
```
Scripts & Systems
├── Combat Systems
├── Vehicle Physics
├── Inventory Systems
├── Dialogue Systems
├── Economy/Shop Systems
└── Admin Tools
3D Assets
├── Characters
├── Environments
├── Props
├── Vehicles
└── Effects
UI Kits
├── Menus
├── HUDs
├── Shops
└── Inventory UIs
Audio
├── Music
├── Sound Effects
└── Ambient
```
---
## 6. AI CODE GENERATION V2
### Why It's Critical
- Current AI is just translation
- "Build me X" is the dream
- Massive productivity boost
### Architecture
```
src/
├── lib/
│ └── ai/
│ ├── code-generator.ts # Generate from prompt
│ ├── code-explainer.ts # Explain code
│ ├── bug-detector.ts # Find issues
│ ├── optimizer.ts # Suggest improvements
│ ├── documentation.ts # Auto-generate docs
│ └── prompts/
│ ├── roblox-system.md # Roblox context
│ ├── uefn-system.md # UEFN context
│ └── generation-templates.md # Structured output
```
### Capabilities
```
User: "Create a round-based game with teams"
AI generates:
├── RoundManager.lua
│ ├── startRound()
│ ├── endRound()
│ ├── checkWinCondition()
│ └── Configuration (roundTime, teamSize)
├── TeamManager.lua
│ ├── assignTeams()
│ ├── balanceTeams()
│ └── getTeamScore()
├── ScoreboardUI.lua
│ └── Full UI with team scores
└── README.md
└── Setup instructions
[All files connected and working together]
```
---
## 7. ONE-CLICK DEPLOY
### Why It's Critical
- Getting code into games is painful
- Manual copy-paste is error-prone
- Professional workflow needs automation
### Architecture
```
src/
├── components/
│ └── deploy/
│ ├── DeployPanel.tsx # Main deploy UI
│ ├── PlatformConnection.tsx # OAuth with platforms
│ ├── DeployProgress.tsx # Real-time status
│ ├── DeployHistory.tsx # Past deployments
│ └── RollbackButton.tsx # Revert if needed
├── lib/
│ └── deploy/
│ ├── roblox-deploy.ts # Roblox Open Cloud API
│ ├── uefn-deploy.ts # UEFN deployment
│ ├── spatial-deploy.ts # Spatial deployment
│ ├── validator.ts # Pre-deploy checks
│ └── optimizer.ts # Platform optimization
```
### Flow
```
1. Connect accounts (OAuth)
└── Roblox: Open Cloud API key
└── UEFN: Epic Games login
└── Spatial: API token
2. Select target
└── Which game/experience
└── Which environment (staging/production)
3. Validate
└── Syntax check
└── Platform compatibility
└── Security scan
4. Deploy
└── Optimize code
└── Upload assets
└── Update game
5. Verify
└── Health check
└── Rollback if failed
```
---
## Implementation Priority Matrix
| Feature | Impact | Effort | Priority |
|---------|--------|--------|----------|
| Visual Scripting | 🔥🔥🔥 | ⚡⚡⚡ | P0 |
| Live Preview | 🔥🔥🔥 | ⚡⚡ | P0 |
| Asset Library | 🔥🔥 | ⚡⚡ | P1 |
| Real-time Collab | 🔥🔥🔥 | ⚡⚡⚡ | P1 |
| AI Generation v2 | 🔥🔥🔥 | ⚡⚡ | P1 |
| Marketplace | 🔥🔥🔥 | ⚡⚡⚡ | P2 |
| One-Click Deploy | 🔥🔥 | ⚡⚡⚡ | P2 |
---
## File Structure After Implementation
```
src/
├── components/
│ ├── ui/ # Primitives (existing)
│ ├── editor/ # Code editor (existing)
│ ├── visual-scripting/ # NEW: Node editor
│ ├── preview/ # NEW: Game preview
│ ├── assets/ # NEW: Asset management
│ ├── collaboration/ # NEW: Real-time collab
│ ├── marketplace/ # NEW: Asset store
│ ├── deploy/ # NEW: Deployment
│ └── avatar/ # EXISTS: Avatar toolkit
├── lib/
│ ├── platforms.ts # Existing
│ ├── templates.ts # Existing
│ ├── avatar-*.ts # Existing
│ ├── visual-scripting/ # NEW
│ ├── preview/ # NEW
│ ├── assets/ # NEW
│ ├── collaboration/ # NEW
│ ├── marketplace/ # NEW
│ ├── deploy/ # NEW
│ └── ai/ # NEW: Enhanced AI
├── hooks/
│ ├── use-visual-script.ts # NEW
│ ├── use-preview.ts # NEW
│ ├── use-collaboration.ts # NEW
│ └── use-assets.ts # NEW
└── stores/
├── editor-store.ts # Existing (Zustand)
├── visual-script-store.ts # NEW
├── asset-store.ts # NEW
└── collab-store.ts # NEW
```
---
## Dependencies to Add
```json
{
"dependencies": {
// Visual Scripting
"reactflow": "^11.10.0",
// 3D Preview
"@react-three/fiber": "^8.15.0",
"@react-three/drei": "^9.88.0",
"three": "^0.159.0",
// Lua Interpreter
"fengari-web": "^0.1.4",
// Real-time Collaboration
"yjs": "^13.6.0",
"@liveblocks/client": "^1.4.0",
"@liveblocks/react": "^1.4.0",
// Search
"meilisearch": "^0.35.0",
// Payments
"@stripe/stripe-js": "^2.2.0",
// State Management
"zustand": "^4.4.0",
"immer": "^10.0.0"
}
}
```

358
docs/VISUAL_GUIDE.md Normal file
View file

@ -0,0 +1,358 @@
# AeThex Studio - Visual Feature Guide
> 🎨 A comprehensive visual guide to AeThex Studio's interface and features
---
## 📐 Main Interface Layout
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ AeThex Studio [Platform ▼] [Toolbar Buttons]│
├────┬────────────────────────────────────────────────────────┬───────────────────┤
│ │ [script.lua] [main.lua] [+] │ │
│ A │ ─────────────────────────────────────────────────────── │ AI Chat │
│ C │ │ │
│ T │ 1 -- Welcome to AeThex Studio! │ ┌─────────────┐ │
│ I │ 2 -- Write your code here │ │ How can I │ │
│ V │ 3 │ │ help you? │ │
│ I │ 4 local Players = game:GetService("Players") │ └─────────────┘ │
│ T │ 5 │ │
│ Y │ 6 Players.PlayerAdded:Connect(function(player) │ [Your message] │
│ │ 7 print(player.Name .. " joined!") │ │
│ B │ 8 end) │ ───────────── │
│ A │ 9 │ Claude: I can │
│ R │ │ help you with │
│ │ Monaco Code Editor │ Roblox code... │
│ │ │ │
├────┼─────────────────────────────────────────────────────────┼───────────────────┤
│ │ Console / Terminal │ Education │
│ │ > run │ Panel │
│ │ ✓ Script executed successfully │ │
│ │ > _ │ [Tutorials] │
└────┴─────────────────────────────────────────────────────────┴───────────────────┘
```
**Layout Components:**
- **Activity Bar** (left): Quick access icons for Files, AI, Learn
- **File Tree**: Collapsible folder structure with drag-drop
- **Editor Tabs**: Multiple open files with close buttons
- **Code Editor**: Monaco editor with syntax highlighting
- **AI Chat**: Context-aware coding assistant
- **Console**: Terminal with CLI commands
- **Education Panel**: Tutorials and documentation
---
## 🔧 Toolbar Features
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ AeThex Studio │ [Roblox ▼] │ Translate │ Templates │ Visual │ Assets │ 3D │ AI │
│ │ │ │ │ Script │ │ │ │
└─────────────────────────────────────────────────────────────────────────────────┘
│ │ │ │ │ │ │
│ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼
Platform Cross-Platform Code Node-Based Model 3D AI
Selector Translation Library Editor Library View Gen
```
---
## 🎨 Visual Scripting Editor
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ Visual Scripting [Generate Code] │
├─────────────────────────────────────────────────────────────────────────────────┤
│ ┌─────────┐ │
│ │ EVENTS │ ┌──────────────────┐ │
│ ├─────────┤ │ 🎮 Player Joined │ │
│ │ Player │ │ ─────────────── │ ┌──────────────────┐ │
│ │ Joined │────▶│ player ●────────┼─────────▶│ 📝 Print │ │
│ │ │ └──────────────────┘ │ ─────────────── │ │
│ ├─────────┤ │ message: ●──────┤ │
│ │ On │ │ "Player joined!"│ │
│ │ Touch │ ┌──────────────────┐ └──────────────────┘ │
│ │ │ │ ⏱️ Wait │ │ │
│ ├─────────┤ │ ─────────────── │ ▼ │
│ │ Key │ │ seconds: [2] │ ┌──────────────────┐ │
│ │ Press │ │ ●───────────────┼─────────▶│ 💫 Tween │ │
│ └─────────┘ └──────────────────┘ │ ─────────────── │ │
│ │ target: ● │ │
│ ┌─────────┐ │ property: Size │ │
│ │ LOGIC │ │ value: [10,10] │ │
│ ├─────────┤ └──────────────────┘ │
│ │ Branch │ │
│ │ Loop │ [Mini Map] ┌───┐ │
│ │ Compare │ │ ▪ │ │
│ └─────────┘ └───┘ │
├─────────────────────────────────────────────────────────────────────────────────┤
│ Palette │ Events │ Logic │ Actions │ Data │ References │ [+ Add] │
└─────────────────────────────────────────────────────────────────────────────────┘
```
**Visual Scripting Features:**
- Drag-and-drop node placement
- 30+ node types across 5 categories
- Connection wires with data flow
- Mini-map for navigation
- Generates Lua/Verse/TypeScript code
---
## 📦 Asset Library
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ Asset Library [Grid ▣] [List ☰] [×] │
├──────────────────┬──────────────────────────────────────────────────────────────┤
│ Categories │ 🔍 Search assets... [Filter ▼] [Sort ▼] │
│ ─────────────── │ ──────────────────────────────────────────────────────────── │
│ ▼ All Assets │ │
│ 📁 Models (12) │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ 🖼️ Textures(8) │ │ 🏠 │ │ ⚔️ │ │ 🌳 │ │ 🚗 │ │
│ 🔊 Audio (5) │ │ │ │ │ │ │ │ │ │
│ 📜 Scripts (3) │ │ House │ │ Sword │ │ Tree │ │ Car │ │
│ │ │ .glb │ │ .fbx │ │ .glb │ │ .obj │ │
│ ▼ Folders │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ 📂 Characters │ │
│ 📂 Environment │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ 📂 Props │ │ 🧱 │ │ 🔊 │ │ 🎵 │ │ 📄 │ │
│ │ │ │ │ │ │ │ │ │ │
│ ─────────────────│ │ Brick │ │ Ambient │ │ Music │ │ Config │ │
│ ★ Favorites (3) │ │ .png │ │ .mp3 │ │ .ogg │ │ .json │ │
│ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
├──────────────────┴──────────────────────────────────────────────────────────────┤
│ ╔═══════════════════════════════════════╗ │
│ ║ Drop files here to upload ║ │
│ ║ 📁 or click to browse ║ │
│ ╚═══════════════════════════════════════╝ │
└─────────────────────────────────────────────────────────────────────────────────┘
```
**Asset Library Features:**
- Grid/List view toggle
- Drag-and-drop upload
- Category filtering
- Search functionality
- Favorites system
- Thumbnail previews
---
## 🎮 3D Live Preview
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ Live Preview [Beta] [▶ Run] [⏸ Pause] [⏹ Stop] [↺] │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ ☀️ │ │
│ │ ╱╲ │ │
│ │ ▓▓▓▓▓ ╲ ╭───╮ │ │
│ │ ▓▓▓▓▓ ╲ │ 🧍│ │ │
│ │ ▓▓▓▓▓ ──────── ╰───╯ │ │
│ │ │ │
│ │ ═══════════════════════════════════════════════════════════ │ [⚙️] │
│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │
│ │ ░░░░░░░░░░░░░░░░░░ BASEPLATE ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │
│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │
│ │ ┌───┐ │ │
│ │ ● Running 3 objects │▪▪▪│ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
├─────────────────────────────────────────────────────────────────────────────────┤
│ Console [🔍] [Filter ▼] [🗑️]│
│ ─────────────────────────────────────────────────────────────────────────────── │
│ 12:34:56.123 Starting script execution... │
│ 12:34:56.234 📝 Player1 joined the game! │
│ 12:34:57.456 ⚠️ Warning: Part not anchored │
│ 12:34:58.789 Script executed successfully │
└─────────────────────────────────────────────────────────────────────────────────┘
```
**3D Preview Features:**
- Real-time Lua script execution
- Three.js viewport with shadows
- Orbit camera controls
- Grid and axes helpers
- Console output with filtering
- Run/Pause/Stop controls
---
## 🤖 AI Code Generator
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ AI Code Generator [ROBLOX] [×] │
├───────────────────────┬─────────────────────────────────────────────────────────┤
│ [Systems] [Snippets] │ │
│ [Custom] │ Inventory System │
│ │ ════════════════════════════════════════════════════ │
│ 🔍 Search... │ Complete inventory management with slots, stacking, │
│ │ drag-drop, and persistence │
│ ───────────────── │ │
│ [All] [Gameplay] │ Features: Item management • Stack splitting • Search │
│ [Economy] [Combat] │ Quick transfer • Categories • Tooltips │
│ │ │
│ ┌───────────────────┐ │ ┌─ Configuration ────────────────────────────────────┐ │
│ │ 📦 Inventory │ │ │ │ │
│ │ System │ │ │ Max Slots [────────●──] 20 │ │
│ │ ───────────────── │ │ │ Max Stack [──────●────] 99 │ │
│ │ intermediate │ │ │ Enable Drag&Drop [✓] │ │
│ │ ~450 lines │ │ │ Persist Data [✓] │ │
│ └───────────────────┘ │ │ Enable Hotbar [✓] │ │
│ │ │ Hotbar Slots [────●──────] 8 │ │
│ ┌───────────────────┐ │ │ │ │
│ │ 📜 Quest System │ │ └────────────────────────────────────────────────────┘ │
│ │ ───────────────── │ │ [✨ Generate] │
│ │ advanced │ │ ────────────────────────────────────────────────────── │
│ │ ~600 lines │ │ │
│ └───────────────────┘ │ -- Inventory System │
│ │ -- Generated by AeThex Studio AI │
│ ┌───────────────────┐ │ │
│ │ 💰 Currency │ │ local Players = game:GetService("Players") │
│ │ System │ │ local ReplicatedStorage = game:GetService(...) │
│ │ ───────────────── │ │ │
│ │ intermediate │ │ local MAX_SLOTS = 20 │
│ │ ~350 lines │ │ local MAX_STACK = 99 │
│ └───────────────────┘ │ ... │
│ │ [📋 Copy] [→ Insert] │
└───────────────────────┴─────────────────────────────────────────────────────────┘
```
**AI Generator Features:**
- 5+ system templates (Inventory, Quests, Currency, Combat, Friends)
- Configurable parameters with sliders/switches
- Multi-platform code generation
- Instant preview of generated code
- Copy or insert directly into editor
---
## 👥 Real-time Collaboration
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ Collaborators (3) [💬 Chat] [×] │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ ● You (Owner) [Following] │ │
│ │ Line 12, Column 5 │ │
│ ├─────────────────────────────────────────────────────────────────────────┤ │
│ │ ● Alice ✏️ typing │ │
│ │ Line 45, Column 18 [Follow] │ │
│ ├─────────────────────────────────────────────────────────────────────────┤ │
│ │ ○ Bob 30s ago │ │
│ │ Line 23, Column 1 [Follow] │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│ │
│ Session: Demo Collaboration │
│ File: script.lua │
│ [🔗 Copy Invite Link] [⚙️ Settings] [🚪 Leave] │
│ │
├─────────────────────────────────────────────────────────────────────────────────┤
│ Chat │
│ ─────────────────────────────────────────────────────────────────────────────── │
│ ● Alice (2 min ago) │
│ Hey, I just pushed some changes to the combat system! │
│ │
│ ● Bob (1 min ago) │
│ Nice! Let me take a look at the damage calculation. │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Type a message... [Send]│ │
│ └─────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────┘
```
**Collaboration Features:**
- Real-time presence indicators
- Colored cursors for each user
- Follow mode to watch others
- Built-in chat system
- Typing indicators
- Permission management
---
## 🌈 Color Scheme
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ AeThex Studio Theme │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ Background ████████ #0a0a0f (Dark navy) │
│ Card/Panel ████████ #1a1a2e (Elevated dark) │
│ Border ████████ #2d2d44 (Subtle divider) │
│ │
│ Primary/Accent ████████ #8b5cf6 (Purple - "Thex" highlight) │
│ Primary Hover ████████ #a78bfa (Lighter purple) │
│ │
│ Text Primary ████████ #f8fafc (White) │
│ Text Muted ████████ #94a3b8 (Gray) │
│ │
│ Success ████████ #22c55e (Green) │
│ Warning ████████ #eab308 (Yellow) │
│ Error ████████ #ef4444 (Red) │
│ Info ████████ #3b82f6 (Blue) │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
```
---
## 📱 Responsive Design
**Desktop (1200px+)**
```
┌──────┬─────────────────────────┬──────────┐
│ File │ Code Editor │ AI Chat │
│ Tree │ │ │
│ │ │ │
│ ├─────────────────────────┤ │
│ │ Console │ Education│
└──────┴─────────────────────────┴──────────┘
```
**Tablet (768px - 1199px)**
```
┌──────┬─────────────────────────┐
│ File │ Code Editor │
│ Tree │ │
│ ├─────────────────────────┤
│ │ Console / AI Chat │
└──────┴─────────────────────────┘
```
**Mobile (<768px)**
```
┌─────────────────────────┐
│ [☰ Menu] AeThex │
├─────────────────────────┤
│ │
│ Code Editor │
│ │
├─────────────────────────┤
│ [Files] [AI] [More] │
└─────────────────────────┘
```
---
## 🚀 Getting Started
1. **Clone**: `git clone https://github.com/AeThex-LABS/aethex-studio.git`
2. **Install**: `npm install`
3. **Run**: `npm run dev`
4. **Open**: http://localhost:3000
---
*Generated by AeThex Studio Visual Documentation*

1481
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -34,10 +34,18 @@
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@radix-ui/react-tooltip": "^1.1.6",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@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",
"fengari-web": "^0.1.4",
"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 +55,13 @@
"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"
"three": "^0.182.0",
"zustand": "^5.0.10"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
@ -60,6 +70,7 @@
"@types/node": "22.19.7",
"@types/react": "18.3.27",
"@types/react-dom": "^18",
"@types/three": "^0.182.0",
"@vitejs/plugin-react": "^5.1.2",
"autoprefixer": "^10.4.23",
"eslint": "^8",

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';
@ -31,6 +32,11 @@ const NewProjectModal = lazy(() => import('./components/NewProjectModal').then(m
const EducationPanel = lazy(() => import('./components/EducationPanel').then(m => ({ default: m.EducationPanel })));
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'));
const AssetLibrary = lazy(() => import('./components/assets/AssetLibrary'));
const LivePreview = lazy(() => import('./components/preview/LivePreview'));
const AIGenerationPanel = lazy(() => import('./components/ai-generation/AIGenerationPanel'));
function App() {
const [currentCode, setCurrentCode] = useState('');
@ -41,6 +47,11 @@ function App() {
const [showCommandPalette, setShowCommandPalette] = useState(false);
const [showSearchInFiles, setShowSearchInFiles] = useState(false);
const [showTranslation, setShowTranslation] = useState(false);
const [showAvatarToolkit, setShowAvatarToolkit] = useState(false);
const [showVisualScripting, setShowVisualScripting] = useState(false);
const [showAssetLibrary, setShowAssetLibrary] = useState(false);
const [showLivePreview, setShowLivePreview] = useState(false);
const [showAIGeneration, setShowAIGeneration] = useState(false);
const [code, setCode] = useState('');
const [currentPlatform, setCurrentPlatform] = useState<PlatformId>('roblox');
const isMobile = useIsMobile();
@ -476,6 +487,11 @@ end)`,
onTemplatesClick={() => setShowTemplates(true)}
onPreviewClick={() => setShowPreview(true)}
onNewProjectClick={() => setShowNewProject(true)}
onAvatarToolkitClick={() => setShowAvatarToolkit(true)}
onVisualScriptingClick={() => setShowVisualScripting(true)}
onAssetLibraryClick={() => setShowAssetLibrary(true)}
onLivePreviewClick={() => setShowLivePreview(true)}
onAIGenerationClick={() => setShowAIGeneration(true)}
/>
</div>
@ -588,6 +604,72 @@ end)`,
/>
)}
</Suspense>
<Suspense fallback={null}>
{showAvatarToolkit && (
<AvatarToolkit
isOpen={showAvatarToolkit}
onClose={() => setShowAvatarToolkit(false)}
/>
)}
</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}>
{showAssetLibrary && (
<AssetLibrary
isOpen={showAssetLibrary}
onClose={() => setShowAssetLibrary(false)}
/>
)}
</Suspense>
<Suspense fallback={<LoadingSpinner />}>
{showLivePreview && (
<Dialog open={showLivePreview} onOpenChange={setShowLivePreview}>
<DialogContent className="max-w-[95vw] max-h-[90vh] w-full h-[85vh] p-0">
<LivePreview
code={currentCode}
onClose={() => setShowLivePreview(false)}
/>
</DialogContent>
</Dialog>
)}
</Suspense>
<Suspense fallback={null}>
{showAIGeneration && (
<AIGenerationPanel
isOpen={showAIGeneration}
onClose={() => setShowAIGeneration(false)}
currentPlatform={currentPlatform}
onCodeGenerated={(code) => {
setCurrentCode(code);
handleCodeChange(code);
}}
/>
)}
</Suspense>
<Suspense fallback={null}>
<WelcomeDialog />
</Suspense>

View file

@ -0,0 +1,925 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Progress } from '@/components/ui/progress';
import { Separator } from '@/components/ui/separator';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import {
Upload,
Download,
Settings,
Wand2,
CheckCircle2,
XCircle,
AlertTriangle,
Info,
ChevronRight,
RefreshCw,
Sparkles,
Users,
Box,
Gamepad2,
Glasses,
PartyPopper,
Globe,
Cpu,
Heart,
Landmark,
Headphones,
ArrowLeftRight,
FileType,
Bone,
Layers,
ImageIcon,
FileBox,
Zap,
} from 'lucide-react';
import {
AvatarPlatformId,
avatarPlatforms,
supportedPlatforms,
getConstraintsForPlatform,
} from '@/lib/avatar-platforms';
import {
AvatarFileFormat,
ParsedAvatar,
AvatarValidationResult,
ExportOptions,
FORMAT_SPECS,
getSupportedImportFormats,
getSupportedExportFormats,
validateForPlatform,
createDemoAvatar,
generateExportFilename,
getOptimizationRecommendations,
} from '@/lib/avatar-formats';
import {
getConversionPaths,
calculatePlatformCompatibility,
} from '@/lib/avatar-rigging';
import {
avatarTemplates,
platformPresets,
getTemplatesForPlatform,
getPresetsForPlatform,
AvatarTemplate,
AvatarPreset,
} from '@/lib/templates-avatars';
interface AvatarToolkitProps {
isOpen: boolean;
onClose: () => void;
}
type TabValue = 'import' | 'export' | 'convert' | 'templates' | 'validate';
// Platform icon mapping
const platformIcons: Record<AvatarPlatformId, React.ReactNode> = {
roblox: <Gamepad2 className="h-4 w-4" />,
vrchat: <Glasses className="h-4 w-4" />,
recroom: <PartyPopper className="h-4 w-4" />,
spatial: <Globe className="h-4 w-4" />,
sandbox: <Box className="h-4 w-4" />,
neos: <Cpu className="h-4 w-4" />,
resonite: <Sparkles className="h-4 w-4" />,
chilloutvr: <Heart className="h-4 w-4" />,
decentraland: <Landmark className="h-4 w-4" />,
'meta-horizon': <Headphones className="h-4 w-4" />,
universal: <Sparkles className="h-4 w-4" />,
};
export default function AvatarToolkit({ isOpen, onClose }: AvatarToolkitProps) {
const [activeTab, setActiveTab] = useState<TabValue>('import');
const [selectedPlatform, setSelectedPlatform] = useState<AvatarPlatformId>('universal');
const [importedAvatar, setImportedAvatar] = useState<ParsedAvatar | null>(null);
const [validationResult, setValidationResult] = useState<AvatarValidationResult | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [processingProgress, setProcessingProgress] = useState(0);
const [selectedTemplate, setSelectedTemplate] = useState<AvatarTemplate | null>(null);
const [selectedPreset, setSelectedPreset] = useState<AvatarPreset | null>(null);
// Export options state
const [exportOptions, setExportOptions] = useState<Partial<ExportOptions>>({
optimizeForPlatform: true,
embedTextures: true,
compressTextures: true,
preserveBlendShapes: true,
preserveAnimations: true,
generateLODs: false,
});
// Conversion state
const [sourcePlatform, setSourcePlatform] = useState<AvatarPlatformId>('universal');
const [targetPlatform, setTargetPlatform] = useState<AvatarPlatformId>('vrchat');
// Handle file import
const handleFileImport = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setIsProcessing(true);
setProcessingProgress(0);
// Simulate file processing with progress
const progressInterval = setInterval(() => {
setProcessingProgress((prev) => Math.min(prev + 10, 90));
}, 200);
try {
// In a real implementation, this would parse the actual file
// For demo purposes, we create a mock avatar
await new Promise((resolve) => setTimeout(resolve, 2000));
const demoAvatar = createDemoAvatar(file.name.replace(/\.[^.]+$/, ''), selectedPlatform);
setImportedAvatar(demoAvatar);
// Validate against selected platform
const validation = validateForPlatform(demoAvatar, selectedPlatform);
setValidationResult(validation);
setProcessingProgress(100);
} catch (error) {
console.error('Import error:', error);
} finally {
clearInterval(progressInterval);
setIsProcessing(false);
}
}, [selectedPlatform]);
// Handle export
const handleExport = useCallback(async () => {
if (!importedAvatar) return;
setIsProcessing(true);
setProcessingProgress(0);
const progressInterval = setInterval(() => {
setProcessingProgress((prev) => Math.min(prev + 15, 90));
}, 200);
try {
await new Promise((resolve) => setTimeout(resolve, 1500));
const format = avatarPlatforms[selectedPlatform].exportFormat as AvatarFileFormat;
const filename = generateExportFilename(importedAvatar.metadata.name, selectedPlatform, format);
// Create a demo export blob
const exportData = JSON.stringify({
avatar: importedAvatar,
platform: selectedPlatform,
options: exportOptions,
exportedAt: new Date().toISOString(),
}, null, 2);
const blob = new Blob([exportData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename.replace(/\.[^.]+$/, '.json'); // Demo uses JSON
a.click();
URL.revokeObjectURL(url);
setProcessingProgress(100);
} catch (error) {
console.error('Export error:', error);
} finally {
clearInterval(progressInterval);
setIsProcessing(false);
}
}, [importedAvatar, selectedPlatform, exportOptions]);
// Get conversion paths
const conversionPaths = useMemo(() => {
return getConversionPaths(sourcePlatform);
}, [sourcePlatform]);
// Get templates for selected platform
const platformTemplates = useMemo(() => {
return getTemplatesForPlatform(selectedPlatform);
}, [selectedPlatform]);
// Get presets for selected platform
const platformPresetsList = useMemo(() => {
return getPresetsForPlatform(selectedPlatform);
}, [selectedPlatform]);
// Apply preset settings
const applyPreset = useCallback((preset: AvatarPreset) => {
setSelectedPreset(preset);
setExportOptions({
...exportOptions,
optimizeForPlatform: true,
preserveBlendShapes: preset.settings.preserveBlendShapes,
generateLODs: preset.settings.generateLODs,
});
}, [exportOptions]);
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-5xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
AeThex Avatar Toolkit
</DialogTitle>
<DialogDescription>
Import, export, and convert avatars across platforms like Roblox, VRChat, RecRoom, Spatial, and more
</DialogDescription>
</DialogHeader>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)} className="flex-1 flex flex-col min-h-0">
<TabsList className="grid grid-cols-5 w-full">
<TabsTrigger value="import" className="flex items-center gap-1.5">
<Upload className="h-4 w-4" />
Import
</TabsTrigger>
<TabsTrigger value="export" className="flex items-center gap-1.5">
<Download className="h-4 w-4" />
Export
</TabsTrigger>
<TabsTrigger value="convert" className="flex items-center gap-1.5">
<ArrowLeftRight className="h-4 w-4" />
Convert
</TabsTrigger>
<TabsTrigger value="templates" className="flex items-center gap-1.5">
<Users className="h-4 w-4" />
Templates
</TabsTrigger>
<TabsTrigger value="validate" className="flex items-center gap-1.5">
<CheckCircle2 className="h-4 w-4" />
Validate
</TabsTrigger>
</TabsList>
<div className="flex-1 min-h-0 mt-4">
{/* Import Tab */}
<TabsContent value="import" className="h-full m-0">
<div className="grid grid-cols-2 gap-6 h-full">
<div className="space-y-4">
<div>
<Label className="text-sm font-medium">Target Platform</Label>
<Select value={selectedPlatform} onValueChange={(v) => setSelectedPlatform(v as AvatarPlatformId)}>
<SelectTrigger className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
{supportedPlatforms.map((platform) => (
<SelectItem key={platform.id} value={platform.id}>
<div className="flex items-center gap-2">
{platformIcons[platform.id]}
<span>{platform.displayName}</span>
{platform.status === 'beta' && (
<Badge variant="secondary" className="text-xs">Beta</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="border-2 border-dashed rounded-lg p-8 text-center">
<input
type="file"
accept=".glb,.gltf,.fbx,.vrm,.obj,.pmx"
onChange={handleFileImport}
className="hidden"
id="avatar-import"
disabled={isProcessing}
/>
<label htmlFor="avatar-import" className="cursor-pointer">
<Upload className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="font-medium">Drop your avatar file here</p>
<p className="text-sm text-muted-foreground mt-1">
Supports GLB, GLTF, FBX, VRM, OBJ, PMX
</p>
<Button variant="outline" className="mt-4" disabled={isProcessing}>
Browse Files
</Button>
</label>
</div>
{isProcessing && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>Processing...</span>
<span>{processingProgress}%</span>
</div>
<Progress value={processingProgress} />
</div>
)}
<div>
<Label className="text-sm font-medium">Supported Formats</Label>
<div className="flex flex-wrap gap-2 mt-2">
{getSupportedImportFormats().map((format) => (
<Badge key={format} variant="outline">
{FORMAT_SPECS[format].extension}
</Badge>
))}
</div>
</div>
</div>
<div className="space-y-4">
<h4 className="font-medium">Platform Requirements</h4>
{selectedPlatform && (
<div className="space-y-3 text-sm">
{(() => {
const constraints = getConstraintsForPlatform(selectedPlatform);
const platform = avatarPlatforms[selectedPlatform];
return (
<>
<div className="flex items-center justify-between p-2 bg-muted/50 rounded">
<span className="flex items-center gap-2">
<Layers className="h-4 w-4" /> Max Polygons
</span>
<span className="font-mono">{constraints.maxPolygons.toLocaleString()}</span>
</div>
<div className="flex items-center justify-between p-2 bg-muted/50 rounded">
<span className="flex items-center gap-2">
<Bone className="h-4 w-4" /> Max Bones
</span>
<span className="font-mono">{constraints.maxBones}</span>
</div>
<div className="flex items-center justify-between p-2 bg-muted/50 rounded">
<span className="flex items-center gap-2">
<Box className="h-4 w-4" /> Max Materials
</span>
<span className="font-mono">{constraints.maxMaterials}</span>
</div>
<div className="flex items-center justify-between p-2 bg-muted/50 rounded">
<span className="flex items-center gap-2">
<ImageIcon className="h-4 w-4" /> Max Texture Size
</span>
<span className="font-mono">{constraints.maxTextureSize}px</span>
</div>
<div className="flex items-center justify-between p-2 bg-muted/50 rounded">
<span className="flex items-center gap-2">
<FileBox className="h-4 w-4" /> Max File Size
</span>
<span className="font-mono">{constraints.maxFileSize}MB</span>
</div>
<Separator />
<div>
<span className="font-medium">Supported Features:</span>
<div className="flex flex-wrap gap-1.5 mt-2">
{platform.features.map((feature) => (
<Badge key={feature} variant="secondary" className="text-xs">
{feature}
</Badge>
))}
</div>
</div>
</>
);
})()}
</div>
)}
</div>
</div>
</TabsContent>
{/* Export Tab */}
<TabsContent value="export" className="h-full m-0">
<ScrollArea className="h-[400px]">
<div className="grid grid-cols-2 gap-6 pr-4">
<div className="space-y-4">
<div>
<Label className="text-sm font-medium">Export Platform</Label>
<Select value={selectedPlatform} onValueChange={(v) => setSelectedPlatform(v as AvatarPlatformId)}>
<SelectTrigger className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
{supportedPlatforms.map((platform) => (
<SelectItem key={platform.id} value={platform.id}>
<div className="flex items-center gap-2">
{platformIcons[platform.id]}
<span>{platform.displayName}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-sm font-medium">Export Preset</Label>
<Select
value={selectedPreset?.id || ''}
onValueChange={(v) => {
const preset = platformPresetsList.find((p) => p.id === v);
if (preset) applyPreset(preset);
}}
>
<SelectTrigger className="mt-1.5">
<SelectValue placeholder="Select a preset..." />
</SelectTrigger>
<SelectContent>
{platformPresetsList.map((preset) => (
<SelectItem key={preset.id} value={preset.id}>
<div>
<div className="font-medium">{preset.name}</div>
<div className="text-xs text-muted-foreground">{preset.description}</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Separator />
<div className="space-y-3">
<h4 className="font-medium text-sm">Export Options</h4>
<div className="flex items-center justify-between">
<Label htmlFor="optimize" className="text-sm">Optimize for Platform</Label>
<Switch
id="optimize"
checked={exportOptions.optimizeForPlatform}
onCheckedChange={(v) => setExportOptions({ ...exportOptions, optimizeForPlatform: v })}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="embed" className="text-sm">Embed Textures</Label>
<Switch
id="embed"
checked={exportOptions.embedTextures}
onCheckedChange={(v) => setExportOptions({ ...exportOptions, embedTextures: v })}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="compress" className="text-sm">Compress Textures</Label>
<Switch
id="compress"
checked={exportOptions.compressTextures}
onCheckedChange={(v) => setExportOptions({ ...exportOptions, compressTextures: v })}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="blendshapes" className="text-sm">Preserve Blend Shapes</Label>
<Switch
id="blendshapes"
checked={exportOptions.preserveBlendShapes}
onCheckedChange={(v) => setExportOptions({ ...exportOptions, preserveBlendShapes: v })}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="animations" className="text-sm">Preserve Animations</Label>
<Switch
id="animations"
checked={exportOptions.preserveAnimations}
onCheckedChange={(v) => setExportOptions({ ...exportOptions, preserveAnimations: v })}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="lods" className="text-sm">Generate LODs</Label>
<Switch
id="lods"
checked={exportOptions.generateLODs}
onCheckedChange={(v) => setExportOptions({ ...exportOptions, generateLODs: v })}
/>
</div>
</div>
</div>
<div className="space-y-4">
{importedAvatar ? (
<>
<div className="p-4 border rounded-lg">
<h4 className="font-medium mb-3">Avatar Preview</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Name:</span>
<span className="font-medium">{importedAvatar.metadata.name}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Triangles:</span>
<span className="font-mono">{importedAvatar.stats.totalTriangles.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Bones:</span>
<span className="font-mono">{importedAvatar.stats.totalBones}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Materials:</span>
<span className="font-mono">{importedAvatar.stats.totalMaterials}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Blend Shapes:</span>
<span className="font-mono">{importedAvatar.stats.totalBlendShapes}</span>
</div>
</div>
</div>
<div className="flex gap-2">
<Button onClick={handleExport} disabled={isProcessing} className="flex-1">
<Download className="h-4 w-4 mr-2" />
Export for {avatarPlatforms[selectedPlatform].name}
</Button>
</div>
{isProcessing && (
<Progress value={processingProgress} />
)}
</>
) : (
<div className="flex flex-col items-center justify-center h-full text-center p-8 border-2 border-dashed rounded-lg">
<Upload className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground">
Import an avatar first to enable export options
</p>
<Button
variant="outline"
className="mt-4"
onClick={() => setActiveTab('import')}
>
Go to Import
</Button>
</div>
)}
</div>
</div>
</ScrollArea>
</TabsContent>
{/* Convert Tab */}
<TabsContent value="convert" className="h-full m-0">
<div className="grid grid-cols-3 gap-6">
<div className="space-y-4">
<div>
<Label className="text-sm font-medium">Source Platform</Label>
<Select value={sourcePlatform} onValueChange={(v) => setSourcePlatform(v as AvatarPlatformId)}>
<SelectTrigger className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
{supportedPlatforms.map((platform) => (
<SelectItem key={platform.id} value={platform.id}>
<div className="flex items-center gap-2">
{platformIcons[platform.id]}
<span>{platform.displayName}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="text-center py-4">
<ArrowLeftRight className="h-8 w-8 mx-auto text-muted-foreground" />
</div>
<div>
<Label className="text-sm font-medium">Target Platform</Label>
<Select value={targetPlatform} onValueChange={(v) => setTargetPlatform(v as AvatarPlatformId)}>
<SelectTrigger className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
{supportedPlatforms.map((platform) => (
<SelectItem key={platform.id} value={platform.id}>
<div className="flex items-center gap-2">
{platformIcons[platform.id]}
<span>{platform.displayName}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="p-4 bg-muted/50 rounded-lg">
<div className="text-sm font-medium mb-2">Compatibility Score</div>
<div className="text-3xl font-bold text-primary">
{calculatePlatformCompatibility(sourcePlatform, targetPlatform)}%
</div>
</div>
</div>
<div className="col-span-2">
<h4 className="font-medium mb-3">Conversion Paths from {avatarPlatforms[sourcePlatform].name}</h4>
<ScrollArea className="h-[350px]">
<div className="space-y-2 pr-4">
{conversionPaths.map((path) => (
<div
key={path.target}
className={`p-3 border rounded-lg cursor-pointer transition-colors ${
path.target === targetPlatform
? 'border-primary bg-primary/5'
: 'hover:bg-muted/50'
}`}
onClick={() => setTargetPlatform(path.target)}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{platformIcons[path.target]}
<span className="font-medium">{avatarPlatforms[path.target].displayName}</span>
</div>
<Badge variant={path.compatibility >= 80 ? 'default' : path.compatibility >= 60 ? 'secondary' : 'outline'}>
{path.compatibility}% compatible
</Badge>
</div>
{path.warnings.length > 0 && (
<div className="space-y-1">
{path.warnings.map((warning, i) => (
<div key={i} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<AlertTriangle className="h-3 w-3 text-yellow-500" />
{warning}
</div>
))}
</div>
)}
</div>
))}
</div>
</ScrollArea>
</div>
</div>
</TabsContent>
{/* Templates Tab */}
<TabsContent value="templates" className="h-full m-0">
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="flex-1">
<Input placeholder="Search templates..." />
</div>
<Select value={selectedPlatform} onValueChange={(v) => setSelectedPlatform(v as AvatarPlatformId)}>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{supportedPlatforms.map((platform) => (
<SelectItem key={platform.id} value={platform.id}>
<div className="flex items-center gap-2">
{platformIcons[platform.id]}
<span>{platform.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<ScrollArea className="h-[350px]">
<div className="grid grid-cols-3 gap-4 pr-4">
{(selectedPlatform === 'universal' ? avatarTemplates : platformTemplates).map((template) => (
<div
key={template.id}
className={`p-4 border rounded-lg cursor-pointer transition-all ${
selectedTemplate?.id === template.id
? 'border-primary ring-2 ring-primary/20'
: 'hover:border-primary/50'
}`}
onClick={() => setSelectedTemplate(template)}
>
<div className="aspect-square bg-muted rounded mb-3 flex items-center justify-center">
<Users className="h-12 w-12 text-muted-foreground" />
</div>
<h4 className="font-medium text-sm">{template.name}</h4>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{template.description}
</p>
<div className="flex flex-wrap gap-1 mt-2">
<Badge variant="outline" className="text-xs">{template.style}</Badge>
<Badge variant="outline" className="text-xs">{template.polyCount} poly</Badge>
</div>
<div className="flex gap-1 mt-2">
{template.platforms.slice(0, 3).map((p) => (
<span key={p} className="text-muted-foreground">
{platformIcons[p]}
</span>
))}
{template.platforms.length > 3 && (
<span className="text-xs text-muted-foreground">+{template.platforms.length - 3}</span>
)}
</div>
</div>
))}
</div>
</ScrollArea>
{selectedTemplate && (
<div className="p-4 border rounded-lg bg-muted/30">
<div className="flex items-start justify-between">
<div>
<h4 className="font-medium">{selectedTemplate.name}</h4>
<p className="text-sm text-muted-foreground mt-1">{selectedTemplate.description}</p>
</div>
<Button size="sm">
<Download className="h-4 w-4 mr-1.5" />
Use Template
</Button>
</div>
<div className="flex flex-wrap gap-1.5 mt-3">
{selectedTemplate.features.map((feature) => (
<Badge key={feature} variant="secondary" className="text-xs">{feature}</Badge>
))}
</div>
</div>
)}
</div>
</TabsContent>
{/* Validate Tab */}
<TabsContent value="validate" className="h-full m-0">
<div className="grid grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<Label className="text-sm font-medium">Validation Target</Label>
<Select value={selectedPlatform} onValueChange={(v) => setSelectedPlatform(v as AvatarPlatformId)}>
<SelectTrigger className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
{supportedPlatforms.map((platform) => (
<SelectItem key={platform.id} value={platform.id}>
<div className="flex items-center gap-2">
{platformIcons[platform.id]}
<span>{platform.displayName}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!importedAvatar ? (
<div className="border-2 border-dashed rounded-lg p-8 text-center">
<Wand2 className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground">
Import an avatar to validate it against platform requirements
</p>
<Button variant="outline" className="mt-4" onClick={() => setActiveTab('import')}>
Import Avatar
</Button>
</div>
) : (
<div className="space-y-3">
<Button
onClick={() => {
const validation = validateForPlatform(importedAvatar, selectedPlatform);
setValidationResult(validation);
}}
className="w-full"
>
<RefreshCw className="h-4 w-4 mr-2" />
Validate for {avatarPlatforms[selectedPlatform].name}
</Button>
{validationResult && (
<div className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
{validationResult.isValid ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-red-500" />
)}
<span className="font-medium">
{validationResult.isValid ? 'Compatible' : 'Issues Found'}
</span>
</div>
<Badge variant={validationResult.score >= 80 ? 'default' : validationResult.score >= 50 ? 'secondary' : 'destructive'}>
Score: {validationResult.score}%
</Badge>
</div>
<div className="space-y-2">
{Object.entries(validationResult.constraints).map(([key, value]) => (
<div key={key} className="flex items-center justify-between text-sm">
<span className="capitalize">{key.replace(/([A-Z])/g, ' $1').trim()}</span>
<div className="flex items-center gap-2">
<span className="font-mono text-xs">
{value.current.toLocaleString()} / {value.max.toLocaleString()}
</span>
{value.passed ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<XCircle className="h-4 w-4 text-red-500" />
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
<div className="space-y-4">
<h4 className="font-medium">Validation Results</h4>
{validationResult ? (
<ScrollArea className="h-[350px]">
<div className="space-y-3 pr-4">
{validationResult.issues.length === 0 ? (
<div className="p-4 bg-green-500/10 border border-green-500/20 rounded-lg">
<div className="flex items-center gap-2 text-green-600">
<CheckCircle2 className="h-5 w-5" />
<span className="font-medium">All checks passed!</span>
</div>
<p className="text-sm text-muted-foreground mt-1">
Your avatar is fully compatible with {avatarPlatforms[selectedPlatform].name}
</p>
</div>
) : (
validationResult.issues.map((issue, i) => (
<div
key={i}
className={`p-3 border rounded-lg ${
issue.type === 'error'
? 'border-red-500/20 bg-red-500/5'
: issue.type === 'warning'
? 'border-yellow-500/20 bg-yellow-500/5'
: 'border-blue-500/20 bg-blue-500/5'
}`}
>
<div className="flex items-start gap-2">
{issue.type === 'error' ? (
<XCircle className="h-4 w-4 text-red-500 mt-0.5" />
) : issue.type === 'warning' ? (
<AlertTriangle className="h-4 w-4 text-yellow-500 mt-0.5" />
) : (
<Info className="h-4 w-4 text-blue-500 mt-0.5" />
)}
<div className="flex-1">
<p className="text-sm font-medium">{issue.message}</p>
{issue.details && (
<p className="text-xs text-muted-foreground mt-0.5">{issue.details}</p>
)}
{issue.autoFix && (
<Badge variant="outline" className="mt-2 text-xs">
<Zap className="h-3 w-3 mr-1" />
Auto-fixable
</Badge>
)}
</div>
</div>
</div>
))
)}
{validationResult.optimizationSuggestions.length > 0 && (
<>
<Separator />
<div className="space-y-2">
<h5 className="text-sm font-medium flex items-center gap-1.5">
<Wand2 className="h-4 w-4" />
Optimization Suggestions
</h5>
{validationResult.optimizationSuggestions.map((suggestion, i) => (
<div key={i} className="flex items-start gap-2 text-sm text-muted-foreground">
<ChevronRight className="h-4 w-4 mt-0.5" />
<span>{suggestion}</span>
</div>
))}
</div>
</>
)}
</div>
</ScrollArea>
) : (
<div className="flex items-center justify-center h-[350px] border-2 border-dashed rounded-lg">
<p className="text-muted-foreground">Validation results will appear here</p>
</div>
)}
</div>
</div>
</TabsContent>
</div>
</Tabs>
</DialogContent>
</Dialog>
);
}

View file

@ -58,38 +58,35 @@ export function FileTree({
if (next.has(id)) {
next.delete(id);
} else {
return (
<ScrollArea className="flex-1 bg-muted/40 border-r border-border min-w-[180px] max-w-[260px]">
<div className="p-2">
{files.map((node) => (
<FileNodeComponent
key={node.id}
node={node}
expandedFolders={expandedFolders}
toggleFolder={toggleFolder}
onFileSelect={onFileSelect}
onFileCreate={onFileCreate}
onFileRename={onFileRename}
onFileDelete={onFileDelete}
onFileMove={onFileMove}
selectedFileId={selectedFileId}
startRename={startRename}
finishRename={finishRename}
editingId={editingId}
editingName={editingName}
setEditingName={setEditingName}
handleDelete={handleDelete}
handleDragStart={handleDragStart}
handleDragOver={handleDragOver}
handleDragLeave={handleDragLeave}
handleDrop={handleDrop}
draggedId={draggedId}
dropTargetId={dropTargetId}
/>
))}
</div>
</ScrollArea>
);
next.add(id);
}
return next;
});
}, []);
const startRename = useCallback((node: FileNode) => {
setEditingId(node.id);
setEditingName(node.name);
}, []);
const finishRename = useCallback((id: string) => {
if (editingName.trim()) {
onFileRename(id, editingName.trim());
}
setEditingId(null);
setEditingName('');
}, [editingName, onFileRename]);
const handleDelete = useCallback((node: FileNode) => {
if (confirm(`Are you sure you want to delete "${node.name}"?`)) {
onFileDelete(node.id);
}
}, [onFileDelete]);
const handleDragStart = useCallback((e: React.DragEvent, node: FileNode) => {
e.stopPropagation();
setDraggedId(node.id);
e.dataTransfer.effectAllowed = 'move';
}, []);
const handleDragOver = useCallback((e: React.DragEvent, node: FileNode) => {

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 } from '@phosphor-icons/react';
import { Copy, FileCode, Download, Info, Play, FolderPlus, User, SignOut, List, ArrowsLeftRight, UserCircle, GitBranch, Package, Cube, MagicWand } from '@phosphor-icons/react';
import { toast } from 'sonner';
import { useState, useEffect, useCallback, memo } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
@ -24,9 +24,14 @@ interface ToolbarProps {
currentPlatform: PlatformId;
onPlatformChange: (platform: PlatformId) => void;
onTranslateClick?: () => void;
onAvatarToolkitClick?: () => void;
onVisualScriptingClick?: () => void;
onAssetLibraryClick?: () => void;
onLivePreviewClick?: () => void;
onAIGenerationClick?: () => void;
}
export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectClick, currentPlatform, onPlatformChange, onTranslateClick }: ToolbarProps) {
export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectClick, currentPlatform, onPlatformChange, onTranslateClick, onAvatarToolkitClick, onVisualScriptingClick, onAssetLibraryClick, onLivePreviewClick, onAIGenerationClick }: ToolbarProps) {
const [showInfo, setShowInfo] = useState(false);
const [user, setUser] = useState<{ login: string; avatarUrl: string; email: string } | null>(null);
@ -98,6 +103,101 @@ export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectCl
</Tooltip>
)}
{/* Avatar Toolkit Button */}
{onAvatarToolkitClick && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={onAvatarToolkitClick}
className="h-8 px-3 text-xs gap-1"
aria-label="Avatar Toolkit"
>
<UserCircle size={14} />
<span>Avatars</span>
</Button>
</TooltipTrigger>
<TooltipContent>Cross-Platform Avatar Toolkit</TooltipContent>
</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>
)}
{/* Asset Library Button */}
{onAssetLibraryClick && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={onAssetLibraryClick}
className="h-8 px-3 text-xs gap-1"
aria-label="Asset Library"
>
<Package size={14} />
<span>Assets</span>
</Button>
</TooltipTrigger>
<TooltipContent>Asset Library (Models, Textures, Audio)</TooltipContent>
</Tooltip>
)}
{/* Live Preview Button */}
{onLivePreviewClick && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={onLivePreviewClick}
className="h-8 px-3 text-xs gap-1 bg-primary/10 border-primary/30 hover:bg-primary/20"
aria-label="Live Preview"
>
<Cube size={14} />
<span>3D Preview</span>
</Button>
</TooltipTrigger>
<TooltipContent>Live 3D Preview with Lua Execution</TooltipContent>
</Tooltip>
)}
{/* AI Generation Button */}
{onAIGenerationClick && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={onAIGenerationClick}
className="h-8 px-3 text-xs gap-1 bg-accent/10 border-accent/30 hover:bg-accent/20"
aria-label="AI Code Generator"
>
<MagicWand size={14} />
<span>AI Generate</span>
</Button>
</TooltipTrigger>
<TooltipContent>AI-Powered Code & System Generation</TooltipContent>
</Tooltip>
)}
<div className="h-6 w-px bg-border mx-1" />
<Tooltip>
@ -212,6 +312,36 @@ export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectCl
<FileCode className="mr-2" size={16} />
<span>Templates</span>
</DropdownMenuItem>
{onAvatarToolkitClick && (
<DropdownMenuItem onClick={onAvatarToolkitClick}>
<UserCircle className="mr-2" size={16} />
<span>Avatar Toolkit</span>
</DropdownMenuItem>
)}
{onVisualScriptingClick && (
<DropdownMenuItem onClick={onVisualScriptingClick}>
<GitBranch className="mr-2" size={16} />
<span>Visual Scripting</span>
</DropdownMenuItem>
)}
{onAssetLibraryClick && (
<DropdownMenuItem onClick={onAssetLibraryClick}>
<Package className="mr-2" size={16} />
<span>Asset Library</span>
</DropdownMenuItem>
)}
{onLivePreviewClick && (
<DropdownMenuItem onClick={onLivePreviewClick}>
<Cube className="mr-2" size={16} />
<span>3D Preview</span>
</DropdownMenuItem>
)}
{onAIGenerationClick && (
<DropdownMenuItem onClick={onAIGenerationClick}>
<MagicWand className="mr-2" size={16} />
<span>AI Generate</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleCopy}>
<Copy className="mr-2" size={16} />
<span>Copy Code</span>

View file

@ -0,0 +1,560 @@
'use client';
import { useState, useMemo } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Switch } from '@/components/ui/switch';
import { Slider } from '@/components/ui/slider';
import { Separator } from '@/components/ui/separator';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import {
Wand2,
Copy,
Check,
Sparkles,
Code,
Search,
Layers,
Gamepad2,
Coins,
Swords,
Users,
Layout,
Globe,
Network,
CreditCard,
ChevronRight,
FileCode,
Zap,
} from 'lucide-react';
import { toast } from 'sonner';
import {
SYSTEM_TEMPLATES,
SYSTEM_CATEGORIES,
SystemCategory,
SystemTemplate,
TemplateParameter,
generateCode,
getTemplatesByCategory,
getTemplatesForPlatform,
} from '@/lib/ai-generation/system-templates';
import {
CODE_SNIPPETS,
getSnippetsForPlatform,
getPatternsForPlatform,
} from '@/lib/ai-generation/generation-prompts';
import { PlatformId } from '@/lib/platforms';
// Category icons
const CATEGORY_ICONS: Record<SystemCategory, React.ReactNode> = {
gameplay: <Gamepad2 className="h-4 w-4" />,
economy: <Coins className="h-4 w-4" />,
combat: <Swords className="h-4 w-4" />,
social: <Users className="h-4 w-4" />,
ui: <Layout className="h-4 w-4" />,
world: <Globe className="h-4 w-4" />,
multiplayer: <Network className="h-4 w-4" />,
monetization: <CreditCard className="h-4 w-4" />,
};
interface AIGenerationPanelProps {
isOpen: boolean;
onClose: () => void;
currentPlatform: PlatformId;
onCodeGenerated: (code: string) => void;
}
export default function AIGenerationPanel({
isOpen,
onClose,
currentPlatform,
onCodeGenerated,
}: AIGenerationPanelProps) {
const [activeTab, setActiveTab] = useState<'templates' | 'snippets' | 'custom'>('templates');
const [selectedCategory, setSelectedCategory] = useState<SystemCategory | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<SystemTemplate | null>(null);
const [parameters, setParameters] = useState<Record<string, any>>({});
const [generatedCode, setGeneratedCode] = useState('');
const [copied, setCopied] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [customPrompt, setCustomPrompt] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
// Map PlatformId to template platform
const templatePlatform = useMemo(() => {
switch (currentPlatform) {
case 'roblox':
return 'roblox';
case 'uefn':
return 'uefn';
case 'spatial':
return 'spatial';
default:
return 'roblox';
}
}, [currentPlatform]);
// Filter templates by platform and search
const filteredTemplates = useMemo(() => {
let templates = getTemplatesForPlatform(templatePlatform);
if (selectedCategory) {
templates = templates.filter((t) => t.category === selectedCategory);
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
templates = templates.filter(
(t) =>
t.name.toLowerCase().includes(query) ||
t.description.toLowerCase().includes(query)
);
}
return templates;
}, [templatePlatform, selectedCategory, searchQuery]);
// Get snippets for current platform
const snippets = useMemo(() => {
return getSnippetsForPlatform(templatePlatform);
}, [templatePlatform]);
// Initialize parameters when template is selected
const handleTemplateSelect = (template: SystemTemplate) => {
setSelectedTemplate(template);
const initialParams: Record<string, any> = {};
template.parameters.forEach((param) => {
initialParams[param.id] = param.default;
});
setParameters(initialParams);
setGeneratedCode('');
};
// Generate code from template
const handleGenerate = () => {
if (!selectedTemplate) return;
const code = generateCode(selectedTemplate, templatePlatform, parameters);
setGeneratedCode(code);
toast.success('Code generated!');
};
// Copy code to clipboard
const handleCopy = async () => {
await navigator.clipboard.writeText(generatedCode);
setCopied(true);
toast.success('Copied to clipboard!');
setTimeout(() => setCopied(false), 2000);
};
// Insert code into editor
const handleInsert = () => {
onCodeGenerated(generatedCode);
toast.success('Code inserted into editor!');
onClose();
};
// Copy snippet
const handleCopySnippet = async (code: string) => {
await navigator.clipboard.writeText(code);
toast.success('Snippet copied!');
};
// Insert snippet
const handleInsertSnippet = (code: string) => {
onCodeGenerated(code);
toast.success('Snippet inserted!');
onClose();
};
// Render parameter input
const renderParameterInput = (param: TemplateParameter) => {
const value = parameters[param.id];
switch (param.type) {
case 'number':
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs">{param.name}</Label>
<span className="text-xs text-muted-foreground">{value}</span>
</div>
<Slider
value={[value]}
onValueChange={([v]) =>
setParameters((p) => ({ ...p, [param.id]: v }))
}
min={param.validation?.min || 0}
max={param.validation?.max || 100}
step={1}
className="w-full"
/>
<p className="text-[10px] text-muted-foreground">{param.description}</p>
</div>
);
case 'boolean':
return (
<div className="flex items-center justify-between">
<div>
<Label className="text-xs">{param.name}</Label>
<p className="text-[10px] text-muted-foreground">{param.description}</p>
</div>
<Switch
checked={value}
onCheckedChange={(checked) =>
setParameters((p) => ({ ...p, [param.id]: checked }))
}
/>
</div>
);
case 'select':
return (
<div className="space-y-1">
<Label className="text-xs">{param.name}</Label>
<Select
value={value}
onValueChange={(v) => setParameters((p) => ({ ...p, [param.id]: v }))}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{param.options?.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">{param.description}</p>
</div>
);
case 'array':
return (
<div className="space-y-1">
<Label className="text-xs">{param.name}</Label>
<Input
value={Array.isArray(value) ? value.join(', ') : value}
onChange={(e) =>
setParameters((p) => ({
...p,
[param.id]: e.target.value.split(',').map((s) => s.trim()),
}))
}
className="h-8 text-xs"
placeholder="item1, item2, item3"
/>
<p className="text-[10px] text-muted-foreground">{param.description}</p>
</div>
);
default:
return (
<div className="space-y-1">
<Label className="text-xs">{param.name}</Label>
<Input
value={value}
onChange={(e) =>
setParameters((p) => ({ ...p, [param.id]: e.target.value }))
}
className="h-8 text-xs"
/>
<p className="text-[10px] text-muted-foreground">{param.description}</p>
</div>
);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-5xl max-h-[85vh] p-0 overflow-hidden">
<DialogHeader className="px-6 py-4 border-b">
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
AI Code Generator
<Badge variant="secondary" className="ml-2 text-xs">
{templatePlatform.toUpperCase()}
</Badge>
</DialogTitle>
</DialogHeader>
<div className="flex h-[70vh]">
{/* Left Panel - Categories/Templates */}
<div className="w-72 border-r flex flex-col">
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)} className="flex-1 flex flex-col">
<TabsList className="grid grid-cols-3 mx-3 mt-3">
<TabsTrigger value="templates" className="text-xs">
<Layers className="h-3 w-3 mr-1" />
Systems
</TabsTrigger>
<TabsTrigger value="snippets" className="text-xs">
<Code className="h-3 w-3 mr-1" />
Snippets
</TabsTrigger>
<TabsTrigger value="custom" className="text-xs">
<Wand2 className="h-3 w-3 mr-1" />
Custom
</TabsTrigger>
</TabsList>
<div className="px-3 py-2">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-3 w-3 text-muted-foreground" />
<Input
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 h-8 text-xs"
/>
</div>
</div>
<ScrollArea className="flex-1">
<TabsContent value="templates" className="mt-0 p-2">
{/* Category filters */}
<div className="flex flex-wrap gap-1 mb-3">
<Badge
variant={selectedCategory === null ? 'default' : 'outline'}
className="cursor-pointer text-[10px]"
onClick={() => setSelectedCategory(null)}
>
All
</Badge>
{(Object.keys(SYSTEM_CATEGORIES) as SystemCategory[]).map((cat) => (
<Badge
key={cat}
variant={selectedCategory === cat ? 'default' : 'outline'}
className="cursor-pointer text-[10px]"
onClick={() => setSelectedCategory(cat)}
>
{CATEGORY_ICONS[cat]}
<span className="ml-1">{SYSTEM_CATEGORIES[cat].label.split(' ')[0]}</span>
</Badge>
))}
</div>
{/* Template list */}
<div className="space-y-1">
{filteredTemplates.map((template) => (
<div
key={template.id}
className={`p-2 rounded-md border cursor-pointer transition-colors ${
selectedTemplate?.id === template.id
? 'bg-primary/10 border-primary'
: 'hover:bg-accent'
}`}
onClick={() => handleTemplateSelect(template)}
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm">{template.name}</span>
<Badge
variant="secondary"
className="text-[10px]"
style={{
backgroundColor: `${SYSTEM_CATEGORIES[template.category].color}20`,
color: SYSTEM_CATEGORIES[template.category].color,
}}
>
{template.complexity}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{template.description}
</p>
<div className="flex items-center gap-2 mt-2 text-[10px] text-muted-foreground">
<span>~{template.estimatedLines} lines</span>
<span></span>
<span>{template.features.length} features</span>
</div>
</div>
))}
</div>
</TabsContent>
<TabsContent value="snippets" className="mt-0 p-2">
<Accordion type="multiple" className="w-full">
{Object.entries(snippets).map(([name, code]) => (
<AccordionItem key={name} value={name}>
<AccordionTrigger className="text-sm py-2">
<div className="flex items-center gap-2">
<FileCode className="h-4 w-4" />
{name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}
</div>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-2">
<pre className="text-[10px] bg-muted p-2 rounded overflow-x-auto max-h-32">
{code.slice(0, 200)}...
</pre>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
className="h-7 text-xs flex-1"
onClick={() => handleCopySnippet(code)}
>
<Copy className="h-3 w-3 mr-1" />
Copy
</Button>
<Button
variant="default"
size="sm"
className="h-7 text-xs flex-1"
onClick={() => handleInsertSnippet(code)}
>
<Zap className="h-3 w-3 mr-1" />
Insert
</Button>
</div>
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</TabsContent>
<TabsContent value="custom" className="mt-0 p-3">
<div className="space-y-3">
<Label className="text-xs">Describe what you want to create:</Label>
<Textarea
placeholder="E.g., Create a system where players can collect coins that spawn randomly around the map. Include a leaderboard that shows top collectors."
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
className="min-h-[150px] text-xs"
/>
<Button
className="w-full"
disabled={!customPrompt.trim() || isGenerating}
onClick={() => {
// For now, just show a message
toast.info('Custom AI generation coming soon! Use system templates for now.');
}}
>
<Wand2 className="h-4 w-4 mr-2" />
Generate with AI
</Button>
<p className="text-[10px] text-muted-foreground text-center">
Tip: Be specific about mechanics, numbers, and behaviors you want.
</p>
</div>
</TabsContent>
</ScrollArea>
</Tabs>
</div>
{/* Right Panel - Configuration/Preview */}
<div className="flex-1 flex flex-col">
{selectedTemplate ? (
<>
{/* Template Info */}
<div className="p-4 border-b">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{selectedTemplate.name}</h3>
<p className="text-sm text-muted-foreground">
{selectedTemplate.description}
</p>
</div>
<Button onClick={handleGenerate}>
<Sparkles className="h-4 w-4 mr-2" />
Generate
</Button>
</div>
<div className="flex flex-wrap gap-1 mt-3">
{selectedTemplate.features.map((feature) => (
<Badge key={feature} variant="outline" className="text-[10px]">
{feature}
</Badge>
))}
</div>
</div>
{/* Parameters */}
{selectedTemplate.parameters.length > 0 && (
<div className="p-4 border-b">
<h4 className="font-medium text-sm mb-3">Configuration</h4>
<div className="grid grid-cols-2 gap-4">
{selectedTemplate.parameters.map((param) => (
<div key={param.id}>{renderParameterInput(param)}</div>
))}
</div>
</div>
)}
{/* Generated Code */}
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="font-medium text-sm">Generated Code</span>
{generatedCode && (
<div className="flex gap-1">
<Button variant="outline" size="sm" onClick={handleCopy}>
{copied ? (
<Check className="h-3 w-3 mr-1" />
) : (
<Copy className="h-3 w-3 mr-1" />
)}
{copied ? 'Copied!' : 'Copy'}
</Button>
<Button size="sm" onClick={handleInsert}>
<ChevronRight className="h-3 w-3 mr-1" />
Insert
</Button>
</div>
)}
</div>
<ScrollArea className="flex-1">
{generatedCode ? (
<pre className="p-4 text-xs font-mono whitespace-pre-wrap">
{generatedCode}
</pre>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center">
<Sparkles className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">Configure parameters and click Generate</p>
</div>
</div>
)}
</ScrollArea>
</div>
</>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center">
<Layers className="h-12 w-12 mx-auto mb-4 opacity-50" />
<h3 className="font-medium text-lg mb-2">Select a System Template</h3>
<p className="text-sm max-w-xs">
Choose from pre-built game systems or create custom code with AI assistance.
</p>
</div>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1 @@
export { default as AIGenerationPanel } from './AIGenerationPanel';

View file

@ -0,0 +1,771 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Upload,
Search,
Grid3X3,
List,
Folder,
FolderPlus,
Star,
Trash2,
Download,
Copy,
MoreVertical,
Image,
Volume2,
Box,
FileCode,
Sparkles,
Database,
Play,
Package,
Palette,
Layout,
Filter,
SortAsc,
SortDesc,
X,
Heart,
Tag,
Info,
Music,
} from 'lucide-react';
import { toast } from 'sonner';
import { useAssetStore } from '@/stores/asset-store';
import {
Asset,
AssetType,
AssetCategory,
ASSET_TYPE_ICONS,
ASSET_TYPE_COLORS,
CATEGORY_LABELS,
formatFileSize,
getAcceptedFileTypes,
} from '@/lib/assets/types';
interface AssetLibraryProps {
isOpen: boolean;
onClose: () => void;
onAssetSelect?: (asset: Asset) => void;
}
// Asset type icons mapping
const TypeIcon: Record<AssetType, React.ReactNode> = {
model: <Box className="h-4 w-4" />,
texture: <Image className="h-4 w-4" />,
audio: <Volume2 className="h-4 w-4" />,
animation: <Play className="h-4 w-4" />,
script: <FileCode className="h-4 w-4" />,
prefab: <Package className="h-4 w-4" />,
material: <Palette className="h-4 w-4" />,
particle: <Sparkles className="h-4 w-4" />,
ui: <Layout className="h-4 w-4" />,
data: <Database className="h-4 w-4" />,
};
export default function AssetLibrary({
isOpen,
onClose,
onAssetSelect,
}: AssetLibraryProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [searchQuery, setSearchQuery] = useState('');
const [selectedType, setSelectedType] = useState<AssetType | 'all'>('all');
const [isDragging, setIsDragging] = useState(false);
const {
assets,
folders,
selectedAssetId,
viewMode,
sort,
addAsset,
removeAsset,
updateAsset,
setSelectedAsset,
setViewMode,
setSort,
setFilter,
toggleFavorite,
getFilteredAssets,
getFavorites,
} = useAssetStore();
// Handle file upload
const handleFileUpload = useCallback(
async (files: FileList | File[]) => {
const fileArray = Array.from(files);
for (const file of fileArray) {
try {
await addAsset(file);
toast.success(`Uploaded ${file.name}`);
} catch (error) {
toast.error(`Failed to upload ${file.name}`);
}
}
},
[addAsset]
);
// Handle drag and drop
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files.length > 0) {
handleFileUpload(e.dataTransfer.files);
}
},
[handleFileUpload]
);
// Filter assets
const filteredAssets = getFilteredAssets().filter((asset) => {
const matchesSearch =
!searchQuery ||
asset.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
asset.tags.some((t) => t.toLowerCase().includes(searchQuery.toLowerCase()));
const matchesType = selectedType === 'all' || asset.type === selectedType;
return matchesSearch && matchesType;
});
const favorites = getFavorites();
const selectedAsset = assets.find((a) => a.id === selectedAssetId);
// Asset type counts
const typeCounts = assets.reduce((acc, asset) => {
acc[asset.type] = (acc[asset.type] || 0) + 1;
return acc;
}, {} as Record<AssetType, number>);
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-6xl max-h-[85vh] p-0 flex flex-col">
<DialogHeader className="px-4 py-3 border-b">
<DialogTitle className="flex items-center gap-2">
<Package className="h-5 w-5 text-primary" />
Asset Library
<Badge variant="secondary" className="ml-2">
{assets.length} assets
</Badge>
</DialogTitle>
</DialogHeader>
<div className="flex flex-1 min-h-0">
{/* Sidebar */}
<div className="w-56 border-r bg-muted/30 flex flex-col">
{/* Upload Button */}
<div className="p-3">
<input
ref={fileInputRef}
type="file"
multiple
accept={getAcceptedFileTypes()}
onChange={(e) => e.target.files && handleFileUpload(e.target.files)}
className="hidden"
/>
<Button
className="w-full"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-4 w-4 mr-2" />
Upload Assets
</Button>
</div>
<Separator />
{/* Quick Filters */}
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
<button
onClick={() => setSelectedType('all')}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors ${
selectedType === 'all'
? 'bg-primary/10 text-primary'
: 'hover:bg-muted'
}`}
>
<Grid3X3 className="h-4 w-4" />
<span>All Assets</span>
<span className="ml-auto text-xs text-muted-foreground">
{assets.length}
</span>
</button>
<button
onClick={() => setFilter({ favorite: true })}
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm hover:bg-muted transition-colors"
>
<Star className="h-4 w-4 text-yellow-500" />
<span>Favorites</span>
<span className="ml-auto text-xs text-muted-foreground">
{favorites.length}
</span>
</button>
<Separator className="my-2" />
<p className="px-3 py-1 text-xs font-medium text-muted-foreground uppercase">
By Type
</p>
{(Object.keys(TypeIcon) as AssetType[]).map((type) => (
<button
key={type}
onClick={() => setSelectedType(type)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors ${
selectedType === type
? 'bg-primary/10 text-primary'
: 'hover:bg-muted'
}`}
>
<span style={{ color: ASSET_TYPE_COLORS[type] }}>
{TypeIcon[type]}
</span>
<span className="capitalize">{type}</span>
<span className="ml-auto text-xs text-muted-foreground">
{typeCounts[type] || 0}
</span>
</button>
))}
</div>
</ScrollArea>
{/* Folders */}
<Separator />
<div className="p-2">
<div className="flex items-center justify-between px-2 py-1">
<span className="text-xs font-medium text-muted-foreground uppercase">
Folders
</span>
<Button variant="ghost" size="icon" className="h-6 w-6">
<FolderPlus className="h-3 w-3" />
</Button>
</div>
{folders.map((folder) => (
<button
key={folder.id}
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm hover:bg-muted transition-colors"
>
<Folder className="h-4 w-4 text-yellow-500" />
<span>{folder.name}</span>
</button>
))}
</div>
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col min-h-0">
{/* Toolbar */}
<div className="flex items-center gap-2 p-3 border-b">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search assets..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Select
value={sort.field}
onValueChange={(field: any) => setSort({ ...sort, field })}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="createdAt">Date Added</SelectItem>
<SelectItem value="name">Name</SelectItem>
<SelectItem value="fileSize">Size</SelectItem>
<SelectItem value="type">Type</SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
onClick={() =>
setSort({
...sort,
direction: sort.direction === 'asc' ? 'desc' : 'asc',
})
}
>
{sort.direction === 'asc' ? (
<SortAsc className="h-4 w-4" />
) : (
<SortDesc className="h-4 w-4" />
)}
</Button>
<Separator orientation="vertical" className="h-6" />
<Button
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
size="icon"
onClick={() => setViewMode('grid')}
>
<Grid3X3 className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
size="icon"
onClick={() => setViewMode('list')}
>
<List className="h-4 w-4" />
</Button>
</div>
{/* Drop Zone / Asset Grid */}
<div
className={`flex-1 min-h-0 relative ${
isDragging ? 'bg-primary/5' : ''
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{isDragging && (
<div className="absolute inset-0 flex items-center justify-center bg-primary/10 border-2 border-dashed border-primary z-10">
<div className="text-center">
<Upload className="h-12 w-12 mx-auto text-primary mb-2" />
<p className="text-lg font-medium text-primary">
Drop files to upload
</p>
</div>
</div>
)}
{filteredAssets.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<Package className="h-16 w-16 text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium mb-2">No assets found</h3>
<p className="text-sm text-muted-foreground mb-4">
{searchQuery
? `No results for "${searchQuery}"`
: 'Upload some assets to get started'}
</p>
<Button onClick={() => fileInputRef.current?.click()}>
<Upload className="h-4 w-4 mr-2" />
Upload Assets
</Button>
</div>
) : (
<ScrollArea className="h-full">
{viewMode === 'grid' ? (
<div className="grid grid-cols-4 gap-4 p-4">
{filteredAssets.map((asset) => (
<AssetCard
key={asset.id}
asset={asset}
isSelected={selectedAssetId === asset.id}
onSelect={() => setSelectedAsset(asset.id)}
onDoubleClick={() => onAssetSelect?.(asset)}
onFavorite={() => toggleFavorite(asset.id)}
onDelete={() => removeAsset(asset.id)}
/>
))}
</div>
) : (
<div className="divide-y">
{filteredAssets.map((asset) => (
<AssetRow
key={asset.id}
asset={asset}
isSelected={selectedAssetId === asset.id}
onSelect={() => setSelectedAsset(asset.id)}
onDoubleClick={() => onAssetSelect?.(asset)}
onFavorite={() => toggleFavorite(asset.id)}
onDelete={() => removeAsset(asset.id)}
/>
))}
</div>
)}
</ScrollArea>
)}
</div>
</div>
{/* Details Panel */}
{selectedAsset && (
<div className="w-72 border-l bg-muted/30 flex flex-col">
<div className="p-4 border-b">
<div className="flex items-start justify-between">
<h3 className="font-medium truncate pr-2">{selectedAsset.name}</h3>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setSelectedAsset(null)}
>
<X className="h-4 w-4" />
</Button>
</div>
<Badge
variant="secondary"
className="mt-2"
style={{ color: ASSET_TYPE_COLORS[selectedAsset.type] }}
>
{selectedAsset.type}
</Badge>
</div>
{/* Preview */}
<div className="p-4 border-b">
<div className="aspect-square bg-muted rounded-lg flex items-center justify-center overflow-hidden">
{selectedAsset.thumbnailUrl ? (
<img
src={selectedAsset.thumbnailUrl}
alt={selectedAsset.name}
className="w-full h-full object-contain"
/>
) : selectedAsset.type === 'audio' ? (
<div className="text-center p-4">
<Music className="h-12 w-12 mx-auto text-muted-foreground mb-2" />
{selectedAsset.dataUrl && (
<audio controls className="w-full mt-2">
<source src={selectedAsset.dataUrl} />
</audio>
)}
</div>
) : (
<div
className="text-muted-foreground"
style={{ color: ASSET_TYPE_COLORS[selectedAsset.type] }}
>
{TypeIcon[selectedAsset.type]}
</div>
)}
</div>
</div>
{/* Metadata */}
<ScrollArea className="flex-1">
<div className="p-4 space-y-4">
<div>
<p className="text-xs text-muted-foreground uppercase mb-1">
File Info
</p>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Size</span>
<span>{formatFileSize(selectedAsset.metadata.fileSize)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Format</span>
<span className="uppercase">
{selectedAsset.metadata.extension}
</span>
</div>
{selectedAsset.metadata.width && (
<div className="flex justify-between">
<span className="text-muted-foreground">Dimensions</span>
<span>
{selectedAsset.metadata.width}×{selectedAsset.metadata.height}
</span>
</div>
)}
{selectedAsset.metadata.duration && (
<div className="flex justify-between">
<span className="text-muted-foreground">Duration</span>
<span>{selectedAsset.metadata.duration.toFixed(1)}s</span>
</div>
)}
</div>
</div>
{selectedAsset.tags.length > 0 && (
<div>
<p className="text-xs text-muted-foreground uppercase mb-2">
Tags
</p>
<div className="flex flex-wrap gap-1">
{selectedAsset.tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
)}
<div>
<p className="text-xs text-muted-foreground uppercase mb-2">
Actions
</p>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => toggleFavorite(selectedAsset.id)}
>
<Star
className={`h-4 w-4 mr-1 ${
selectedAsset.favorite ? 'fill-yellow-500 text-yellow-500' : ''
}`}
/>
{selectedAsset.favorite ? 'Unfavorite' : 'Favorite'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
// Copy asset reference
navigator.clipboard.writeText(selectedAsset.id);
toast.success('Asset ID copied!');
}}
>
<Copy className="h-4 w-4 mr-1" />
Copy ID
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => {
removeAsset(selectedAsset.id);
toast.success('Asset deleted');
}}
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</Button>
</div>
</div>
</div>
</ScrollArea>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
// Asset Card Component (Grid View)
function AssetCard({
asset,
isSelected,
onSelect,
onDoubleClick,
onFavorite,
onDelete,
}: {
asset: Asset;
isSelected: boolean;
onSelect: () => void;
onDoubleClick: () => void;
onFavorite: () => void;
onDelete: () => void;
}) {
return (
<div
className={`group relative rounded-lg border bg-card overflow-hidden cursor-pointer transition-all ${
isSelected ? 'ring-2 ring-primary border-primary' : 'hover:border-primary/50'
}`}
onClick={onSelect}
onDoubleClick={onDoubleClick}
>
{/* Thumbnail */}
<div className="aspect-square bg-muted flex items-center justify-center overflow-hidden">
{asset.thumbnailUrl ? (
<img
src={asset.thumbnailUrl}
alt={asset.name}
className="w-full h-full object-cover"
/>
) : (
<div style={{ color: ASSET_TYPE_COLORS[asset.type] }}>
{TypeIcon[asset.type]}
</div>
)}
</div>
{/* Info */}
<div className="p-2">
<p className="text-sm font-medium truncate">{asset.name}</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(asset.metadata.fileSize)}
</p>
</div>
{/* Favorite Badge */}
{asset.favorite && (
<Star className="absolute top-2 right-2 h-4 w-4 fill-yellow-500 text-yellow-500" />
)}
{/* Hover Actions */}
<div className="absolute top-2 left-2 opacity-0 group-hover:opacity-100 transition-opacity">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" className="h-6 w-6">
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={onFavorite}>
<Star className="h-4 w-4 mr-2" />
{asset.favorite ? 'Unfavorite' : 'Favorite'}
</DropdownMenuItem>
<DropdownMenuItem>
<Download className="h-4 w-4 mr-2" />
Download
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive" onClick={onDelete}>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Type Badge */}
<Badge
variant="secondary"
className="absolute bottom-2 right-2 text-xs"
style={{ color: ASSET_TYPE_COLORS[asset.type] }}
>
{asset.type}
</Badge>
</div>
);
}
// Asset Row Component (List View)
function AssetRow({
asset,
isSelected,
onSelect,
onDoubleClick,
onFavorite,
onDelete,
}: {
asset: Asset;
isSelected: boolean;
onSelect: () => void;
onDoubleClick: () => void;
onFavorite: () => void;
onDelete: () => void;
}) {
return (
<div
className={`flex items-center gap-4 px-4 py-3 cursor-pointer transition-colors ${
isSelected ? 'bg-primary/10' : 'hover:bg-muted/50'
}`}
onClick={onSelect}
onDoubleClick={onDoubleClick}
>
{/* Thumbnail */}
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center overflow-hidden flex-shrink-0">
{asset.thumbnailUrl ? (
<img
src={asset.thumbnailUrl}
alt={asset.name}
className="w-full h-full object-cover"
/>
) : (
<div style={{ color: ASSET_TYPE_COLORS[asset.type] }}>
{TypeIcon[asset.type]}
</div>
)}
</div>
{/* Name */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{asset.name}</p>
<p className="text-xs text-muted-foreground capitalize">{asset.type}</p>
</div>
{/* Size */}
<span className="text-sm text-muted-foreground w-20 text-right">
{formatFileSize(asset.metadata.fileSize)}
</span>
{/* Favorite */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onFavorite();
}}
>
<Star
className={`h-4 w-4 ${
asset.favorite ? 'fill-yellow-500 text-yellow-500' : ''
}`}
/>
</Button>
{/* Actions */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Download className="h-4 w-4 mr-2" />
Download
</DropdownMenuItem>
<DropdownMenuItem>
<Copy className="h-4 w-4 mr-2" />
Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive" onClick={onDelete}>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View file

@ -0,0 +1,363 @@
'use client';
import { useState, lazy, Suspense } from 'react';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Play,
Square,
Pause,
RotateCcw,
Settings,
Maximize2,
Minimize2,
Eye,
EyeOff,
Grid3X3,
Box,
Sun,
Terminal,
Loader2,
} from 'lucide-react';
import { usePreviewStore } from '@/stores/preview-store';
import PreviewConsole from './PreviewConsole';
// Lazy load the 3D viewport
const PreviewViewport = lazy(() => import('./PreviewViewport'));
interface LivePreviewProps {
code: string;
onClose?: () => void;
}
export default function LivePreview({ code, onClose }: LivePreviewProps) {
const {
isRunning,
isPaused,
settings,
updateSettings,
runScript,
stopScript,
pauseScript,
resumeScript,
resetScene,
scene,
} = usePreviewStore();
const [isFullscreen, setIsFullscreen] = useState(false);
const [showConsole, setShowConsole] = useState(true);
const handleRun = async () => {
await runScript(code);
};
const handleStop = () => {
stopScript();
};
const handleReset = () => {
resetScene();
};
return (
<div
className={`flex flex-col bg-background ${
isFullscreen ? 'fixed inset-0 z-50' : 'h-full'
}`}
>
{/* Toolbar */}
<div className="flex items-center justify-between px-3 py-2 border-b bg-card">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm">Live Preview</span>
<Badge variant="secondary" className="text-xs">
Beta
</Badge>
</div>
<div className="flex items-center gap-1">
{/* Run controls */}
{!isRunning ? (
<Button
variant="default"
size="sm"
onClick={handleRun}
className="h-8 px-3 gap-1"
>
<Play className="h-4 w-4" />
Run
</Button>
) : (
<>
<Button
variant="outline"
size="sm"
onClick={isPaused ? resumeScript : pauseScript}
className="h-8 px-3 gap-1"
>
{isPaused ? (
<>
<Play className="h-4 w-4" />
Resume
</>
) : (
<>
<Pause className="h-4 w-4" />
Pause
</>
)}
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleStop}
className="h-8 px-3 gap-1"
>
<Square className="h-4 w-4" />
Stop
</Button>
</>
)}
<Button
variant="outline"
size="sm"
onClick={handleReset}
className="h-8 px-3 gap-1"
>
<RotateCcw className="h-4 w-4" />
Reset
</Button>
<Separator orientation="vertical" className="h-6 mx-1" />
{/* View toggles */}
<Button
variant={showConsole ? 'secondary' : 'ghost'}
size="icon"
className="h-8 w-8"
onClick={() => setShowConsole(!showConsole)}
title={showConsole ? 'Hide Console' : 'Show Console'}
>
<Terminal className="h-4 w-4" />
</Button>
{/* Settings popover */}
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Settings className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64" align="end">
<div className="space-y-4">
<h4 className="font-medium text-sm">Preview Settings</h4>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="showGrid" className="text-xs flex items-center gap-2">
<Grid3X3 className="h-3 w-3" />
Show Grid
</Label>
<Switch
id="showGrid"
checked={settings.showGrid}
onCheckedChange={(checked) =>
updateSettings({ showGrid: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="showAxes" className="text-xs flex items-center gap-2">
<Box className="h-3 w-3" />
Show Axes
</Label>
<Switch
id="showAxes"
checked={settings.showAxes}
onCheckedChange={(checked) =>
updateSettings({ showAxes: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="shadows" className="text-xs flex items-center gap-2">
<Sun className="h-3 w-3" />
Shadows
</Label>
<Switch
id="shadows"
checked={settings.shadowsEnabled}
onCheckedChange={(checked) =>
updateSettings({ shadowsEnabled: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="wireframe" className="text-xs flex items-center gap-2">
<Eye className="h-3 w-3" />
Wireframe
</Label>
<Switch
id="wireframe"
checked={settings.showWireframe}
onCheckedChange={(checked) =>
updateSettings({ showWireframe: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="stats" className="text-xs">
Show Stats
</Label>
<Switch
id="stats"
checked={settings.showStats}
onCheckedChange={(checked) =>
updateSettings({ showStats: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="autoRotate" className="text-xs">
Auto Rotate
</Label>
<Switch
id="autoRotate"
checked={settings.autoRotate}
onCheckedChange={(checked) =>
updateSettings({ autoRotate: checked })
}
/>
</div>
<Separator />
<div className="space-y-2">
<Label className="text-xs">Background</Label>
<Select
value={settings.backgroundColor}
onValueChange={(value) =>
updateSettings({ backgroundColor: value })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="#1a1a2e">Dark Blue</SelectItem>
<SelectItem value="#0d0d0d">Black</SelectItem>
<SelectItem value="#1a1a1a">Dark Gray</SelectItem>
<SelectItem value="#2d2d44">Slate</SelectItem>
<SelectItem value="#87ceeb">Sky Blue</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</PopoverContent>
</Popover>
{/* Fullscreen toggle */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setIsFullscreen(!isFullscreen)}
>
{isFullscreen ? (
<Minimize2 className="h-4 w-4" />
) : (
<Maximize2 className="h-4 w-4" />
)}
</Button>
</div>
</div>
{/* Main content */}
<div className="flex-1 min-h-0">
<ResizablePanelGroup direction="vertical">
{/* 3D Viewport */}
<ResizablePanel defaultSize={showConsole ? 70 : 100} minSize={30}>
<div className="relative h-full">
<Suspense
fallback={
<div className="flex items-center justify-center h-full bg-muted/50">
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Loading 3D viewport...
</span>
</div>
</div>
}
>
<PreviewViewport />
</Suspense>
{/* Running indicator */}
{isRunning && (
<div className="absolute top-3 left-3">
<Badge
variant="default"
className={`gap-1 ${isPaused ? 'bg-yellow-500' : 'bg-green-500'}`}
>
<span
className={`w-2 h-2 rounded-full ${
isPaused ? 'bg-yellow-300' : 'bg-green-300 animate-pulse'
}`}
/>
{isPaused ? 'Paused' : 'Running'}
</Badge>
</div>
)}
{/* Instance count */}
<div className="absolute bottom-3 left-3">
<Badge variant="secondary" className="text-xs">
{scene.instances.length} objects
</Badge>
</div>
</div>
</ResizablePanel>
{/* Console */}
{showConsole && (
<>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={30} minSize={15} maxSize={50}>
<PreviewConsole />
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</div>
</div>
);
}

View file

@ -0,0 +1,263 @@
'use client';
import { useRef, useEffect, useState } from 'react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Trash2,
Search,
AlertTriangle,
AlertCircle,
Info,
MessageSquare,
Filter,
ChevronDown,
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { usePreviewStore } from '@/stores/preview-store';
import { ConsoleOutput } from '@/lib/preview/types';
// Icons for different output types
const OUTPUT_ICONS: Record<ConsoleOutput['type'], React.ReactNode> = {
log: <MessageSquare className="h-3 w-3" />,
warn: <AlertTriangle className="h-3 w-3" />,
error: <AlertCircle className="h-3 w-3" />,
info: <Info className="h-3 w-3" />,
};
// Colors for different output types
const OUTPUT_COLORS: Record<ConsoleOutput['type'], string> = {
log: 'text-foreground',
warn: 'text-yellow-500',
error: 'text-red-500',
info: 'text-blue-500',
};
const OUTPUT_BG: Record<ConsoleOutput['type'], string> = {
log: 'bg-transparent',
warn: 'bg-yellow-500/5',
error: 'bg-red-500/5',
info: 'bg-blue-500/5',
};
interface PreviewConsoleProps {
className?: string;
}
export default function PreviewConsole({ className }: PreviewConsoleProps) {
const { consoleOutputs, clearConsole } = usePreviewStore();
const scrollRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [filters, setFilters] = useState({
log: true,
warn: true,
error: true,
info: true,
});
// Auto-scroll to bottom when new output arrives
useEffect(() => {
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [consoleOutputs, autoScroll]);
// Filter outputs
const filteredOutputs = consoleOutputs.filter((output) => {
// Type filter
if (!filters[output.type]) return false;
// Search filter
if (searchQuery) {
return output.message.toLowerCase().includes(searchQuery.toLowerCase());
}
return true;
});
// Count by type
const counts = consoleOutputs.reduce(
(acc, output) => {
acc[output.type] = (acc[output.type] || 0) + 1;
return acc;
},
{ log: 0, warn: 0, error: 0, info: 0 } as Record<string, number>
);
// Format timestamp
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3,
});
};
return (
<div className={`flex flex-col h-full bg-card ${className || ''}`}>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">Console</span>
{counts.error > 0 && (
<Badge variant="destructive" className="h-5 px-1.5 text-xs">
{counts.error}
</Badge>
)}
{counts.warn > 0 && (
<Badge
variant="secondary"
className="h-5 px-1.5 text-xs bg-yellow-500/20 text-yellow-600"
>
{counts.warn}
</Badge>
)}
</div>
<div className="flex items-center gap-1">
{/* Search */}
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
placeholder="Filter..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-7 w-32 pl-7 text-xs"
/>
</div>
{/* Filter dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<Filter className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuCheckboxItem
checked={filters.log}
onCheckedChange={(checked) =>
setFilters((f) => ({ ...f, log: checked }))
}
>
<MessageSquare className="h-3 w-3 mr-2" />
Logs ({counts.log})
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={filters.info}
onCheckedChange={(checked) =>
setFilters((f) => ({ ...f, info: checked }))
}
>
<Info className="h-3 w-3 mr-2 text-blue-500" />
Info ({counts.info})
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={filters.warn}
onCheckedChange={(checked) =>
setFilters((f) => ({ ...f, warn: checked }))
}
>
<AlertTriangle className="h-3 w-3 mr-2 text-yellow-500" />
Warnings ({counts.warn})
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={filters.error}
onCheckedChange={(checked) =>
setFilters((f) => ({ ...f, error: checked }))
}
>
<AlertCircle className="h-3 w-3 mr-2 text-red-500" />
Errors ({counts.error})
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Clear button */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={clearConsole}
title="Clear console"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
{/* Output list */}
<ScrollArea
ref={scrollRef}
className="flex-1"
onScrollCapture={(e) => {
const target = e.target as HTMLDivElement;
const isAtBottom =
target.scrollHeight - target.scrollTop - target.clientHeight < 50;
setAutoScroll(isAtBottom);
}}
>
<div className="font-mono text-xs">
{filteredOutputs.length === 0 ? (
<div className="flex items-center justify-center h-32 text-muted-foreground">
{consoleOutputs.length === 0
? 'No output yet. Run a script to see results.'
: 'No matching results.'}
</div>
) : (
filteredOutputs.map((output) => (
<div
key={output.id}
className={`flex items-start gap-2 px-3 py-1 border-b border-border/50 hover:bg-accent/5 ${OUTPUT_BG[output.type]}`}
>
<span className={`mt-0.5 ${OUTPUT_COLORS[output.type]}`}>
{OUTPUT_ICONS[output.type]}
</span>
<span className="text-muted-foreground text-[10px] mt-0.5 shrink-0">
{formatTime(output.timestamp)}
</span>
<span
className={`flex-1 whitespace-pre-wrap break-all ${OUTPUT_COLORS[output.type]}`}
>
{output.message}
</span>
</div>
))
)}
</div>
</ScrollArea>
{/* Auto-scroll indicator */}
{!autoScroll && filteredOutputs.length > 0 && (
<div className="absolute bottom-2 right-2">
<Button
variant="secondary"
size="sm"
className="h-7 text-xs shadow-lg"
onClick={() => {
setAutoScroll(true);
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}}
>
<ChevronDown className="h-3 w-3 mr-1" />
Scroll to bottom
</Button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,252 @@
'use client';
import { useRef, useMemo, Suspense } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import {
OrbitControls,
Grid,
GizmoHelper,
GizmoViewport,
Environment,
Stats,
PerspectiveCamera,
} from '@react-three/drei';
import * as THREE from 'three';
import { usePreviewStore } from '@/stores/preview-store';
import { PreviewInstance, MATERIAL_PROPERTIES } from '@/lib/preview/types';
// Single instance mesh component
function InstanceMesh({ instance }: { instance: PreviewInstance }) {
const meshRef = useRef<THREE.Mesh>(null);
// Get material properties
const matProps = MATERIAL_PROPERTIES[instance.material] || MATERIAL_PROPERTIES.Plastic;
// Create geometry based on shape
const geometry = useMemo(() => {
switch (instance.shape) {
case 'Ball':
return new THREE.SphereGeometry(0.5, 32, 32);
case 'Cylinder':
return new THREE.CylinderGeometry(0.5, 0.5, 1, 32);
case 'Wedge':
// Create a wedge shape
const shape = new THREE.Shape();
shape.moveTo(0, 0);
shape.lineTo(1, 0);
shape.lineTo(0, 1);
shape.closePath();
const extrudeSettings = { depth: 1, bevelEnabled: false };
return new THREE.ExtrudeGeometry(shape, extrudeSettings);
case 'Block':
default:
return new THREE.BoxGeometry(1, 1, 1);
}
}, [instance.shape]);
// Create material
const material = useMemo(() => {
const color = new THREE.Color(instance.color.r, instance.color.g, instance.color.b);
if (matProps.emissive) {
return new THREE.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: 0.5,
roughness: matProps.roughness,
metalness: matProps.metalness,
transparent: instance.transparency > 0,
opacity: 1 - instance.transparency,
});
}
return new THREE.MeshStandardMaterial({
color,
roughness: matProps.roughness,
metalness: matProps.metalness,
transparent: instance.transparency > 0,
opacity: 1 - instance.transparency,
});
}, [instance.color, instance.transparency, instance.material, matProps]);
if (!instance.visible) return null;
return (
<mesh
ref={meshRef}
position={[instance.position.x, instance.position.y, instance.position.z]}
rotation={[instance.rotation.x, instance.rotation.y, instance.rotation.z]}
scale={[instance.scale.x, instance.scale.y, instance.scale.z]}
geometry={geometry}
material={material}
castShadow
receiveShadow
/>
);
}
// Scene content
function SceneContent() {
const { scene, settings } = usePreviewStore();
return (
<>
{/* Lighting */}
{scene.lights.map((light) => {
const color = new THREE.Color(light.color.r, light.color.g, light.color.b);
switch (light.type) {
case 'directional':
return (
<directionalLight
key={light.id}
position={[light.position.x, light.position.y, light.position.z]}
color={color}
intensity={light.intensity}
castShadow={settings.shadowsEnabled && light.castShadow}
shadow-mapSize={[2048, 2048]}
shadow-camera-far={200}
shadow-camera-left={-50}
shadow-camera-right={50}
shadow-camera-top={50}
shadow-camera-bottom={-50}
/>
);
case 'point':
return (
<pointLight
key={light.id}
position={[light.position.x, light.position.y, light.position.z]}
color={color}
intensity={light.intensity}
distance={light.range || 10}
castShadow={settings.shadowsEnabled && light.castShadow}
/>
);
case 'spot':
return (
<spotLight
key={light.id}
position={[light.position.x, light.position.y, light.position.z]}
color={color}
intensity={light.intensity}
distance={light.range || 20}
angle={((light.angle || 45) * Math.PI) / 180}
castShadow={settings.shadowsEnabled && light.castShadow}
/>
);
case 'ambient':
return (
<ambientLight key={light.id} color={color} intensity={light.intensity} />
);
default:
return null;
}
})}
{/* Instances */}
{scene.instances.map((instance) => (
<InstanceMesh key={instance.id} instance={instance} />
))}
{/* Grid */}
{settings.showGrid && (
<Grid
args={[100, 100]}
position={[0, 0, 0]}
cellSize={2}
cellThickness={0.5}
cellColor="#4a4a6a"
sectionSize={10}
sectionThickness={1}
sectionColor="#6a6a8a"
fadeDistance={100}
fadeStrength={1}
followCamera={false}
infiniteGrid={true}
/>
)}
{/* Axes Helper */}
{settings.showAxes && (
<GizmoHelper alignment="bottom-right" margin={[80, 80]}>
<GizmoViewport
axisColors={['#ff4444', '#44ff44', '#4444ff']}
labelColor="white"
/>
</GizmoHelper>
)}
</>
);
}
// Camera controller
function CameraController() {
const { scene, settings } = usePreviewStore();
const controlsRef = useRef<any>(null);
useFrame(() => {
if (controlsRef.current && settings.autoRotate) {
controlsRef.current.autoRotate = true;
controlsRef.current.autoRotateSpeed = 1;
} else if (controlsRef.current) {
controlsRef.current.autoRotate = false;
}
});
return (
<>
<PerspectiveCamera
makeDefault
position={[scene.camera.position.x, scene.camera.position.y, scene.camera.position.z]}
fov={scene.camera.fov}
near={scene.camera.near}
far={scene.camera.far}
/>
<OrbitControls
ref={controlsRef}
target={[scene.camera.target.x, scene.camera.target.y, scene.camera.target.z]}
enableDamping
dampingFactor={0.05}
minDistance={1}
maxDistance={500}
/>
</>
);
}
// Loading fallback
function LoadingFallback() {
return (
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#888888" wireframe />
</mesh>
);
}
export default function PreviewViewport() {
const { settings } = usePreviewStore();
return (
<div className="w-full h-full relative">
<Canvas
shadows={settings.shadowsEnabled}
gl={{
antialias: settings.antialias,
toneMapping: THREE.ACESFilmicToneMapping,
toneMappingExposure: 1,
}}
style={{ background: settings.backgroundColor }}
>
<Suspense fallback={<LoadingFallback />}>
<CameraController />
<SceneContent />
<Environment preset="city" background={false} />
</Suspense>
{settings.showStats && <Stats />}
</Canvas>
</div>
);
}

View file

@ -0,0 +1,3 @@
export { default as LivePreview } from './LivePreview';
export { default as PreviewViewport } from './PreviewViewport';
export { default as PreviewConsole } from './PreviewConsole';

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,433 @@
/**
* AI Code Generation v2 - Generation Prompts
* Prompts and utilities for AI-assisted code generation
*/
export interface GenerationPrompt {
id: string;
name: string;
description: string;
category: string;
template: string;
examples: string[];
}
export interface GenerationResult {
code: string;
explanation: string;
warnings: string[];
suggestions: string[];
}
// Platform-specific code patterns
export const PLATFORM_PATTERNS = {
roblox: {
services: [
'Players',
'ReplicatedStorage',
'ServerStorage',
'DataStoreService',
'TweenService',
'RunService',
'UserInputService',
'Workspace',
'Lighting',
'SoundService',
'MarketplaceService',
'TeleportService',
],
commonPatterns: {
playerJoin: `Players.PlayerAdded:Connect(function(player)
-- Code here
end)`,
dataStore: `local DataStore = DataStoreService:GetDataStore("StoreName")
local success, data = pcall(function()
return DataStore:GetAsync(key)
end)`,
tween: `local tweenInfo = TweenInfo.new(duration, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
local tween = TweenService:Create(object, tweenInfo, {Property = value})
tween:Play()`,
remoteEvent: `local RemoteEvent = ReplicatedStorage:WaitForChild("EventName")
-- Server:
RemoteEvent.OnServerEvent:Connect(function(player, ...)
end)
-- Client:
RemoteEvent:FireServer(...)`,
},
},
uefn: {
modules: [
'/Fortnite.com/Devices',
'/Fortnite.com/Characters',
'/Fortnite.com/Game',
'/Verse.org/Simulation',
'/Verse.org/Random',
],
commonPatterns: {
device: `device_name := class(creative_device):
OnBegin<override>()<suspends> : void =
# Code here`,
subscription: `if (Agent := agent[Player]):
Agent.JumpedEvent.Subscribe(OnPlayerJumped)`,
async: `spawn:
# Async code here
Sleep(1.0)`,
},
},
spatial: {
modules: ['spatial', 'three', 'physics', 'networking'],
commonPatterns: {
component: `export class MyComponent extends Component {
onStart(): void {
// Code here
}
}`,
networked: `@networked
class NetworkedState {
@networked health: number = 100;
}`,
},
},
};
// Generation prompt templates
export const GENERATION_PROMPTS: GenerationPrompt[] = [
{
id: 'game-mechanic',
name: 'Game Mechanic',
description: 'Generate a custom game mechanic',
category: 'gameplay',
template: `Generate a {{platform}} script for the following game mechanic:
**Mechanic Description:**
{{description}}
**Requirements:**
- Clean, well-commented code
- Error handling where appropriate
- Modular design for easy modification
- Performance optimized
{{additionalRequirements}}`,
examples: [
'Double jump ability with cooldown',
'Grappling hook that pulls player to surfaces',
'Time-slowing ability when in danger',
],
},
{
id: 'npc-behavior',
name: 'NPC Behavior',
description: 'Generate NPC AI behavior',
category: 'ai',
template: `Generate a {{platform}} NPC behavior script:
**NPC Type:** {{npcType}}
**Behavior:** {{behavior}}
Requirements:
- State machine pattern
- Patrol, chase, and return states
- Detection range: {{detectionRange}} studs
- Attack range: {{attackRange}} studs
{{additionalRequirements}}`,
examples: [
'Guard that patrols and chases intruders',
'Shopkeeper that interacts with players',
'Boss with multiple attack phases',
],
},
{
id: 'ui-system',
name: 'UI System',
description: 'Generate UI components and systems',
category: 'ui',
template: `Generate a {{platform}} UI system:
**UI Type:** {{uiType}}
**Features:** {{features}}
Requirements:
- Responsive design
- Smooth animations
- Accessibility considerations
- Mobile support
{{additionalRequirements}}`,
examples: ['Health bar with animations', 'Inventory grid UI', 'Dialog system with choices'],
},
{
id: 'multiplayer-feature',
name: 'Multiplayer Feature',
description: 'Generate multiplayer/networked features',
category: 'multiplayer',
template: `Generate a {{platform}} multiplayer feature:
**Feature:** {{feature}}
**Players:** {{playerCount}}
Requirements:
- Server-side validation
- Client prediction where needed
- Efficient networking
- Cheat prevention
{{additionalRequirements}}`,
examples: ['Team-based capture points', 'Trading system between players', 'Synchronized events'],
},
];
// Helper function to fill prompt template
export function fillPromptTemplate(
promptId: string,
variables: Record<string, string>
): string {
const prompt = GENERATION_PROMPTS.find((p) => p.id === promptId);
if (!prompt) return '';
let filled = prompt.template;
for (const [key, value] of Object.entries(variables)) {
filled = filled.split(`{{${key}}}`).join(value);
}
return filled;
}
// Code snippet library for common patterns
export const CODE_SNIPPETS = {
roblox: {
'player-data-template': `-- Player Data Management
local Players = game:GetService("Players")
local DataStoreService = game:GetService("DataStoreService")
local DataStore = DataStoreService:GetDataStore("PlayerData")
local PlayerData = {}
local DEFAULT_DATA = {
coins = 0,
level = 1,
inventory = {}
}
local function LoadData(player)
local success, data = pcall(function()
return DataStore:GetAsync("Player_" .. player.UserId)
end)
if success and data then
PlayerData[player] = data
else
PlayerData[player] = table.clone(DEFAULT_DATA)
end
end
local function SaveData(player)
if not PlayerData[player] then return end
pcall(function()
DataStore:SetAsync("Player_" .. player.UserId, PlayerData[player])
end)
end
Players.PlayerAdded:Connect(LoadData)
Players.PlayerRemoving:Connect(function(player)
SaveData(player)
PlayerData[player] = nil
end)
game:BindToClose(function()
for _, player in ipairs(Players:GetPlayers()) do
SaveData(player)
end
end)`,
'tween-utility': `-- Tween Utility Module
local TweenService = game:GetService("TweenService")
local TweenUtility = {}
function TweenUtility.Tween(object, properties, duration, easingStyle, easingDirection)
easingStyle = easingStyle or Enum.EasingStyle.Quad
easingDirection = easingDirection or Enum.EasingDirection.Out
local tweenInfo = TweenInfo.new(duration, easingStyle, easingDirection)
local tween = TweenService:Create(object, tweenInfo, properties)
tween:Play()
return tween
end
function TweenUtility.FadeIn(guiObject, duration)
guiObject.Visible = true
return TweenUtility.Tween(guiObject, {BackgroundTransparency = 0}, duration or 0.3)
end
function TweenUtility.FadeOut(guiObject, duration)
local tween = TweenUtility.Tween(guiObject, {BackgroundTransparency = 1}, duration or 0.3)
tween.Completed:Connect(function()
guiObject.Visible = false
end)
return tween
end
return TweenUtility`,
'signal-class': `-- Signal/Event Class
local Signal = {}
Signal.__index = Signal
function Signal.new()
return setmetatable({
_connections = {}
}, Signal)
end
function Signal:Connect(callback)
local connection = {
_callback = callback,
_signal = self
}
function connection:Disconnect()
for i, conn in ipairs(self._signal._connections) do
if conn == self then
table.remove(self._signal._connections, i)
break
end
end
end
table.insert(self._connections, connection)
return connection
end
function Signal:Fire(...)
for _, connection in ipairs(self._connections) do
task.spawn(connection._callback, ...)
end
end
function Signal:Wait()
local thread = coroutine.running()
local connection
connection = self:Connect(function(...)
connection:Disconnect()
task.spawn(thread, ...)
end)
return coroutine.yield()
end
return Signal`,
},
uefn: {
'device-template': `# Basic Device Template
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
device_name := class(creative_device):
# Device references
@editable TriggerDevice : trigger_device = trigger_device{}
# Variables
var IsActive : logic = false
OnBegin<override>()<suspends> : void =
TriggerDevice.TriggeredEvent.Subscribe(OnTriggered)
Print("Device initialized")
OnTriggered(Agent : ?agent) : void =
if (Player := player[Agent?]):
set IsActive = not IsActive
Print("Triggered by player")`,
'player-counter': `# Player Counter
using { /Fortnite.com/Devices }
using { /Fortnite.com/Game }
using { /Verse.org/Simulation }
player_counter := class(creative_device):
var PlayerCount : int = 0
OnBegin<override>()<suspends> : void =
GetPlayspace().PlayerAddedEvent().Subscribe(OnPlayerAdded)
GetPlayspace().PlayerRemovedEvent().Subscribe(OnPlayerRemoved)
OnPlayerAdded(Player : player) : void =
set PlayerCount += 1
Print("Players: {PlayerCount}")
OnPlayerRemoved(Player : player) : void =
set PlayerCount -= 1
Print("Players: {PlayerCount}")`,
},
spatial: {
'component-template': `// Basic Component Template
import { Component, NetworkedComponent } from 'spatial';
export class MyComponent extends Component {
// Properties
private health: number = 100;
private isActive: boolean = true;
onStart(): void {
console.log('Component started');
this.setupListeners();
}
onUpdate(deltaTime: number): void {
if (!this.isActive) return;
// Update logic here
}
onDestroy(): void {
console.log('Component destroyed');
}
private setupListeners(): void {
// Setup event listeners
}
}`,
'networked-state': `// Networked State Management
import { NetworkedComponent, networked, rpc, RpcMode } from 'spatial';
export class GameState extends NetworkedComponent {
@networked
private score: number = 0;
@networked
private gamePhase: string = 'waiting';
@rpc(RpcMode.Server)
addScore(amount: number): void {
this.score += amount;
this.onScoreChanged();
}
@rpc(RpcMode.AllClients)
onScoreChanged(): void {
console.log(\`Score updated: \${this.score}\`);
}
@rpc(RpcMode.Server)
setGamePhase(phase: string): void {
this.gamePhase = phase;
}
}`,
},
};
// Get snippets for a platform
export function getSnippetsForPlatform(platform: string): Record<string, string> {
return CODE_SNIPPETS[platform as keyof typeof CODE_SNIPPETS] || {};
}
// Get common patterns for a platform
export function getPatternsForPlatform(
platform: string
): Record<string, string> {
const patterns = PLATFORM_PATTERNS[platform as keyof typeof PLATFORM_PATTERNS];
return patterns?.commonPatterns || {};
}

File diff suppressed because it is too large Load diff

222
src/lib/assets/types.ts Normal file
View file

@ -0,0 +1,222 @@
/**
* AeThex Asset Library - Type Definitions
* Types for managing game assets (models, textures, audio, etc.)
*/
export type AssetType =
| 'model' // 3D models (GLB, FBX, OBJ)
| 'texture' // Images (PNG, JPG, WEBP)
| 'audio' // Sounds (MP3, WAV, OGG)
| 'animation' // Animation files
| 'script' // Code snippets
| 'prefab' // Pre-made game objects
| 'material' // Material definitions
| 'particle' // Particle effects
| 'ui' // UI elements
| 'data'; // Data files (JSON, CSV)
export type AssetCategory =
| 'characters'
| 'environments'
| 'props'
| 'vehicles'
| 'weapons'
| 'effects'
| 'ui-elements'
| 'sounds'
| 'music'
| 'scripts'
| 'materials'
| 'uncategorized';
export interface AssetMetadata {
// File info
fileName: string;
fileSize: number;
mimeType: string;
extension: string;
// Asset-specific metadata
width?: number; // For images
height?: number; // For images
duration?: number; // For audio/video
sampleRate?: number; // For audio
channels?: number; // For audio
polyCount?: number; // For 3D models
vertexCount?: number; // For 3D models
hasAnimations?: boolean; // For 3D models
boneCount?: number; // For 3D models
materialCount?: number; // For 3D models
}
export interface Asset {
id: string;
name: string;
type: AssetType;
category: AssetCategory;
description?: string;
tags: string[];
// File data
file?: File;
dataUrl?: string; // Base64 for small files
blobUrl?: string; // Object URL for large files
thumbnailUrl?: string; // Preview image
// Metadata
metadata: AssetMetadata;
// Organization
folderId?: string;
favorite: boolean;
// Timestamps
createdAt: Date;
updatedAt: Date;
// Platform compatibility
platforms: ('roblox' | 'uefn' | 'spatial' | 'universal')[];
}
export interface AssetFolder {
id: string;
name: string;
parentId?: string;
color?: string;
icon?: string;
createdAt: Date;
}
export interface AssetUploadOptions {
name?: string;
category?: AssetCategory;
tags?: string[];
folderId?: string;
generateThumbnail?: boolean;
}
export interface AssetFilter {
type?: AssetType | AssetType[];
category?: AssetCategory | AssetCategory[];
tags?: string[];
folderId?: string;
favorite?: boolean;
search?: string;
}
export interface AssetSortOptions {
field: 'name' | 'createdAt' | 'updatedAt' | 'fileSize' | 'type';
direction: 'asc' | 'desc';
}
// File format mappings
export const FILE_TYPE_MAP: Record<string, AssetType> = {
// Models
'.glb': 'model',
'.gltf': 'model',
'.fbx': 'model',
'.obj': 'model',
'.dae': 'model',
// Textures
'.png': 'texture',
'.jpg': 'texture',
'.jpeg': 'texture',
'.webp': 'texture',
'.svg': 'texture',
'.gif': 'texture',
'.bmp': 'texture',
'.tga': 'texture',
// Audio
'.mp3': 'audio',
'.wav': 'audio',
'.ogg': 'audio',
'.m4a': 'audio',
'.flac': 'audio',
// Animations
'.bvh': 'animation',
'.anim': 'animation',
// Scripts
'.lua': 'script',
'.verse': 'script',
'.ts': 'script',
'.js': 'script',
// Data
'.json': 'data',
'.csv': 'data',
'.xml': 'data',
};
export const ASSET_TYPE_ICONS: Record<AssetType, string> = {
model: 'Box',
texture: 'Image',
audio: 'Volume2',
animation: 'Play',
script: 'FileCode',
prefab: 'Package',
material: 'Palette',
particle: 'Sparkles',
ui: 'Layout',
data: 'Database',
};
export const ASSET_TYPE_COLORS: Record<AssetType, string> = {
model: '#3b82f6', // Blue
texture: '#22c55e', // Green
audio: '#f97316', // Orange
animation: '#a855f7', // Purple
script: '#eab308', // Yellow
prefab: '#ec4899', // Pink
material: '#14b8a6', // Teal
particle: '#f43f5e', // Rose
ui: '#8b5cf6', // Violet
data: '#6b7280', // Gray
};
export const CATEGORY_LABELS: Record<AssetCategory, string> = {
characters: 'Characters',
environments: 'Environments',
props: 'Props',
vehicles: 'Vehicles',
weapons: 'Weapons',
effects: 'Effects',
'ui-elements': 'UI Elements',
sounds: 'Sound Effects',
music: 'Music',
scripts: 'Scripts',
materials: 'Materials',
uncategorized: 'Uncategorized',
};
// Helper functions
export function getAssetTypeFromFile(file: File): AssetType {
const extension = '.' + file.name.split('.').pop()?.toLowerCase();
return FILE_TYPE_MAP[extension] || 'data';
}
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
export function getAcceptedFileTypes(type?: AssetType): string {
if (!type) {
return Object.keys(FILE_TYPE_MAP).join(',');
}
return Object.entries(FILE_TYPE_MAP)
.filter(([_, t]) => t === type)
.map(([ext]) => ext)
.join(',');
}
export function generateAssetId(): string {
return `asset-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}

577
src/lib/avatar-formats.ts Normal file
View file

@ -0,0 +1,577 @@
/**
* AeThex Avatar Format Handlers
* Import/export handlers for various 3D avatar formats
*/
import { AvatarPlatformId, avatarPlatforms, AvatarConstraints } from './avatar-platforms';
import { validateRig, autoMapBones, RigValidationResult } from './avatar-rigging';
export type AvatarFileFormat =
| 'glb'
| 'gltf'
| 'fbx'
| 'vrm'
| 'obj'
| 'pmx'
| 'vroid'
| 'aeth'
| 'vox';
export interface AvatarMetadata {
name: string;
author?: string;
version?: string;
description?: string;
license?: string;
thumbnail?: string;
createdAt: Date;
updatedAt: Date;
sourcePlatform?: AvatarPlatformId;
tags?: string[];
customData?: Record<string, unknown>;
}
export interface AvatarMeshInfo {
name: string;
vertexCount: number;
triangleCount: number;
materialIndex: number;
hasNormals: boolean;
hasUVs: boolean;
hasTangents: boolean;
hasVertexColors: boolean;
hasSkinning: boolean;
boneInfluenceCount: number;
}
export interface AvatarMaterialInfo {
name: string;
type: 'standard' | 'pbr' | 'toon' | 'unlit' | 'custom';
color?: { r: number; g: number; b: number; a: number };
metallic?: number;
roughness?: number;
textures: {
diffuse?: string;
normal?: string;
metallic?: string;
roughness?: string;
emission?: string;
occlusion?: string;
};
}
export interface AvatarTextureInfo {
name: string;
width: number;
height: number;
format: 'png' | 'jpg' | 'webp' | 'basis' | 'ktx2';
sizeBytes: number;
mipLevels: number;
}
export interface AvatarStats {
totalVertices: number;
totalTriangles: number;
totalBones: number;
totalMaterials: number;
totalTextures: number;
totalBlendShapes: number;
fileSizeMB: number;
maxTextureSize: number;
}
export interface ParsedAvatar {
id: string;
format: AvatarFileFormat;
metadata: AvatarMetadata;
stats: AvatarStats;
meshes: AvatarMeshInfo[];
materials: AvatarMaterialInfo[];
textures: AvatarTextureInfo[];
bones: string[];
blendShapes: string[];
animations: string[];
rawData?: ArrayBuffer;
}
export interface ValidationIssue {
type: 'error' | 'warning' | 'info';
code: string;
message: string;
details?: string;
autoFix?: boolean;
}
export interface AvatarValidationResult {
isValid: boolean;
score: number;
issues: ValidationIssue[];
rigValidation: RigValidationResult;
constraints: {
polygons: { current: number; max: number; passed: boolean };
bones: { current: number; max: number; passed: boolean };
materials: { current: number; max: number; passed: boolean };
textureSize: { current: number; max: number; passed: boolean };
fileSize: { current: number; max: number; passed: boolean };
};
optimizationSuggestions: string[];
}
export interface ExportOptions {
format: AvatarFileFormat;
platform: AvatarPlatformId;
optimizeForPlatform: boolean;
embedTextures: boolean;
compressTextures: boolean;
targetTextureSize?: number;
preserveBlendShapes: boolean;
preserveAnimations: boolean;
generateLODs: boolean;
lodLevels?: number[];
}
export interface ImportResult {
success: boolean;
avatar?: ParsedAvatar;
errors: string[];
warnings: string[];
}
export interface ExportResult {
success: boolean;
data?: Blob;
filename: string;
errors: string[];
warnings: string[];
stats?: {
originalSize: number;
exportedSize: number;
reductionPercent: number;
};
}
// Format specifications
export const FORMAT_SPECS: Record<AvatarFileFormat, {
name: string;
extension: string;
mimeType: string;
description: string;
supportsAnimations: boolean;
supportsSkinning: boolean;
supportsBlendShapes: boolean;
binary: boolean;
}> = {
glb: {
name: 'glTF Binary',
extension: '.glb',
mimeType: 'model/gltf-binary',
description: 'Optimized binary format, ideal for web and real-time applications',
supportsAnimations: true,
supportsSkinning: true,
supportsBlendShapes: true,
binary: true,
},
gltf: {
name: 'glTF',
extension: '.gltf',
mimeType: 'model/gltf+json',
description: 'JSON-based format with external resources',
supportsAnimations: true,
supportsSkinning: true,
supportsBlendShapes: true,
binary: false,
},
fbx: {
name: 'FBX',
extension: '.fbx',
mimeType: 'application/octet-stream',
description: 'Autodesk format, widely used in game development',
supportsAnimations: true,
supportsSkinning: true,
supportsBlendShapes: true,
binary: true,
},
vrm: {
name: 'VRM',
extension: '.vrm',
mimeType: 'model/gltf-binary',
description: 'VR avatar format based on glTF with humanoid extensions',
supportsAnimations: true,
supportsSkinning: true,
supportsBlendShapes: true,
binary: true,
},
obj: {
name: 'Wavefront OBJ',
extension: '.obj',
mimeType: 'text/plain',
description: 'Simple geometry format, no rigging support',
supportsAnimations: false,
supportsSkinning: false,
supportsBlendShapes: false,
binary: false,
},
pmx: {
name: 'PMX',
extension: '.pmx',
mimeType: 'application/octet-stream',
description: 'MikuMikuDance format for anime-style characters',
supportsAnimations: true,
supportsSkinning: true,
supportsBlendShapes: true,
binary: true,
},
vroid: {
name: 'VRoid',
extension: '.vroid',
mimeType: 'application/octet-stream',
description: 'VRoid Studio project format',
supportsAnimations: true,
supportsSkinning: true,
supportsBlendShapes: true,
binary: true,
},
aeth: {
name: 'AeThex Universal',
extension: '.aeth',
mimeType: 'application/x-aethex-avatar',
description: 'AeThex universal avatar format with full platform metadata',
supportsAnimations: true,
supportsSkinning: true,
supportsBlendShapes: true,
binary: true,
},
vox: {
name: 'MagicaVoxel',
extension: '.vox',
mimeType: 'application/octet-stream',
description: 'Voxel format for blocky/pixelated avatars',
supportsAnimations: false,
supportsSkinning: false,
supportsBlendShapes: false,
binary: true,
},
};
/**
* Detect file format from file extension or MIME type
*/
export function detectFormat(filename: string, mimeType?: string): AvatarFileFormat | null {
const ext = filename.toLowerCase().split('.').pop();
for (const [format, spec] of Object.entries(FORMAT_SPECS)) {
if (spec.extension === `.${ext}` || spec.mimeType === mimeType) {
return format as AvatarFileFormat;
}
}
return null;
}
/**
* Check if a format is supported for import
*/
export function canImport(format: AvatarFileFormat): boolean {
return ['glb', 'gltf', 'fbx', 'vrm', 'obj', 'pmx', 'aeth'].includes(format);
}
/**
* Check if a format is supported for export to a specific platform
*/
export function canExport(format: AvatarFileFormat, platform: AvatarPlatformId): boolean {
const platformData = avatarPlatforms[platform];
return platformData.importFormats.includes(format);
}
/**
* Get recommended export format for a platform
*/
export function getRecommendedFormat(platform: AvatarPlatformId): AvatarFileFormat {
return avatarPlatforms[platform].exportFormat as AvatarFileFormat;
}
/**
* Validate avatar against platform constraints
*/
export function validateForPlatform(
avatar: ParsedAvatar,
platform: AvatarPlatformId
): AvatarValidationResult {
const constraints = avatarPlatforms[platform].constraints;
const issues: ValidationIssue[] = [];
const optimizationSuggestions: string[] = [];
// Polygon check
const polygonsPassed = avatar.stats.totalTriangles <= constraints.maxPolygons;
if (!polygonsPassed) {
issues.push({
type: 'error',
code: 'POLY_LIMIT',
message: `Triangle count (${avatar.stats.totalTriangles}) exceeds platform limit (${constraints.maxPolygons})`,
details: `Reduce by ${avatar.stats.totalTriangles - constraints.maxPolygons} triangles`,
autoFix: true,
});
optimizationSuggestions.push(
`Use mesh decimation to reduce triangle count to ${constraints.maxPolygons}`
);
}
// Bone check
const bonesPassed = avatar.stats.totalBones <= constraints.maxBones;
if (!bonesPassed) {
issues.push({
type: 'error',
code: 'BONE_LIMIT',
message: `Bone count (${avatar.stats.totalBones}) exceeds platform limit (${constraints.maxBones})`,
details: `Remove ${avatar.stats.totalBones - constraints.maxBones} bones`,
autoFix: false,
});
optimizationSuggestions.push(
`Remove non-essential bones or merge small bone chains`
);
}
// Material check
const materialsPassed = avatar.stats.totalMaterials <= constraints.maxMaterials;
if (!materialsPassed) {
issues.push({
type: 'warning',
code: 'MATERIAL_LIMIT',
message: `Material count (${avatar.stats.totalMaterials}) exceeds platform limit (${constraints.maxMaterials})`,
details: `Merge ${avatar.stats.totalMaterials - constraints.maxMaterials} materials`,
autoFix: true,
});
optimizationSuggestions.push(
`Merge materials using texture atlasing`
);
}
// Texture size check
const textureSizePassed = avatar.stats.maxTextureSize <= constraints.maxTextureSize;
if (!textureSizePassed) {
issues.push({
type: 'warning',
code: 'TEXTURE_SIZE',
message: `Max texture size (${avatar.stats.maxTextureSize}px) exceeds platform limit (${constraints.maxTextureSize}px)`,
autoFix: true,
});
optimizationSuggestions.push(
`Resize textures to ${constraints.maxTextureSize}x${constraints.maxTextureSize}`
);
}
// File size check
const fileSizePassed = avatar.stats.fileSizeMB <= constraints.maxFileSize;
if (!fileSizePassed) {
issues.push({
type: 'error',
code: 'FILE_SIZE',
message: `File size (${avatar.stats.fileSizeMB.toFixed(2)}MB) exceeds platform limit (${constraints.maxFileSize}MB)`,
autoFix: true,
});
optimizationSuggestions.push(
`Compress textures and reduce mesh complexity`
);
}
// Rig validation
const rigValidation = validateRig(avatar.bones, platform);
if (!rigValidation.isValid) {
issues.push({
type: 'error',
code: 'RIG_INVALID',
message: `Rig is missing ${rigValidation.missingRequiredBones.length} required bones`,
details: `Missing: ${rigValidation.missingRequiredBones.join(', ')}`,
autoFix: false,
});
}
if (rigValidation.warnings.length > 0) {
for (const warning of rigValidation.warnings) {
issues.push({
type: 'warning',
code: 'RIG_WARNING',
message: warning,
autoFix: false,
});
}
}
// Calculate overall score
const passedChecks = [polygonsPassed, bonesPassed, materialsPassed, textureSizePassed, fileSizePassed]
.filter(Boolean).length;
const constraintScore = (passedChecks / 5) * 50;
const rigScore = rigValidation.score * 0.5;
const overallScore = Math.round(constraintScore + rigScore);
return {
isValid: polygonsPassed && bonesPassed && fileSizePassed && rigValidation.isValid,
score: overallScore,
issues,
rigValidation,
constraints: {
polygons: { current: avatar.stats.totalTriangles, max: constraints.maxPolygons, passed: polygonsPassed },
bones: { current: avatar.stats.totalBones, max: constraints.maxBones, passed: bonesPassed },
materials: { current: avatar.stats.totalMaterials, max: constraints.maxMaterials, passed: materialsPassed },
textureSize: { current: avatar.stats.maxTextureSize, max: constraints.maxTextureSize, passed: textureSizePassed },
fileSize: { current: avatar.stats.fileSizeMB, max: constraints.maxFileSize, passed: fileSizePassed },
},
optimizationSuggestions,
};
}
/**
* Create a mock/demo parsed avatar for testing
*/
export function createDemoAvatar(name: string, platform: AvatarPlatformId): ParsedAvatar {
const platformData = avatarPlatforms[platform];
return {
id: `demo-${Date.now()}`,
format: platformData.exportFormat as AvatarFileFormat,
metadata: {
name,
author: 'AeThex Studio',
version: '1.0.0',
description: `Demo avatar for ${platformData.displayName}`,
createdAt: new Date(),
updatedAt: new Date(),
sourcePlatform: platform,
tags: ['demo', platform],
},
stats: {
totalVertices: Math.floor(platformData.constraints.maxPolygons * 0.6),
totalTriangles: Math.floor(platformData.constraints.maxPolygons * 0.5),
totalBones: Math.floor(platformData.constraints.maxBones * 0.8),
totalMaterials: Math.min(4, platformData.constraints.maxMaterials),
totalTextures: 4,
totalBlendShapes: platformData.skeleton.blendShapeSupport ? 52 : 0,
fileSizeMB: platformData.constraints.maxFileSize * 0.3,
maxTextureSize: platformData.constraints.maxTextureSize,
},
meshes: [
{
name: 'Body',
vertexCount: 5000,
triangleCount: 4000,
materialIndex: 0,
hasNormals: true,
hasUVs: true,
hasTangents: true,
hasVertexColors: false,
hasSkinning: true,
boneInfluenceCount: 4,
},
],
materials: [
{
name: 'Skin',
type: 'pbr',
color: { r: 255, g: 224, b: 189, a: 255 },
metallic: 0,
roughness: 0.8,
textures: { diffuse: 'skin_diffuse.png', normal: 'skin_normal.png' },
},
],
textures: [
{ name: 'skin_diffuse.png', width: 1024, height: 1024, format: 'png', sizeBytes: 512000, mipLevels: 10 },
{ name: 'skin_normal.png', width: 1024, height: 1024, format: 'png', sizeBytes: 512000, mipLevels: 10 },
],
bones: platformData.skeleton.bones.map(b => b.name),
blendShapes: platformData.skeleton.blendShapeSupport
? ['Blink', 'Smile', 'Frown', 'Surprise', 'Angry']
: [],
animations: ['Idle', 'Walk', 'Run', 'Jump'],
};
}
/**
* Generate export filename
*/
export function generateExportFilename(
avatarName: string,
platform: AvatarPlatformId,
format: AvatarFileFormat
): string {
const sanitizedName = avatarName.toLowerCase().replace(/[^a-z0-9]/g, '_');
const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const extension = FORMAT_SPECS[format].extension;
return `${sanitizedName}_${platform}_${timestamp}${extension}`;
}
/**
* Get supported import formats
*/
export function getSupportedImportFormats(): AvatarFileFormat[] {
return ['glb', 'gltf', 'fbx', 'vrm', 'obj', 'pmx', 'aeth'];
}
/**
* Get supported export formats for a platform
*/
export function getSupportedExportFormats(platform: AvatarPlatformId): AvatarFileFormat[] {
const platformData = avatarPlatforms[platform];
return platformData.importFormats.filter(f =>
['glb', 'gltf', 'fbx', 'vrm', 'aeth'].includes(f)
) as AvatarFileFormat[];
}
/**
* Estimate export file size based on avatar stats and target format
*/
export function estimateExportSize(
avatar: ParsedAvatar,
targetFormat: AvatarFileFormat,
options: Partial<ExportOptions>
): number {
let baseSize = avatar.stats.fileSizeMB;
// Adjust for format
if (targetFormat === 'glb' && avatar.format !== 'glb') {
baseSize *= 0.7; // GLB is usually more compact
} else if (targetFormat === 'fbx') {
baseSize *= 1.2; // FBX can be larger
}
// Adjust for compression
if (options.compressTextures) {
baseSize *= 0.5;
}
// Adjust for LODs
if (options.generateLODs) {
baseSize *= 1.3;
}
return Math.round(baseSize * 100) / 100;
}
/**
* Get optimization recommendations for an avatar
*/
export function getOptimizationRecommendations(
avatar: ParsedAvatar,
targetPlatform: AvatarPlatformId
): string[] {
const recommendations: string[] = [];
const validation = validateForPlatform(avatar, targetPlatform);
recommendations.push(...validation.optimizationSuggestions);
// Additional recommendations
if (avatar.textures.some(t => t.format !== 'png' && t.format !== 'jpg')) {
recommendations.push('Convert textures to PNG or JPG for maximum compatibility');
}
if (avatar.meshes.length > 5) {
recommendations.push(`Consider merging ${avatar.meshes.length} meshes to reduce draw calls`);
}
if (avatar.materials.some(m => m.type !== 'pbr')) {
recommendations.push('Convert materials to PBR for consistent appearance across platforms');
}
return recommendations;
}

548
src/lib/avatar-platforms.ts Normal file
View file

@ -0,0 +1,548 @@
/**
* AeThex Avatar Platform Configuration
* Cross-platform avatar specifications for Roblox, VRChat, RecRoom, Spatial, Sandbox, and more
*/
export type AvatarPlatformId =
| 'roblox'
| 'vrchat'
| 'recroom'
| 'spatial'
| 'sandbox'
| 'neos'
| 'resonite'
| 'chilloutvr'
| 'decentraland'
| 'meta-horizon'
| 'universal';
export interface BoneMapping {
name: string;
universalName: string;
required: boolean;
parent?: string;
alternateNames?: string[];
}
export interface AvatarConstraints {
maxPolygons: number;
maxBones: number;
maxMaterials: number;
maxTextureSize: number;
maxFileSize: number; // in MB
supportedFormats: string[];
minHeight?: number;
maxHeight?: number;
requiresPhysBones?: boolean;
requiresDynamicBones?: boolean;
}
export interface SkeletonSpec {
type: 'humanoid' | 'generic' | 'r6' | 'r15' | 'rthro' | 'custom';
rootBone: string;
bones: BoneMapping[];
blendShapeSupport: boolean;
maxBlendShapes?: number;
ikSupport: boolean;
fingerTracking: boolean;
eyeTracking: boolean;
fullBodyTracking: boolean;
}
export interface AvatarPlatform {
id: AvatarPlatformId;
name: string;
displayName: string;
description: string;
color: string;
icon: string;
constraints: AvatarConstraints;
skeleton: SkeletonSpec;
exportFormat: string;
importFormats: string[];
documentation: string;
status: 'supported' | 'beta' | 'experimental' | 'coming-soon';
features: string[];
}
// Universal humanoid bone names (industry standard)
export const UNIVERSAL_BONES = {
// Root
ROOT: 'Root',
HIPS: 'Hips',
// Spine
SPINE: 'Spine',
SPINE1: 'Spine1',
SPINE2: 'Spine2',
CHEST: 'Chest',
UPPER_CHEST: 'UpperChest',
NECK: 'Neck',
HEAD: 'Head',
// Left Arm
LEFT_SHOULDER: 'LeftShoulder',
LEFT_UPPER_ARM: 'LeftUpperArm',
LEFT_LOWER_ARM: 'LeftLowerArm',
LEFT_HAND: 'LeftHand',
// Left Fingers
LEFT_THUMB_PROXIMAL: 'LeftThumbProximal',
LEFT_THUMB_INTERMEDIATE: 'LeftThumbIntermediate',
LEFT_THUMB_DISTAL: 'LeftThumbDistal',
LEFT_INDEX_PROXIMAL: 'LeftIndexProximal',
LEFT_INDEX_INTERMEDIATE: 'LeftIndexIntermediate',
LEFT_INDEX_DISTAL: 'LeftIndexDistal',
LEFT_MIDDLE_PROXIMAL: 'LeftMiddleProximal',
LEFT_MIDDLE_INTERMEDIATE: 'LeftMiddleIntermediate',
LEFT_MIDDLE_DISTAL: 'LeftMiddleDistal',
LEFT_RING_PROXIMAL: 'LeftRingProximal',
LEFT_RING_INTERMEDIATE: 'LeftRingIntermediate',
LEFT_RING_DISTAL: 'LeftRingDistal',
LEFT_LITTLE_PROXIMAL: 'LeftLittleProximal',
LEFT_LITTLE_INTERMEDIATE: 'LeftLittleIntermediate',
LEFT_LITTLE_DISTAL: 'LeftLittleDistal',
// Right Arm
RIGHT_SHOULDER: 'RightShoulder',
RIGHT_UPPER_ARM: 'RightUpperArm',
RIGHT_LOWER_ARM: 'RightLowerArm',
RIGHT_HAND: 'RightHand',
// Right Fingers
RIGHT_THUMB_PROXIMAL: 'RightThumbProximal',
RIGHT_THUMB_INTERMEDIATE: 'RightThumbIntermediate',
RIGHT_THUMB_DISTAL: 'RightThumbDistal',
RIGHT_INDEX_PROXIMAL: 'RightIndexProximal',
RIGHT_INDEX_INTERMEDIATE: 'RightIndexIntermediate',
RIGHT_INDEX_DISTAL: 'RightIndexDistal',
RIGHT_MIDDLE_PROXIMAL: 'RightMiddleProximal',
RIGHT_MIDDLE_INTERMEDIATE: 'RightMiddleIntermediate',
RIGHT_MIDDLE_DISTAL: 'RightMiddleDistal',
RIGHT_RING_PROXIMAL: 'RightRingProximal',
RIGHT_RING_INTERMEDIATE: 'RightRingIntermediate',
RIGHT_RING_DISTAL: 'RightRingDistal',
RIGHT_LITTLE_PROXIMAL: 'RightLittleProximal',
RIGHT_LITTLE_INTERMEDIATE: 'RightLittleIntermediate',
RIGHT_LITTLE_DISTAL: 'RightLittleDistal',
// Left Leg
LEFT_UPPER_LEG: 'LeftUpperLeg',
LEFT_LOWER_LEG: 'LeftLowerLeg',
LEFT_FOOT: 'LeftFoot',
LEFT_TOES: 'LeftToes',
// Right Leg
RIGHT_UPPER_LEG: 'RightUpperLeg',
RIGHT_LOWER_LEG: 'RightLowerLeg',
RIGHT_FOOT: 'RightFoot',
RIGHT_TOES: 'RightToes',
// Eyes
LEFT_EYE: 'LeftEye',
RIGHT_EYE: 'RightEye',
JAW: 'Jaw',
} as const;
// VRChat skeleton specification
const vrchatSkeleton: SkeletonSpec = {
type: 'humanoid',
rootBone: 'Armature',
blendShapeSupport: true,
maxBlendShapes: 256,
ikSupport: true,
fingerTracking: true,
eyeTracking: true,
fullBodyTracking: true,
bones: [
{ name: 'Hips', universalName: UNIVERSAL_BONES.HIPS, required: true },
{ name: 'Spine', universalName: UNIVERSAL_BONES.SPINE, required: true, parent: 'Hips' },
{ name: 'Chest', universalName: UNIVERSAL_BONES.CHEST, required: true, parent: 'Spine' },
{ name: 'Upper Chest', universalName: UNIVERSAL_BONES.UPPER_CHEST, required: false, parent: 'Chest' },
{ name: 'Neck', universalName: UNIVERSAL_BONES.NECK, required: true, parent: 'Chest' },
{ name: 'Head', universalName: UNIVERSAL_BONES.HEAD, required: true, parent: 'Neck' },
{ name: 'Left Shoulder', universalName: UNIVERSAL_BONES.LEFT_SHOULDER, required: false, parent: 'Chest' },
{ name: 'Left Upper Arm', universalName: UNIVERSAL_BONES.LEFT_UPPER_ARM, required: true, parent: 'Left Shoulder' },
{ name: 'Left Lower Arm', universalName: UNIVERSAL_BONES.LEFT_LOWER_ARM, required: true, parent: 'Left Upper Arm' },
{ name: 'Left Hand', universalName: UNIVERSAL_BONES.LEFT_HAND, required: true, parent: 'Left Lower Arm' },
{ name: 'Right Shoulder', universalName: UNIVERSAL_BONES.RIGHT_SHOULDER, required: false, parent: 'Chest' },
{ name: 'Right Upper Arm', universalName: UNIVERSAL_BONES.RIGHT_UPPER_ARM, required: true, parent: 'Right Shoulder' },
{ name: 'Right Lower Arm', universalName: UNIVERSAL_BONES.RIGHT_LOWER_ARM, required: true, parent: 'Right Upper Arm' },
{ name: 'Right Hand', universalName: UNIVERSAL_BONES.RIGHT_HAND, required: true, parent: 'Right Lower Arm' },
{ name: 'Left Upper Leg', universalName: UNIVERSAL_BONES.LEFT_UPPER_LEG, required: true, parent: 'Hips' },
{ name: 'Left Lower Leg', universalName: UNIVERSAL_BONES.LEFT_LOWER_LEG, required: true, parent: 'Left Upper Leg' },
{ name: 'Left Foot', universalName: UNIVERSAL_BONES.LEFT_FOOT, required: true, parent: 'Left Lower Leg' },
{ name: 'Left Toes', universalName: UNIVERSAL_BONES.LEFT_TOES, required: false, parent: 'Left Foot' },
{ name: 'Right Upper Leg', universalName: UNIVERSAL_BONES.RIGHT_UPPER_LEG, required: true, parent: 'Hips' },
{ name: 'Right Lower Leg', universalName: UNIVERSAL_BONES.RIGHT_LOWER_LEG, required: true, parent: 'Right Upper Leg' },
{ name: 'Right Foot', universalName: UNIVERSAL_BONES.RIGHT_FOOT, required: true, parent: 'Right Lower Leg' },
{ name: 'Right Toes', universalName: UNIVERSAL_BONES.RIGHT_TOES, required: false, parent: 'Right Foot' },
{ name: 'Left Eye', universalName: UNIVERSAL_BONES.LEFT_EYE, required: false, parent: 'Head' },
{ name: 'Right Eye', universalName: UNIVERSAL_BONES.RIGHT_EYE, required: false, parent: 'Head' },
{ name: 'Jaw', universalName: UNIVERSAL_BONES.JAW, required: false, parent: 'Head' },
],
};
// Roblox R15 skeleton specification
const robloxR15Skeleton: SkeletonSpec = {
type: 'r15',
rootBone: 'HumanoidRootPart',
blendShapeSupport: true,
maxBlendShapes: 50,
ikSupport: true,
fingerTracking: false,
eyeTracking: true,
fullBodyTracking: false,
bones: [
{ name: 'HumanoidRootPart', universalName: UNIVERSAL_BONES.ROOT, required: true },
{ name: 'LowerTorso', universalName: UNIVERSAL_BONES.HIPS, required: true, parent: 'HumanoidRootPart' },
{ name: 'UpperTorso', universalName: UNIVERSAL_BONES.SPINE, required: true, parent: 'LowerTorso' },
{ name: 'Head', universalName: UNIVERSAL_BONES.HEAD, required: true, parent: 'UpperTorso' },
{ name: 'LeftUpperArm', universalName: UNIVERSAL_BONES.LEFT_UPPER_ARM, required: true, parent: 'UpperTorso' },
{ name: 'LeftLowerArm', universalName: UNIVERSAL_BONES.LEFT_LOWER_ARM, required: true, parent: 'LeftUpperArm' },
{ name: 'LeftHand', universalName: UNIVERSAL_BONES.LEFT_HAND, required: true, parent: 'LeftLowerArm' },
{ name: 'RightUpperArm', universalName: UNIVERSAL_BONES.RIGHT_UPPER_ARM, required: true, parent: 'UpperTorso' },
{ name: 'RightLowerArm', universalName: UNIVERSAL_BONES.RIGHT_LOWER_ARM, required: true, parent: 'RightUpperArm' },
{ name: 'RightHand', universalName: UNIVERSAL_BONES.RIGHT_HAND, required: true, parent: 'RightLowerArm' },
{ name: 'LeftUpperLeg', universalName: UNIVERSAL_BONES.LEFT_UPPER_LEG, required: true, parent: 'LowerTorso' },
{ name: 'LeftLowerLeg', universalName: UNIVERSAL_BONES.LEFT_LOWER_LEG, required: true, parent: 'LeftUpperLeg' },
{ name: 'LeftFoot', universalName: UNIVERSAL_BONES.LEFT_FOOT, required: true, parent: 'LeftLowerLeg' },
{ name: 'RightUpperLeg', universalName: UNIVERSAL_BONES.RIGHT_UPPER_LEG, required: true, parent: 'LowerTorso' },
{ name: 'RightLowerLeg', universalName: UNIVERSAL_BONES.RIGHT_LOWER_LEG, required: true, parent: 'RightUpperLeg' },
{ name: 'RightFoot', universalName: UNIVERSAL_BONES.RIGHT_FOOT, required: true, parent: 'RightLowerLeg' },
],
};
// RecRoom skeleton specification
const recRoomSkeleton: SkeletonSpec = {
type: 'humanoid',
rootBone: 'Root',
blendShapeSupport: true,
maxBlendShapes: 30,
ikSupport: true,
fingerTracking: false,
eyeTracking: false,
fullBodyTracking: false,
bones: [
{ name: 'Root', universalName: UNIVERSAL_BONES.ROOT, required: true },
{ name: 'Hips', universalName: UNIVERSAL_BONES.HIPS, required: true, parent: 'Root' },
{ name: 'Spine', universalName: UNIVERSAL_BONES.SPINE, required: true, parent: 'Hips' },
{ name: 'Chest', universalName: UNIVERSAL_BONES.CHEST, required: true, parent: 'Spine' },
{ name: 'Neck', universalName: UNIVERSAL_BONES.NECK, required: true, parent: 'Chest' },
{ name: 'Head', universalName: UNIVERSAL_BONES.HEAD, required: true, parent: 'Neck' },
{ name: 'LeftArm', universalName: UNIVERSAL_BONES.LEFT_UPPER_ARM, required: true, parent: 'Chest' },
{ name: 'LeftForeArm', universalName: UNIVERSAL_BONES.LEFT_LOWER_ARM, required: true, parent: 'LeftArm' },
{ name: 'LeftHand', universalName: UNIVERSAL_BONES.LEFT_HAND, required: true, parent: 'LeftForeArm' },
{ name: 'RightArm', universalName: UNIVERSAL_BONES.RIGHT_UPPER_ARM, required: true, parent: 'Chest' },
{ name: 'RightForeArm', universalName: UNIVERSAL_BONES.RIGHT_LOWER_ARM, required: true, parent: 'RightArm' },
{ name: 'RightHand', universalName: UNIVERSAL_BONES.RIGHT_HAND, required: true, parent: 'RightForeArm' },
{ name: 'LeftLeg', universalName: UNIVERSAL_BONES.LEFT_UPPER_LEG, required: true, parent: 'Hips' },
{ name: 'LeftKnee', universalName: UNIVERSAL_BONES.LEFT_LOWER_LEG, required: true, parent: 'LeftLeg' },
{ name: 'LeftFoot', universalName: UNIVERSAL_BONES.LEFT_FOOT, required: true, parent: 'LeftKnee' },
{ name: 'RightLeg', universalName: UNIVERSAL_BONES.RIGHT_UPPER_LEG, required: true, parent: 'Hips' },
{ name: 'RightKnee', universalName: UNIVERSAL_BONES.RIGHT_LOWER_LEG, required: true, parent: 'RightLeg' },
{ name: 'RightFoot', universalName: UNIVERSAL_BONES.RIGHT_FOOT, required: true, parent: 'RightKnee' },
],
};
// Universal/Standard humanoid skeleton
const universalSkeleton: SkeletonSpec = {
type: 'humanoid',
rootBone: 'Armature',
blendShapeSupport: true,
maxBlendShapes: 256,
ikSupport: true,
fingerTracking: true,
eyeTracking: true,
fullBodyTracking: true,
bones: Object.entries(UNIVERSAL_BONES).map(([key, name]) => ({
name,
universalName: name,
required: ['HIPS', 'SPINE', 'HEAD', 'LEFT_UPPER_ARM', 'LEFT_LOWER_ARM', 'LEFT_HAND',
'RIGHT_UPPER_ARM', 'RIGHT_LOWER_ARM', 'RIGHT_HAND', 'LEFT_UPPER_LEG',
'LEFT_LOWER_LEG', 'LEFT_FOOT', 'RIGHT_UPPER_LEG', 'RIGHT_LOWER_LEG', 'RIGHT_FOOT'].includes(key),
})),
};
export const avatarPlatforms: Record<AvatarPlatformId, AvatarPlatform> = {
roblox: {
id: 'roblox',
name: 'Roblox',
displayName: 'Roblox Studio',
description: 'Import/export avatars for Roblox experiences with R6, R15, or Rthro support',
color: '#00A2FF',
icon: 'gamepad-2',
constraints: {
maxPolygons: 10000,
maxBones: 76,
maxMaterials: 1,
maxTextureSize: 1024,
maxFileSize: 30,
supportedFormats: ['fbx', 'obj'],
minHeight: 0.7,
maxHeight: 3.0,
},
skeleton: robloxR15Skeleton,
exportFormat: 'fbx',
importFormats: ['fbx', 'obj', 'glb', 'gltf', 'vrm'],
documentation: 'https://create.roblox.com/docs/art/characters',
status: 'supported',
features: ['R6', 'R15', 'Rthro', 'Layered Clothing', 'Dynamic Heads', 'Accessories'],
},
vrchat: {
id: 'vrchat',
name: 'VRChat',
displayName: 'VRChat SDK',
description: 'Create avatars for VRChat with full body tracking, PhysBones, and expressions',
color: '#1FB2A5',
icon: 'glasses',
constraints: {
maxPolygons: 70000, // Poor rating threshold
maxBones: 256,
maxMaterials: 32,
maxTextureSize: 2048,
maxFileSize: 200,
supportedFormats: ['fbx', 'vrm'],
requiresPhysBones: true,
},
skeleton: vrchatSkeleton,
exportFormat: 'unitypackage',
importFormats: ['fbx', 'glb', 'gltf', 'vrm', 'pmx'],
documentation: 'https://creators.vrchat.com/avatars/',
status: 'supported',
features: ['PhysBones', 'Avatar Dynamics', 'Eye Tracking', 'Face Tracking', 'OSC', 'Full Body'],
},
recroom: {
id: 'recroom',
name: 'RecRoom',
displayName: 'Rec Room',
description: 'Create fun, stylized avatars for Rec Room social experiences',
color: '#FF6B6B',
icon: 'party-popper',
constraints: {
maxPolygons: 15000,
maxBones: 52,
maxMaterials: 4,
maxTextureSize: 512,
maxFileSize: 20,
supportedFormats: ['fbx'],
},
skeleton: recRoomSkeleton,
exportFormat: 'fbx',
importFormats: ['fbx', 'glb', 'gltf', 'vrm'],
documentation: 'https://recroom.com/developer',
status: 'supported',
features: ['Stylized Look', 'Props', 'Costumes', 'Expressions'],
},
spatial: {
id: 'spatial',
name: 'Spatial',
displayName: 'Spatial Creator Toolkit',
description: 'Create avatars for Spatial VR/AR experiences and virtual spaces',
color: '#9B5DE5',
icon: 'globe',
constraints: {
maxPolygons: 50000,
maxBones: 128,
maxMaterials: 8,
maxTextureSize: 2048,
maxFileSize: 50,
supportedFormats: ['glb', 'gltf', 'vrm'],
},
skeleton: vrchatSkeleton,
exportFormat: 'glb',
importFormats: ['glb', 'gltf', 'vrm', 'fbx'],
documentation: 'https://toolkit.spatial.io/docs/avatars',
status: 'supported',
features: ['Ready Player Me', 'Custom Avatars', 'Emotes', 'Accessories'],
},
sandbox: {
id: 'sandbox',
name: 'Sandbox',
displayName: 'The Sandbox',
description: 'Create voxel-style or custom avatars for The Sandbox metaverse',
color: '#00D4FF',
icon: 'box',
constraints: {
maxPolygons: 20000,
maxBones: 64,
maxMaterials: 8,
maxTextureSize: 1024,
maxFileSize: 30,
supportedFormats: ['glb', 'gltf', 'vox'],
},
skeleton: universalSkeleton,
exportFormat: 'glb',
importFormats: ['glb', 'gltf', 'fbx', 'vrm', 'vox'],
documentation: 'https://sandboxgame.gitbook.io/the-sandbox',
status: 'supported',
features: ['Voxel Style', 'LAND Integration', 'NFT Support', 'Equipment'],
},
neos: {
id: 'neos',
name: 'NeosVR',
displayName: 'NeosVR',
description: 'Create highly customizable avatars for NeosVR',
color: '#F5A623',
icon: 'cpu',
constraints: {
maxPolygons: 100000,
maxBones: 256,
maxMaterials: 32,
maxTextureSize: 4096,
maxFileSize: 300,
supportedFormats: ['fbx', 'glb', 'gltf', 'vrm'],
},
skeleton: vrchatSkeleton,
exportFormat: 'glb',
importFormats: ['fbx', 'glb', 'gltf', 'vrm', 'obj'],
documentation: 'https://wiki.neos.com/',
status: 'beta',
features: ['LogiX', 'Dynamic Bones', 'Full Customization', 'In-World Editing'],
},
resonite: {
id: 'resonite',
name: 'Resonite',
displayName: 'Resonite',
description: 'Successor to NeosVR with enhanced avatar capabilities',
color: '#7B68EE',
icon: 'sparkles',
constraints: {
maxPolygons: 100000,
maxBones: 256,
maxMaterials: 32,
maxTextureSize: 4096,
maxFileSize: 300,
supportedFormats: ['fbx', 'glb', 'gltf', 'vrm'],
},
skeleton: vrchatSkeleton,
exportFormat: 'glb',
importFormats: ['fbx', 'glb', 'gltf', 'vrm', 'obj'],
documentation: 'https://wiki.resonite.com/',
status: 'beta',
features: ['ProtoFlux', 'Dynamic Bones', 'Face Tracking', 'Full Body'],
},
chilloutvr: {
id: 'chilloutvr',
name: 'ChilloutVR',
displayName: 'ChilloutVR',
description: 'Create avatars for ChilloutVR social platform',
color: '#E91E63',
icon: 'heart',
constraints: {
maxPolygons: 80000,
maxBones: 256,
maxMaterials: 24,
maxTextureSize: 2048,
maxFileSize: 150,
supportedFormats: ['fbx', 'vrm'],
},
skeleton: vrchatSkeleton,
exportFormat: 'unitypackage',
importFormats: ['fbx', 'glb', 'gltf', 'vrm'],
documentation: 'https://docs.abinteractive.net/',
status: 'beta',
features: ['Advanced Rigging', 'Toggles', 'Gestures', 'Eye/Face Tracking'],
},
decentraland: {
id: 'decentraland',
name: 'Decentraland',
displayName: 'Decentraland',
description: 'Create Web3-enabled avatars for the Decentraland metaverse',
color: '#FF2D55',
icon: 'landmark',
constraints: {
maxPolygons: 1500,
maxBones: 52,
maxMaterials: 2,
maxTextureSize: 512,
maxFileSize: 2,
supportedFormats: ['glb'],
},
skeleton: universalSkeleton,
exportFormat: 'glb',
importFormats: ['glb', 'gltf', 'fbx', 'vrm'],
documentation: 'https://docs.decentraland.org/creator/wearables/creating-wearables/',
status: 'supported',
features: ['Wearables', 'NFT Integration', 'Emotes', 'Blockchain'],
},
'meta-horizon': {
id: 'meta-horizon',
name: 'Meta Horizon',
displayName: 'Meta Horizon Worlds',
description: 'Create avatars for Meta Horizon Worlds VR platform',
color: '#0668E1',
icon: 'headphones',
constraints: {
maxPolygons: 25000,
maxBones: 70,
maxMaterials: 8,
maxTextureSize: 1024,
maxFileSize: 50,
supportedFormats: ['glb', 'fbx'],
},
skeleton: universalSkeleton,
exportFormat: 'glb',
importFormats: ['glb', 'gltf', 'fbx', 'vrm'],
documentation: 'https://developer.oculus.com/',
status: 'experimental',
features: ['Hand Tracking', 'Body Estimation', 'Expressions'],
},
universal: {
id: 'universal',
name: 'Universal',
displayName: 'AeThex Universal Format',
description: 'The AeThex universal avatar format compatible with all platforms',
color: '#00FF88',
icon: 'sparkles',
constraints: {
maxPolygons: 100000,
maxBones: 256,
maxMaterials: 32,
maxTextureSize: 4096,
maxFileSize: 500,
supportedFormats: ['aeth', 'glb', 'gltf', 'vrm', 'fbx'],
},
skeleton: universalSkeleton,
exportFormat: 'aeth',
importFormats: ['fbx', 'glb', 'gltf', 'vrm', 'obj', 'pmx', 'vroid'],
documentation: 'https://aethex.dev/docs/avatar-format',
status: 'supported',
features: ['All Platforms', 'Lossless Conversion', 'Metadata Preservation', 'Auto-Optimization'],
},
};
export const supportedPlatforms = Object.values(avatarPlatforms).filter(
(p) => p.status === 'supported' || p.status === 'beta'
);
export function getAvatarPlatform(id: AvatarPlatformId): AvatarPlatform {
return avatarPlatforms[id];
}
export function isPlatformSupported(id: AvatarPlatformId): boolean {
return avatarPlatforms[id].status === 'supported';
}
export function getConstraintsForPlatform(id: AvatarPlatformId): AvatarConstraints {
return avatarPlatforms[id].constraints;
}
export function getSkeletonForPlatform(id: AvatarPlatformId): SkeletonSpec {
return avatarPlatforms[id].skeleton;
}
export function canConvert(from: AvatarPlatformId, to: AvatarPlatformId): boolean {
const fromPlatform = avatarPlatforms[from];
const toPlatform = avatarPlatforms[to];
// Can always convert to universal
if (to === 'universal') return true;
// Can convert from universal to anything
if (from === 'universal') return true;
// Check if formats are compatible
const fromFormat = fromPlatform.exportFormat;
return toPlatform.importFormats.includes(fromFormat);
}

488
src/lib/avatar-rigging.ts Normal file
View file

@ -0,0 +1,488 @@
/**
* AeThex Avatar Rigging System
* Universal skeleton mapping and auto-rigging for cross-platform avatars
*/
import {
AvatarPlatformId,
BoneMapping,
SkeletonSpec,
UNIVERSAL_BONES,
avatarPlatforms,
getSkeletonForPlatform,
} from './avatar-platforms';
// Common bone name aliases across different software
export const BONE_ALIASES: Record<string, string[]> = {
[UNIVERSAL_BONES.HIPS]: ['Hips', 'hips', 'pelvis', 'Pelvis', 'LowerTorso', 'Root', 'Bip01_Pelvis', 'mixamorig:Hips'],
[UNIVERSAL_BONES.SPINE]: ['Spine', 'spine', 'Spine1', 'spine1', 'UpperTorso', 'Bip01_Spine', 'mixamorig:Spine'],
[UNIVERSAL_BONES.SPINE1]: ['Spine1', 'Spine2', 'spine2', 'Bip01_Spine1', 'mixamorig:Spine1'],
[UNIVERSAL_BONES.SPINE2]: ['Spine2', 'Spine3', 'spine3', 'Bip01_Spine2', 'mixamorig:Spine2'],
[UNIVERSAL_BONES.CHEST]: ['Chest', 'chest', 'Ribcage', 'UpperChest', 'Bip01_Spine3', 'mixamorig:Spine2'],
[UNIVERSAL_BONES.NECK]: ['Neck', 'neck', 'Neck1', 'Bip01_Neck', 'mixamorig:Neck'],
[UNIVERSAL_BONES.HEAD]: ['Head', 'head', 'Bip01_Head', 'mixamorig:Head'],
// Left arm
[UNIVERSAL_BONES.LEFT_SHOULDER]: ['LeftShoulder', 'Left Shoulder', 'L_Shoulder', 'shoulder.L', 'Bip01_L_Clavicle', 'mixamorig:LeftShoulder'],
[UNIVERSAL_BONES.LEFT_UPPER_ARM]: ['LeftUpperArm', 'Left Upper Arm', 'LeftArm', 'L_UpperArm', 'upperarm.L', 'Bip01_L_UpperArm', 'mixamorig:LeftArm'],
[UNIVERSAL_BONES.LEFT_LOWER_ARM]: ['LeftLowerArm', 'Left Lower Arm', 'LeftForeArm', 'L_Forearm', 'forearm.L', 'Bip01_L_Forearm', 'mixamorig:LeftForeArm'],
[UNIVERSAL_BONES.LEFT_HAND]: ['LeftHand', 'Left Hand', 'L_Hand', 'hand.L', 'Bip01_L_Hand', 'mixamorig:LeftHand'],
// Right arm
[UNIVERSAL_BONES.RIGHT_SHOULDER]: ['RightShoulder', 'Right Shoulder', 'R_Shoulder', 'shoulder.R', 'Bip01_R_Clavicle', 'mixamorig:RightShoulder'],
[UNIVERSAL_BONES.RIGHT_UPPER_ARM]: ['RightUpperArm', 'Right Upper Arm', 'RightArm', 'R_UpperArm', 'upperarm.R', 'Bip01_R_UpperArm', 'mixamorig:RightArm'],
[UNIVERSAL_BONES.RIGHT_LOWER_ARM]: ['RightLowerArm', 'Right Lower Arm', 'RightForeArm', 'R_Forearm', 'forearm.R', 'Bip01_R_Forearm', 'mixamorig:RightForeArm'],
[UNIVERSAL_BONES.RIGHT_HAND]: ['RightHand', 'Right Hand', 'R_Hand', 'hand.R', 'Bip01_R_Hand', 'mixamorig:RightHand'],
// Left leg
[UNIVERSAL_BONES.LEFT_UPPER_LEG]: ['LeftUpperLeg', 'Left Upper Leg', 'LeftThigh', 'L_Thigh', 'thigh.L', 'Bip01_L_Thigh', 'mixamorig:LeftUpLeg'],
[UNIVERSAL_BONES.LEFT_LOWER_LEG]: ['LeftLowerLeg', 'Left Lower Leg', 'LeftShin', 'LeftKnee', 'L_Calf', 'calf.L', 'Bip01_L_Calf', 'mixamorig:LeftLeg'],
[UNIVERSAL_BONES.LEFT_FOOT]: ['LeftFoot', 'Left Foot', 'L_Foot', 'foot.L', 'Bip01_L_Foot', 'mixamorig:LeftFoot'],
[UNIVERSAL_BONES.LEFT_TOES]: ['LeftToes', 'Left Toes', 'LeftToe', 'L_Toe', 'toe.L', 'Bip01_L_Toe0', 'mixamorig:LeftToeBase'],
// Right leg
[UNIVERSAL_BONES.RIGHT_UPPER_LEG]: ['RightUpperLeg', 'Right Upper Leg', 'RightThigh', 'R_Thigh', 'thigh.R', 'Bip01_R_Thigh', 'mixamorig:RightUpLeg'],
[UNIVERSAL_BONES.RIGHT_LOWER_LEG]: ['RightLowerLeg', 'Right Lower Leg', 'RightShin', 'RightKnee', 'R_Calf', 'calf.R', 'Bip01_R_Calf', 'mixamorig:RightLeg'],
[UNIVERSAL_BONES.RIGHT_FOOT]: ['RightFoot', 'Right Foot', 'R_Foot', 'foot.R', 'Bip01_R_Foot', 'mixamorig:RightFoot'],
[UNIVERSAL_BONES.RIGHT_TOES]: ['RightToes', 'Right Toes', 'RightToe', 'R_Toe', 'toe.R', 'Bip01_R_Toe0', 'mixamorig:RightToeBase'],
// Eyes and jaw
[UNIVERSAL_BONES.LEFT_EYE]: ['LeftEye', 'Left Eye', 'L_Eye', 'eye.L', 'EyeLeft'],
[UNIVERSAL_BONES.RIGHT_EYE]: ['RightEye', 'Right Eye', 'R_Eye', 'eye.R', 'EyeRight'],
[UNIVERSAL_BONES.JAW]: ['Jaw', 'jaw', 'Jaw_Joint', 'LowerJaw'],
};
export interface RigValidationResult {
isValid: boolean;
missingRequiredBones: string[];
missingOptionalBones: string[];
extraBones: string[];
boneMapping: Map<string, string>;
warnings: string[];
errors: string[];
score: number; // 0-100 compatibility score
}
export interface ConversionResult {
success: boolean;
warnings: string[];
errors: string[];
sourceBoneCount: number;
targetBoneCount: number;
mappedBones: number;
unmappedBones: string[];
addedBones: string[];
removedBones: string[];
}
export interface BoneTransform {
position: { x: number; y: number; z: number };
rotation: { x: number; y: number; z: number; w: number };
scale: { x: number; y: number; z: number };
}
export interface AvatarRig {
name: string;
platform: AvatarPlatformId;
bones: Map<string, BoneTransform>;
hierarchy: Map<string, string | null>;
blendShapes?: string[];
}
/**
* Find the universal bone name for a given bone name
*/
export function findUniversalBoneName(boneName: string): string | null {
// Direct match
if (Object.values(UNIVERSAL_BONES).includes(boneName as any)) {
return boneName;
}
// Search through aliases
for (const [universalName, aliases] of Object.entries(BONE_ALIASES)) {
if (aliases.some(alias =>
alias.toLowerCase() === boneName.toLowerCase() ||
boneName.toLowerCase().includes(alias.toLowerCase()) ||
alias.toLowerCase().includes(boneName.toLowerCase())
)) {
return universalName;
}
}
return null;
}
/**
* Get the platform-specific bone name from universal name
*/
export function getPlatformBoneName(universalName: string, platformId: AvatarPlatformId): string | null {
const skeleton = getSkeletonForPlatform(platformId);
const bone = skeleton.bones.find(b => b.universalName === universalName);
return bone?.name || null;
}
/**
* Create a bone mapping from source platform to target platform
*/
export function createBoneMapping(
sourcePlatform: AvatarPlatformId,
targetPlatform: AvatarPlatformId
): Map<string, string> {
const sourceSkeleton = getSkeletonForPlatform(sourcePlatform);
const targetSkeleton = getSkeletonForPlatform(targetPlatform);
const mapping = new Map<string, string>();
for (const sourceBone of sourceSkeleton.bones) {
const targetBone = targetSkeleton.bones.find(
b => b.universalName === sourceBone.universalName
);
if (targetBone) {
mapping.set(sourceBone.name, targetBone.name);
}
}
return mapping;
}
/**
* Validate a rig against a platform's requirements
*/
export function validateRig(
boneNames: string[],
targetPlatform: AvatarPlatformId
): RigValidationResult {
const skeleton = getSkeletonForPlatform(targetPlatform);
const platform = avatarPlatforms[targetPlatform];
const result: RigValidationResult = {
isValid: true,
missingRequiredBones: [],
missingOptionalBones: [],
extraBones: [],
boneMapping: new Map(),
warnings: [],
errors: [],
score: 100,
};
// Map input bones to universal names
const inputBoneSet = new Set(boneNames);
const mappedUniversalBones = new Set<string>();
for (const boneName of boneNames) {
const universalName = findUniversalBoneName(boneName);
if (universalName) {
const platformBoneName = getPlatformBoneName(universalName, targetPlatform);
if (platformBoneName) {
result.boneMapping.set(boneName, platformBoneName);
mappedUniversalBones.add(universalName);
}
} else {
result.extraBones.push(boneName);
}
}
// Check for missing required and optional bones
for (const bone of skeleton.bones) {
if (!mappedUniversalBones.has(bone.universalName)) {
if (bone.required) {
result.missingRequiredBones.push(bone.name);
result.errors.push(`Missing required bone: ${bone.name}`);
} else {
result.missingOptionalBones.push(bone.name);
result.warnings.push(`Missing optional bone: ${bone.name}`);
}
}
}
// Calculate score
const requiredBoneCount = skeleton.bones.filter(b => b.required).length;
const foundRequiredCount = requiredBoneCount - result.missingRequiredBones.length;
const baseScore = (foundRequiredCount / requiredBoneCount) * 80;
const optionalBoneCount = skeleton.bones.filter(b => !b.required).length;
const foundOptionalCount = optionalBoneCount > 0
? (optionalBoneCount - result.missingOptionalBones.length) / optionalBoneCount
: 1;
const bonusScore = foundOptionalCount * 20;
result.score = Math.round(baseScore + bonusScore);
// Check bone count limit
if (boneNames.length > platform.constraints.maxBones) {
result.warnings.push(
`Bone count (${boneNames.length}) exceeds platform limit (${platform.constraints.maxBones})`
);
result.score -= 10;
}
// Determine validity
result.isValid = result.missingRequiredBones.length === 0;
result.score = Math.max(0, Math.min(100, result.score));
return result;
}
/**
* Auto-map bones from an unknown rig to a target platform
*/
export function autoMapBones(
inputBones: string[],
targetPlatform: AvatarPlatformId
): Map<string, string> {
const skeleton = getSkeletonForPlatform(targetPlatform);
const mapping = new Map<string, string>();
for (const inputBone of inputBones) {
const universalName = findUniversalBoneName(inputBone);
if (universalName) {
const targetBone = skeleton.bones.find(b => b.universalName === universalName);
if (targetBone) {
mapping.set(inputBone, targetBone.name);
}
}
}
return mapping;
}
/**
* Suggest fixes for a rig that doesn't meet platform requirements
*/
export function suggestRigFixes(
validationResult: RigValidationResult,
targetPlatform: AvatarPlatformId
): string[] {
const suggestions: string[] = [];
const platform = avatarPlatforms[targetPlatform];
if (validationResult.missingRequiredBones.length > 0) {
suggestions.push(
`Add missing required bones: ${validationResult.missingRequiredBones.join(', ')}`
);
suggestions.push(
`Consider using the AeThex auto-rigger to automatically add missing bones`
);
}
if (validationResult.extraBones.length > 5) {
suggestions.push(
`Remove or rename ${validationResult.extraBones.length} unrecognized bones for better compatibility`
);
}
if (validationResult.score < 70) {
suggestions.push(
`Consider starting with an ${platform.name} compatible base rig`
);
}
return suggestions;
}
/**
* Convert a rig from one platform to another
*/
export function convertRig(
sourceBones: string[],
sourcePlatform: AvatarPlatformId,
targetPlatform: AvatarPlatformId
): ConversionResult {
const result: ConversionResult = {
success: false,
warnings: [],
errors: [],
sourceBoneCount: sourceBones.length,
targetBoneCount: 0,
mappedBones: 0,
unmappedBones: [],
addedBones: [],
removedBones: [],
};
const targetSkeleton = getSkeletonForPlatform(targetPlatform);
const boneMapping = createBoneMapping(sourcePlatform, targetPlatform);
// Track mapped and unmapped bones
const mappedSourceBones = new Set<string>();
for (const sourceBone of sourceBones) {
if (boneMapping.has(sourceBone)) {
mappedSourceBones.add(sourceBone);
result.mappedBones++;
} else {
// Try auto-mapping
const universalName = findUniversalBoneName(sourceBone);
if (universalName) {
const targetBone = getPlatformBoneName(universalName, targetPlatform);
if (targetBone) {
boneMapping.set(sourceBone, targetBone);
mappedSourceBones.add(sourceBone);
result.mappedBones++;
} else {
result.unmappedBones.push(sourceBone);
}
} else {
result.unmappedBones.push(sourceBone);
}
}
}
// Check for required bones that need to be added
for (const bone of targetSkeleton.bones) {
if (bone.required) {
const isMapped = Array.from(boneMapping.values()).includes(bone.name);
if (!isMapped) {
result.addedBones.push(bone.name);
result.warnings.push(`Will generate required bone: ${bone.name}`);
}
}
}
// Calculate removed bones (source bones not needed in target)
result.removedBones = sourceBones.filter(b => !mappedSourceBones.has(b));
result.targetBoneCount = result.mappedBones + result.addedBones.length;
// Determine success
const targetRequiredBones = targetSkeleton.bones.filter(b => b.required);
const missingRequired = targetRequiredBones.filter(b => {
const isMapped = Array.from(boneMapping.values()).includes(b.name);
const willBeAdded = result.addedBones.includes(b.name);
return !isMapped && !willBeAdded;
});
if (missingRequired.length > 0) {
result.errors.push(
`Cannot generate required bones: ${missingRequired.map(b => b.name).join(', ')}`
);
} else {
result.success = true;
}
if (result.unmappedBones.length > 0) {
result.warnings.push(
`${result.unmappedBones.length} bones will not be transferred`
);
}
return result;
}
/**
* Get the bone hierarchy for a platform
*/
export function getBoneHierarchy(platformId: AvatarPlatformId): Map<string, string | null> {
const skeleton = getSkeletonForPlatform(platformId);
const hierarchy = new Map<string, string | null>();
for (const bone of skeleton.bones) {
hierarchy.set(bone.name, bone.parent || null);
}
return hierarchy;
}
/**
* Generate T-pose bone transforms for a platform
*/
export function generateTPose(platformId: AvatarPlatformId): Map<string, BoneTransform> {
const skeleton = getSkeletonForPlatform(platformId);
const transforms = new Map<string, BoneTransform>();
// Default T-pose positions (simplified)
const defaultTransform: BoneTransform = {
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0, w: 1 },
scale: { x: 1, y: 1, z: 1 },
};
for (const bone of skeleton.bones) {
transforms.set(bone.name, { ...defaultTransform });
}
return transforms;
}
/**
* Calculate the compatibility between two platforms for avatar conversion
*/
export function calculatePlatformCompatibility(
sourcePlatform: AvatarPlatformId,
targetPlatform: AvatarPlatformId
): number {
const sourceSkeleton = getSkeletonForPlatform(sourcePlatform);
const targetSkeleton = getSkeletonForPlatform(targetPlatform);
// Count matching bones
let matches = 0;
for (const sourceBone of sourceSkeleton.bones) {
const hasMatch = targetSkeleton.bones.some(
b => b.universalName === sourceBone.universalName
);
if (hasMatch) matches++;
}
const totalBones = Math.max(sourceSkeleton.bones.length, targetSkeleton.bones.length);
const boneScore = (matches / totalBones) * 70;
// Feature compatibility
let featureScore = 0;
if (sourceSkeleton.fingerTracking === targetSkeleton.fingerTracking) featureScore += 10;
if (sourceSkeleton.eyeTracking === targetSkeleton.eyeTracking) featureScore += 10;
if (sourceSkeleton.blendShapeSupport === targetSkeleton.blendShapeSupport) featureScore += 10;
return Math.round(boneScore + featureScore);
}
/**
* Get all supported conversion paths from a source platform
*/
export function getConversionPaths(sourcePlatform: AvatarPlatformId): Array<{
target: AvatarPlatformId;
compatibility: number;
warnings: string[];
}> {
const paths: Array<{
target: AvatarPlatformId;
compatibility: number;
warnings: string[];
}> = [];
const platforms = Object.keys(avatarPlatforms) as AvatarPlatformId[];
for (const targetPlatform of platforms) {
if (targetPlatform === sourcePlatform) continue;
const compatibility = calculatePlatformCompatibility(sourcePlatform, targetPlatform);
const warnings: string[] = [];
const sourcePlatformData = avatarPlatforms[sourcePlatform];
const targetPlatformData = avatarPlatforms[targetPlatform];
// Add warnings for feature loss
if (sourcePlatformData.skeleton.fingerTracking && !targetPlatformData.skeleton.fingerTracking) {
warnings.push('Finger tracking will be lost');
}
if (sourcePlatformData.skeleton.eyeTracking && !targetPlatformData.skeleton.eyeTracking) {
warnings.push('Eye tracking will be lost');
}
if (sourcePlatformData.skeleton.fullBodyTracking && !targetPlatformData.skeleton.fullBodyTracking) {
warnings.push('Full body tracking will be lost');
}
if (sourcePlatformData.constraints.maxPolygons > targetPlatformData.constraints.maxPolygons) {
warnings.push(`Mesh may need reduction (${targetPlatformData.constraints.maxPolygons} max polys)`);
}
paths.push({
target: targetPlatform,
compatibility,
warnings,
});
}
// Sort by compatibility
paths.sort((a, b) => b.compatibility - a.compatibility);
return paths;
}

View file

@ -0,0 +1,139 @@
/**
* Real-time Collaboration Types
*/
export interface CollaboratorInfo {
id: string;
name: string;
color: string;
avatarUrl?: string;
cursor?: CursorPosition;
selection?: SelectionRange;
lastActive: number;
isTyping: boolean;
}
export interface CursorPosition {
lineNumber: number;
column: number;
}
export interface SelectionRange {
startLineNumber: number;
startColumn: number;
endLineNumber: number;
endColumn: number;
}
export interface CollaborationSession {
id: string;
name: string;
ownerId: string;
ownerName: string;
createdAt: number;
collaborators: CollaboratorInfo[];
fileId: string;
fileName: string;
isPublic: boolean;
maxCollaborators: number;
}
export interface CollaborationMessage {
type: CollaborationMessageType;
sessionId: string;
senderId: string;
timestamp: number;
payload: any;
}
export type CollaborationMessageType =
| 'join'
| 'leave'
| 'cursor_update'
| 'selection_update'
| 'code_change'
| 'chat'
| 'typing_start'
| 'typing_stop'
| 'file_change'
| 'request_sync'
| 'sync_response';
export interface ChatMessage {
id: string;
senderId: string;
senderName: string;
senderColor: string;
content: string;
timestamp: number;
}
export interface CodeChange {
range: SelectionRange;
text: string;
rangeLength: number;
}
// Collaboration colors for users
export const COLLABORATOR_COLORS = [
'#ef4444', // red
'#f97316', // orange
'#eab308', // yellow
'#22c55e', // green
'#14b8a6', // teal
'#3b82f6', // blue
'#8b5cf6', // violet
'#ec4899', // pink
'#6366f1', // indigo
'#06b6d4', // cyan
];
export function getCollaboratorColor(index: number): string {
return COLLABORATOR_COLORS[index % COLLABORATOR_COLORS.length];
}
// Session settings
export interface SessionSettings {
allowChat: boolean;
allowVoice: boolean;
showCursors: boolean;
showSelections: boolean;
autoSync: boolean;
followMode: boolean;
followUserId?: string;
}
export const DEFAULT_SESSION_SETTINGS: SessionSettings = {
allowChat: true,
allowVoice: false,
showCursors: true,
showSelections: true,
autoSync: true,
followMode: false,
};
// Connection states
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error';
// Permission levels
export type PermissionLevel = 'viewer' | 'editor' | 'admin' | 'owner';
export interface CollaboratorPermission {
collaboratorId: string;
level: PermissionLevel;
canEdit: boolean;
canInvite: boolean;
canChat: boolean;
}
export function canEdit(permission: PermissionLevel): boolean {
return ['editor', 'admin', 'owner'].includes(permission);
}
export function canInvite(permission: PermissionLevel): boolean {
return ['admin', 'owner'].includes(permission);
}
export function canManage(permission: PermissionLevel): boolean {
return permission === 'owner';
}

View file

@ -0,0 +1,425 @@
/**
* Lua Runtime using Fengari
* Executes Lua code with a mock Roblox API
*/
import { createRobloxAPI, Instance } from './roblox-api';
export interface LuaRuntimeOutput {
type: 'log' | 'warn' | 'error' | 'info';
message: string;
timestamp: number;
}
export interface LuaRuntimeState {
running: boolean;
outputs: LuaRuntimeOutput[];
error: string | null;
instances: Instance[];
}
export interface LuaRuntime {
execute: (code: string) => Promise<LuaRuntimeState>;
stop: () => void;
getState: () => LuaRuntimeState;
getInstances: () => Instance[];
onOutput: (callback: (output: LuaRuntimeOutput) => void) => void;
onInstanceUpdate: (callback: (instances: Instance[]) => void) => void;
}
/**
* Creates a new Lua runtime instance
* Uses JavaScript-based Lua transpilation for simplicity
*/
export function createLuaRuntime(): LuaRuntime {
let state: LuaRuntimeState = {
running: false,
outputs: [],
error: null,
instances: [],
};
let outputCallbacks: ((output: LuaRuntimeOutput) => void)[] = [];
let instanceCallbacks: ((instances: Instance[]) => void)[] = [];
let stopFlag = false;
let api = createRobloxAPI();
const addOutput = (type: LuaRuntimeOutput['type'], message: string) => {
const output: LuaRuntimeOutput = {
type,
message,
timestamp: Date.now(),
};
state.outputs.push(output);
outputCallbacks.forEach(cb => cb(output));
};
const notifyInstanceUpdate = () => {
instanceCallbacks.forEach(cb => cb(state.instances));
};
/**
* Simple Lua to JavaScript transpiler for basic Roblox scripts
* This handles common patterns but isn't a full Lua implementation
*/
const transpileLuaToJS = (luaCode: string): string => {
let js = luaCode;
// Remove comments
js = js.replace(/--\[\[[\s\S]*?\]\]/g, '');
js = js.replace(/--.*$/gm, '');
// Replace local keyword
js = js.replace(/\blocal\s+/g, 'let ');
// Replace function definitions
js = js.replace(/function\s+(\w+)\s*\((.*?)\)/g, 'async function $1($2)');
js = js.replace(/function\s*\((.*?)\)/g, 'async function($1)');
// Replace end keywords
js = js.replace(/\bend\b/g, '}');
// Replace then/do with {
js = js.replace(/\bthen\b/g, '{');
js = js.replace(/\bdo\b/g, '{');
// Replace elseif with else if
js = js.replace(/\belseif\b/g, '} else if');
// Replace else (but not else if)
js = js.replace(/\belse\b(?!\s*if)/g, '} else {');
// Replace repeat/until
js = js.replace(/\brepeat\b/g, 'do {');
js = js.replace(/\buntil\s+(.*?)$/gm, '} while (!($1));');
// Replace for loops - numeric
js = js.replace(
/for\s+(\w+)\s*=\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*(\d+))?\s*\{/g,
(_, var_, start, end, step) => {
const stepVal = step || '1';
return `for (let ${var_} = ${start}; ${var_} <= ${end}; ${var_} += ${stepVal}) {`;
}
);
// Replace for...in loops (pairs)
js = js.replace(
/for\s+(\w+)\s*,\s*(\w+)\s+in\s+pairs\s*\((.*?)\)\s*\{/g,
'for (let [$1, $2] of Object.entries($3)) {'
);
// Replace for...in loops (ipairs)
js = js.replace(
/for\s+(\w+)\s*,\s*(\w+)\s+in\s+ipairs\s*\((.*?)\)\s*\{/g,
'for (let [$1, $2] of $3.entries()) {'
);
// Replace while loops
js = js.replace(/while\s+(.*?)\s*\{/g, 'while ($1) {');
// Replace if statements
js = js.replace(/if\s+(.*?)\s*\{/g, 'if ($1) {');
// Replace logical operators
js = js.replace(/\band\b/g, '&&');
js = js.replace(/\bor\b/g, '||');
js = js.replace(/\bnot\b/g, '!');
// Replace nil with null
js = js.replace(/\bnil\b/g, 'null');
// Replace ~= with !==
js = js.replace(/~=/g, '!==');
// Replace string concatenation
js = js.replace(/\.\./g, '+');
// Replace # length operator
js = js.replace(/#(\w+)/g, '$1.length');
// Replace table constructors
js = js.replace(/\{([^{}]*?)\}/g, (match, content) => {
// Check if it looks like an array
if (!/\w+\s*=/.test(content)) {
return `[${content}]`;
}
// It's an object
const converted = content.replace(/\[["']?(\w+)["']?\]\s*=/g, '$1:').replace(/(\w+)\s*=/g, '$1:');
return `{${converted}}`;
});
// Replace print with our output function
js = js.replace(/\bprint\s*\(/g, '__print(');
// Replace warn
js = js.replace(/\bwarn\s*\(/g, '__warn(');
// Replace wait
js = js.replace(/\bwait\s*\(/g, 'await __wait(');
js = js.replace(/\btask\.wait\s*\(/g, 'await __wait(');
// Replace game:GetService
js = js.replace(/game:GetService\s*\(\s*["'](\w+)["']\s*\)/g, '__game.GetService("$1")');
js = js.replace(/game\.(\w+)/g, '__game.$1');
// Replace workspace
js = js.replace(/\bworkspace\b/g, '__workspace');
// Replace Instance.new
js = js.replace(/Instance\.new\s*\(\s*["'](\w+)["']\s*(?:,\s*(.*?))?\)/g, (_, className, parent) => {
if (parent) {
return `__Instance.new("${className}", ${parent})`;
}
return `__Instance.new("${className}")`;
});
// Replace Vector3.new
js = js.replace(/Vector3\.new\s*\((.*?)\)/g, '__Vector3.new($1)');
js = js.replace(/Vector3\.zero/g, '__Vector3.zero');
js = js.replace(/Vector3\.one/g, '__Vector3.one');
// Replace Color3
js = js.replace(/Color3\.new\s*\((.*?)\)/g, '__Color3.new($1)');
js = js.replace(/Color3\.fromRGB\s*\((.*?)\)/g, '__Color3.fromRGB($1)');
// Replace CFrame.new
js = js.replace(/CFrame\.new\s*\((.*?)\)/g, '__CFrame.new($1)');
// Replace TweenInfo.new
js = js.replace(/TweenInfo\.new\s*\((.*?)\)/g, '__TweenInfo.new($1)');
// Replace Enum references
js = js.replace(/Enum\.(\w+)\.(\w+)/g, '__Enum.$1.$2');
// Replace method calls with : to .
js = js.replace(/:(\w+)\s*\(/g, '.$1(');
// Replace self with this
js = js.replace(/\bself\b/g, 'this');
// Replace true/false (they're the same in JS)
// Replace math functions
js = js.replace(/math\.(\w+)/g, 'Math.$1');
// Replace string functions
js = js.replace(/string\.(\w+)/g, (_, fn) => {
const mapping: Record<string, string> = {
len: 'length',
sub: 'substring',
lower: 'toLowerCase',
upper: 'toUpperCase',
find: 'indexOf',
format: '__stringFormat',
};
return mapping[fn] || `String.prototype.${fn}`;
});
// Replace table functions
js = js.replace(/table\.insert\s*\((.*?),\s*(.*?)\)/g, '$1.push($2)');
js = js.replace(/table\.remove\s*\((.*?),\s*(.*?)\)/g, '$1.splice($2 - 1, 1)');
js = js.replace(/table\.concat\s*\((.*?)\)/g, '$1.join("")');
return js;
};
const execute = async (code: string): Promise<LuaRuntimeState> => {
// Reset state
state = {
running: true,
outputs: [],
error: null,
instances: [],
};
stopFlag = false;
api = createRobloxAPI();
addOutput('info', 'Starting script execution...');
try {
// Transpile Lua to JavaScript
const jsCode = transpileLuaToJS(code);
// Create execution context
const context = {
__print: (...args: any[]) => {
addOutput('log', args.map(a => String(a)).join(' '));
},
__warn: (...args: any[]) => {
addOutput('warn', args.map(a => String(a)).join(' '));
},
__wait: async (seconds: number = 0) => {
if (stopFlag) throw new Error('Script stopped');
await new Promise(resolve => setTimeout(resolve, (seconds || 0.03) * 1000));
return seconds;
},
__game: api.game,
__workspace: api.workspace,
__Instance: {
new: (className: string, parent?: Instance) => {
const instance = api.Instance.new(className, parent);
state.instances.push(instance);
notifyInstanceUpdate();
return instance;
},
},
__Vector3: api.Vector3,
__Color3: api.Color3,
__CFrame: api.CFrame,
__TweenInfo: api.TweenInfo,
__Enum: api.Enum,
__stringFormat: (format: string, ...args: any[]) => {
let result = format;
args.forEach((arg, i) => {
result = result.replace(/%[dsf]/, String(arg));
});
return result;
},
console: {
log: (...args: any[]) => addOutput('log', args.map(a => String(a)).join(' ')),
warn: (...args: any[]) => addOutput('warn', args.map(a => String(a)).join(' ')),
error: (...args: any[]) => addOutput('error', args.map(a => String(a)).join(' ')),
},
setTimeout,
setInterval,
clearTimeout,
clearInterval,
Math,
String,
Number,
Array,
Object,
JSON,
Date,
Promise,
};
// Wrap the code in an async IIFE
const wrappedCode = `
(async () => {
${jsCode}
})();
`;
// Create function with context
const contextKeys = Object.keys(context);
const contextValues = Object.values(context);
// Use Function constructor to create isolated execution
const fn = new Function(...contextKeys, wrappedCode);
// Execute
await fn(...contextValues);
// Start RunService if we have connections
api.runService.start();
addOutput('info', 'Script executed successfully');
} catch (error: any) {
state.error = error.message || 'Unknown error';
addOutput('error', `Error: ${state.error}`);
}
state.running = false;
return state;
};
const stop = () => {
stopFlag = true;
state.running = false;
api.runService.stop();
addOutput('info', 'Script stopped');
};
return {
execute,
stop,
getState: () => state,
getInstances: () => state.instances,
onOutput: (callback) => {
outputCallbacks.push(callback);
},
onInstanceUpdate: (callback) => {
instanceCallbacks.push(callback);
},
};
}
/**
* Parse Lua code to extract instance creations for preview
*/
export function parseScriptForInstances(code: string): Array<{
className: string;
name?: string;
properties: Record<string, any>;
}> {
const instances: Array<{
className: string;
name?: string;
properties: Record<string, any>;
}> = [];
// Match Instance.new calls
const instancePattern = /Instance\.new\s*\(\s*["'](\w+)["']/g;
let match;
while ((match = instancePattern.exec(code)) !== null) {
instances.push({
className: match[1],
properties: {},
});
}
return instances;
}
/**
* Validate Lua syntax (basic check)
*/
export function validateLuaSyntax(code: string): { valid: boolean; errors: string[] } {
const errors: string[] = [];
// Check for balanced keywords
const keywords = {
'function': 'end',
'if': 'end',
'for': 'end',
'while': 'end',
'repeat': 'until',
'do': 'end',
};
// Remove strings and comments for analysis
const cleaned = code
.replace(/--\[\[[\s\S]*?\]\]/g, '')
.replace(/--.*$/gm, '')
.replace(/"[^"]*"/g, '""')
.replace(/'[^']*'/g, "''");
// Count keyword pairs
for (const [start, end] of Object.entries(keywords)) {
const startRegex = new RegExp(`\\b${start}\\b`, 'g');
const endRegex = new RegExp(`\\b${end}\\b`, 'g');
const startCount = (cleaned.match(startRegex) || []).length;
const endCount = (cleaned.match(endRegex) || []).length;
if (startCount !== endCount) {
errors.push(`Unmatched '${start}' - found ${startCount} '${start}' but ${endCount} '${end}'`);
}
}
// Check for common syntax errors
if (/=\s*=\s*=/.test(cleaned)) {
errors.push("Invalid operator '===' - use '==' for equality");
}
if (/!\s*=/.test(cleaned)) {
errors.push("Invalid operator '!=' - use '~=' for not equal");
}
return {
valid: errors.length === 0,
errors,
};
}

View file

@ -0,0 +1,664 @@
/**
* Mock Roblox API for Live Preview
* Provides a simplified simulation of Roblox's API surface
*/
import { Vector3, Color, Euler, Quaternion } from 'three';
// Type definitions for the mock API
export interface Instance {
ClassName: string;
Name: string;
Parent: Instance | null;
children: Instance[];
properties: Record<string, any>;
// Methods
Destroy: () => void;
Clone: () => Instance;
FindFirstChild: (name: string) => Instance | null;
GetChildren: () => Instance[];
IsA: (className: string) => boolean;
WaitForChild: (name: string, timeout?: number) => Promise<Instance>;
}
export interface Vector3Value {
X: number;
Y: number;
Z: number;
add: (other: Vector3Value) => Vector3Value;
sub: (other: Vector3Value) => Vector3Value;
mul: (scalar: number) => Vector3Value;
div: (scalar: number) => Vector3Value;
Magnitude: number;
Unit: Vector3Value;
Dot: (other: Vector3Value) => number;
Cross: (other: Vector3Value) => Vector3Value;
}
export interface Color3Value {
R: number;
G: number;
B: number;
}
export interface CFrameValue {
Position: Vector3Value;
LookVector: Vector3Value;
RightVector: Vector3Value;
UpVector: Vector3Value;
Rotation: Vector3Value;
}
// Create Vector3 constructor
export function createVector3(x: number = 0, y: number = 0, z: number = 0): Vector3Value {
const vec: Vector3Value = {
X: x,
Y: y,
Z: z,
add: (other) => createVector3(x + other.X, y + other.Y, z + other.Z),
sub: (other) => createVector3(x - other.X, y - other.Y, z - other.Z),
mul: (scalar) => createVector3(x * scalar, y * scalar, z * scalar),
div: (scalar) => createVector3(x / scalar, y / scalar, z / scalar),
get Magnitude() {
return Math.sqrt(x * x + y * y + z * z);
},
get Unit() {
const mag = Math.sqrt(x * x + y * y + z * z);
return mag > 0 ? createVector3(x / mag, y / mag, z / mag) : createVector3();
},
Dot: (other) => x * other.X + y * other.Y + z * other.Z,
Cross: (other) => createVector3(
y * other.Z - z * other.Y,
z * other.X - x * other.Z,
x * other.Y - y * other.X
),
};
return vec;
}
// Create Color3 constructor
export function createColor3(r: number = 0, g: number = 0, b: number = 0): Color3Value {
return { R: r, G: g, B: b };
}
export function createColor3FromRGB(r: number, g: number, b: number): Color3Value {
return { R: r / 255, G: g / 255, B: b / 255 };
}
// Create CFrame constructor
export function createCFrame(x: number = 0, y: number = 0, z: number = 0): CFrameValue {
return {
Position: createVector3(x, y, z),
LookVector: createVector3(0, 0, -1),
RightVector: createVector3(1, 0, 0),
UpVector: createVector3(0, 1, 0),
Rotation: createVector3(0, 0, 0),
};
}
// Instance factory
let instanceIdCounter = 0;
export function createInstance(className: string, parent?: Instance | null): Instance {
const instance: Instance = {
ClassName: className,
Name: className + instanceIdCounter++,
Parent: parent || null,
children: [],
properties: getDefaultProperties(className),
Destroy() {
if (this.Parent) {
const idx = this.Parent.children.indexOf(this);
if (idx > -1) this.Parent.children.splice(idx, 1);
}
this.children.forEach(child => child.Destroy());
},
Clone() {
const clone = createInstance(this.ClassName);
clone.Name = this.Name;
clone.properties = { ...this.properties };
this.children.forEach(child => {
const childClone = child.Clone();
childClone.Parent = clone;
clone.children.push(childClone);
});
return clone;
},
FindFirstChild(name: string) {
return this.children.find(c => c.Name === name) || null;
},
GetChildren() {
return [...this.children];
},
IsA(className: string) {
return this.ClassName === className || getClassHierarchy(this.ClassName).includes(className);
},
async WaitForChild(name: string, timeout: number = 5) {
const existing = this.FindFirstChild(name);
if (existing) return existing;
return new Promise((resolve, reject) => {
const start = Date.now();
const check = () => {
const child = this.FindFirstChild(name);
if (child) {
resolve(child);
} else if (Date.now() - start > timeout * 1000) {
reject(new Error(`WaitForChild timeout: ${name}`));
} else {
setTimeout(check, 100);
}
};
check();
});
},
};
if (parent) {
parent.children.push(instance);
}
return instance;
}
// Default properties for common classes
function getDefaultProperties(className: string): Record<string, any> {
const defaults: Record<string, Record<string, any>> = {
Part: {
Position: createVector3(0, 0, 0),
Size: createVector3(4, 1, 2),
Color: createColor3(0.6, 0.6, 0.6),
BrickColor: 'Medium stone grey',
Transparency: 0,
Anchored: false,
CanCollide: true,
Material: 'Plastic',
Shape: 'Block',
},
SpawnLocation: {
Position: createVector3(0, 1, 0),
Size: createVector3(6, 1, 6),
Color: createColor3(0.05, 0.5, 0.05),
Anchored: true,
CanCollide: true,
},
Model: {
PrimaryPart: null,
},
Script: {
Source: '',
Enabled: true,
},
LocalScript: {
Source: '',
Enabled: true,
},
ModuleScript: {
Source: '',
},
PointLight: {
Brightness: 1,
Color: createColor3(1, 1, 1),
Range: 8,
Shadows: false,
},
SpotLight: {
Brightness: 1,
Color: createColor3(1, 1, 1),
Range: 16,
Angle: 90,
Shadows: true,
},
SurfaceGui: {
Face: 'Front',
Enabled: true,
},
TextLabel: {
Text: 'Label',
TextColor3: createColor3(0, 0, 0),
TextSize: 14,
BackgroundTransparency: 0,
BackgroundColor3: createColor3(1, 1, 1),
},
Sound: {
SoundId: '',
Volume: 0.5,
Playing: false,
Looped: false,
},
ParticleEmitter: {
Enabled: true,
Rate: 20,
Lifetime: { Min: 1, Max: 2 },
Speed: { Min: 5, Max: 5 },
Color: createColor3(1, 1, 1),
},
Humanoid: {
Health: 100,
MaxHealth: 100,
WalkSpeed: 16,
JumpPower: 50,
},
Camera: {
CFrame: createCFrame(0, 5, 10),
FieldOfView: 70,
CameraType: 'Custom',
},
};
return defaults[className] || {};
}
// Class hierarchy for IsA checks
function getClassHierarchy(className: string): string[] {
const hierarchies: Record<string, string[]> = {
Part: ['BasePart', 'PVInstance', 'Instance'],
SpawnLocation: ['Part', 'BasePart', 'PVInstance', 'Instance'],
Model: ['PVInstance', 'Instance'],
Script: ['BaseScript', 'LuaSourceContainer', 'Instance'],
LocalScript: ['BaseScript', 'LuaSourceContainer', 'Instance'],
ModuleScript: ['LuaSourceContainer', 'Instance'],
Humanoid: ['Instance'],
Sound: ['Instance'],
PointLight: ['Light', 'Instance'],
SpotLight: ['Light', 'Instance'],
Camera: ['Instance'],
};
return hierarchies[className] || ['Instance'];
}
// Services
export interface GameService {
Name: string;
instances: Record<string, Instance>;
}
export function createGameServices() {
const workspace = createInstance('Workspace');
workspace.Name = 'Workspace';
const replicatedStorage = createInstance('ReplicatedStorage');
replicatedStorage.Name = 'ReplicatedStorage';
const players = createInstance('Players');
players.Name = 'Players';
const lighting = createInstance('Lighting');
lighting.Name = 'Lighting';
lighting.properties = {
Ambient: createColor3(0.5, 0.5, 0.5),
Brightness: 2,
ClockTime: 14,
GeographicLatitude: 41.7,
GlobalShadows: true,
OutdoorAmbient: createColor3(0.5, 0.5, 0.5),
};
const serverStorage = createInstance('ServerStorage');
serverStorage.Name = 'ServerStorage';
const starterGui = createInstance('StarterGui');
starterGui.Name = 'StarterGui';
const starterPack = createInstance('StarterPack');
starterPack.Name = 'StarterPack';
const starterPlayer = createInstance('StarterPlayer');
starterPlayer.Name = 'StarterPlayer';
return {
Workspace: workspace,
ReplicatedStorage: replicatedStorage,
Players: players,
Lighting: lighting,
ServerStorage: serverStorage,
StarterGui: starterGui,
StarterPack: starterPack,
StarterPlayer: starterPlayer,
};
}
// Events system
export type EventCallback = (...args: any[]) => void;
export interface RBXScriptSignal {
Connect: (callback: EventCallback) => { Disconnect: () => void };
Wait: () => Promise<any[]>;
callbacks: EventCallback[];
fire: (...args: any[]) => void;
}
export function createSignal(): RBXScriptSignal {
const callbacks: EventCallback[] = [];
return {
callbacks,
Connect(callback: EventCallback) {
callbacks.push(callback);
return {
Disconnect: () => {
const idx = callbacks.indexOf(callback);
if (idx > -1) callbacks.splice(idx, 1);
},
};
},
Wait() {
return new Promise((resolve) => {
const handler = (...args: any[]) => {
const idx = callbacks.indexOf(handler);
if (idx > -1) callbacks.splice(idx, 1);
resolve(args);
};
callbacks.push(handler);
});
},
fire(...args: any[]) {
callbacks.forEach(cb => {
try {
cb(...args);
} catch (e) {
console.error('Event callback error:', e);
}
});
},
};
}
// TweenService mock
export interface TweenInfo {
Time: number;
EasingStyle: string;
EasingDirection: string;
RepeatCount: number;
Reverses: boolean;
DelayTime: number;
}
export function createTweenInfo(
time: number = 1,
easingStyle: string = 'Linear',
easingDirection: string = 'Out',
repeatCount: number = 0,
reverses: boolean = false,
delayTime: number = 0
): TweenInfo {
return { Time: time, EasingStyle: easingStyle, EasingDirection: easingDirection, RepeatCount: repeatCount, Reverses: reverses, DelayTime: delayTime };
}
export interface Tween {
Play: () => void;
Pause: () => void;
Cancel: () => void;
Completed: RBXScriptSignal;
}
export function createTweenService() {
return {
Create(instance: Instance, tweenInfo: TweenInfo, properties: Record<string, any>): Tween {
const completed = createSignal();
let animationId: number | null = null;
let isPaused = false;
return {
Completed: completed,
Play() {
if (isPaused) {
isPaused = false;
return;
}
const startValues: Record<string, any> = {};
const endValues = properties;
for (const key in endValues) {
startValues[key] = instance.properties[key];
}
const duration = tweenInfo.Time * 1000;
const startTime = Date.now();
const animate = () => {
if (isPaused) return;
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// Apply easing
const easedProgress = applyEasing(progress, tweenInfo.EasingStyle, tweenInfo.EasingDirection);
// Interpolate values
for (const key in endValues) {
const start = startValues[key];
const end = endValues[key];
if (typeof start === 'number' && typeof end === 'number') {
instance.properties[key] = start + (end - start) * easedProgress;
} else if (start && typeof start === 'object' && 'X' in start) {
// Vector3
instance.properties[key] = createVector3(
start.X + (end.X - start.X) * easedProgress,
start.Y + (end.Y - start.Y) * easedProgress,
start.Z + (end.Z - start.Z) * easedProgress
);
} else if (start && typeof start === 'object' && 'R' in start) {
// Color3
instance.properties[key] = createColor3(
start.R + (end.R - start.R) * easedProgress,
start.G + (end.G - start.G) * easedProgress,
start.B + (end.B - start.B) * easedProgress
);
}
}
if (progress < 1) {
animationId = requestAnimationFrame(animate);
} else {
completed.fire();
}
};
animate();
},
Pause() {
isPaused = true;
},
Cancel() {
if (animationId) {
cancelAnimationFrame(animationId);
}
},
};
},
};
}
function applyEasing(t: number, style: string, direction: string): number {
// Simplified easing functions
const easingFunctions: Record<string, (t: number) => number> = {
Linear: (t) => t,
Quad: (t) => t * t,
Cubic: (t) => t * t * t,
Sine: (t) => 1 - Math.cos((t * Math.PI) / 2),
Bounce: (t) => {
if (t < 1 / 2.75) return 7.5625 * t * t;
if (t < 2 / 2.75) return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75;
if (t < 2.5 / 2.75) return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375;
return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375;
},
Elastic: (t) => t === 0 ? 0 : t === 1 ? 1 : -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * (2 * Math.PI) / 3),
};
const ease = easingFunctions[style] || easingFunctions.Linear;
if (direction === 'In') {
return ease(t);
} else if (direction === 'Out') {
return 1 - ease(1 - t);
} else if (direction === 'InOut') {
return t < 0.5 ? ease(t * 2) / 2 : 1 - ease((1 - t) * 2) / 2;
}
return t;
}
// RunService mock
export function createRunService() {
const heartbeat = createSignal();
const renderStepped = createSignal();
const stepped = createSignal();
let lastTime = Date.now();
let isRunning = false;
const tick = () => {
if (!isRunning) return;
const now = Date.now();
const dt = (now - lastTime) / 1000;
lastTime = now;
stepped.fire(now / 1000, dt);
heartbeat.fire(dt);
renderStepped.fire(dt);
requestAnimationFrame(tick);
};
return {
Heartbeat: heartbeat,
RenderStepped: renderStepped,
Stepped: stepped,
start() {
isRunning = true;
lastTime = Date.now();
requestAnimationFrame(tick);
},
stop() {
isRunning = false;
},
};
}
// UserInputService mock
export function createUserInputService() {
const inputBegan = createSignal();
const inputEnded = createSignal();
const inputChanged = createSignal();
return {
InputBegan: inputBegan,
InputEnded: inputEnded,
InputChanged: inputChanged,
GetMouseLocation: () => ({ X: 0, Y: 0 }),
IsKeyDown: (keyCode: string) => false,
IsMouseButtonPressed: (button: number) => false,
};
}
// Export combined API
export function createRobloxAPI() {
const services = createGameServices();
const runService = createRunService();
const tweenService = createTweenService();
const userInputService = createUserInputService();
return {
game: {
GetService: (serviceName: string) => {
switch (serviceName) {
case 'Workspace': return services.Workspace;
case 'ReplicatedStorage': return services.ReplicatedStorage;
case 'Players': return services.Players;
case 'Lighting': return services.Lighting;
case 'ServerStorage': return services.ServerStorage;
case 'StarterGui': return services.StarterGui;
case 'StarterPack': return services.StarterPack;
case 'StarterPlayer': return services.StarterPlayer;
case 'TweenService': return tweenService;
case 'RunService': return runService;
case 'UserInputService': return userInputService;
default: throw new Error(`Unknown service: ${serviceName}`);
}
},
Workspace: services.Workspace,
},
workspace: services.Workspace,
Vector3: {
new: createVector3,
zero: createVector3(0, 0, 0),
one: createVector3(1, 1, 1),
xAxis: createVector3(1, 0, 0),
yAxis: createVector3(0, 1, 0),
zAxis: createVector3(0, 0, 1),
},
Color3: {
new: createColor3,
fromRGB: createColor3FromRGB,
},
CFrame: {
new: createCFrame,
},
TweenInfo: {
new: createTweenInfo,
},
Instance: {
new: createInstance,
},
Enum: {
Material: {
Plastic: 'Plastic',
Wood: 'Wood',
Metal: 'Metal',
Glass: 'Glass',
Neon: 'Neon',
Grass: 'Grass',
Sand: 'Sand',
Brick: 'Brick',
Concrete: 'Concrete',
Ice: 'Ice',
Marble: 'Marble',
Granite: 'Granite',
SmoothPlastic: 'SmoothPlastic',
ForceField: 'ForceField',
},
PartType: {
Block: 'Block',
Ball: 'Ball',
Cylinder: 'Cylinder',
Wedge: 'Wedge',
},
KeyCode: {
W: 'W', A: 'A', S: 'S', D: 'D',
E: 'E', F: 'F', G: 'G', Q: 'Q', R: 'R',
Space: 'Space', LeftShift: 'LeftShift', LeftControl: 'LeftControl',
One: 'One', Two: 'Two', Three: 'Three',
},
EasingStyle: {
Linear: 'Linear', Quad: 'Quad', Cubic: 'Cubic',
Sine: 'Sine', Bounce: 'Bounce', Elastic: 'Elastic',
},
EasingDirection: {
In: 'In', Out: 'Out', InOut: 'InOut',
},
},
print: (...args: any[]) => console.log('[Roblox]', ...args),
warn: (...args: any[]) => console.warn('[Roblox]', ...args),
error: (message: string) => { throw new Error(message); },
wait: (seconds: number = 0) => new Promise(resolve => setTimeout(resolve, seconds * 1000)),
task: {
wait: (seconds: number = 0) => new Promise(resolve => setTimeout(resolve, seconds * 1000)),
spawn: (fn: () => void) => setTimeout(fn, 0),
delay: (seconds: number, fn: () => void) => setTimeout(fn, seconds * 1000),
},
services,
runService,
};
}

220
src/lib/preview/types.ts Normal file
View file

@ -0,0 +1,220 @@
/**
* Live Preview Types
*/
export interface PreviewInstance {
id: string;
className: string;
name: string;
position: { x: number; y: number; z: number };
rotation: { x: number; y: number; z: number };
scale: { x: number; y: number; z: number };
color: { r: number; g: number; b: number };
transparency: number;
material: string;
shape: 'Block' | 'Ball' | 'Cylinder' | 'Wedge';
visible: boolean;
children: PreviewInstance[];
properties: Record<string, any>;
}
export interface PreviewLight {
id: string;
type: 'point' | 'spot' | 'directional' | 'ambient';
position: { x: number; y: number; z: number };
color: { r: number; g: number; b: number };
intensity: number;
range?: number;
angle?: number;
castShadow: boolean;
}
export interface PreviewCamera {
position: { x: number; y: number; z: number };
target: { x: number; y: number; z: number };
fov: number;
near: number;
far: number;
}
export interface PreviewScene {
instances: PreviewInstance[];
lights: PreviewLight[];
camera: PreviewCamera;
skybox: string | null;
ambientColor: { r: number; g: number; b: number };
ambientIntensity: number;
fogEnabled: boolean;
fogColor: { r: number; g: number; b: number };
fogNear: number;
fogFar: number;
}
export interface ConsoleOutput {
id: string;
type: 'log' | 'warn' | 'error' | 'info';
message: string;
timestamp: number;
source?: string;
line?: number;
}
export interface PreviewSettings {
showGrid: boolean;
showAxes: boolean;
showStats: boolean;
showWireframe: boolean;
shadowsEnabled: boolean;
antialias: boolean;
autoRotate: boolean;
backgroundColor: string;
}
export const DEFAULT_PREVIEW_SETTINGS: PreviewSettings = {
showGrid: true,
showAxes: true,
showStats: false,
showWireframe: false,
shadowsEnabled: true,
antialias: true,
autoRotate: false,
backgroundColor: '#1a1a2e',
};
export const DEFAULT_CAMERA: PreviewCamera = {
position: { x: 10, y: 8, z: 10 },
target: { x: 0, y: 0, z: 0 },
fov: 70,
near: 0.1,
far: 1000,
};
export function createDefaultScene(): PreviewScene {
return {
instances: [
// Default baseplate
{
id: 'baseplate',
className: 'Part',
name: 'Baseplate',
position: { x: 0, y: -0.5, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 100, y: 1, z: 100 },
color: { r: 0.3, g: 0.5, b: 0.3 },
transparency: 0,
material: 'Grass',
shape: 'Block',
visible: true,
children: [],
properties: {},
},
// Default spawn
{
id: 'spawnlocation',
className: 'SpawnLocation',
name: 'SpawnLocation',
position: { x: 0, y: 0.5, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 6, y: 1, z: 6 },
color: { r: 0.2, g: 0.6, b: 0.2 },
transparency: 0,
material: 'Plastic',
shape: 'Block',
visible: true,
children: [],
properties: {},
},
],
lights: [
{
id: 'sunlight',
type: 'directional',
position: { x: 50, y: 100, z: 50 },
color: { r: 1, g: 0.98, b: 0.9 },
intensity: 1.5,
castShadow: true,
},
{
id: 'ambient',
type: 'ambient',
position: { x: 0, y: 0, z: 0 },
color: { r: 0.4, g: 0.4, b: 0.5 },
intensity: 0.5,
castShadow: false,
},
],
camera: DEFAULT_CAMERA,
skybox: null,
ambientColor: { r: 0.5, g: 0.5, b: 0.6 },
ambientIntensity: 0.5,
fogEnabled: false,
fogColor: { r: 0.8, g: 0.85, b: 0.9 },
fogNear: 100,
fogFar: 500,
};
}
/**
* Convert Roblox API instance to preview instance
*/
export function convertToPreviewInstance(instance: any, id: string): PreviewInstance {
const props = instance.properties || {};
const position = props.Position || { X: 0, Y: 0, Z: 0 };
const size = props.Size || { X: 4, Y: 1, Z: 2 };
const color = props.Color || { R: 0.6, G: 0.6, B: 0.6 };
return {
id,
className: instance.ClassName,
name: instance.Name || instance.ClassName,
position: {
x: position.X || 0,
y: position.Y || 0,
z: position.Z || 0,
},
rotation: { x: 0, y: 0, z: 0 },
scale: {
x: size.X || 4,
y: size.Y || 1,
z: size.Z || 2,
},
color: {
r: color.R || 0.6,
g: color.G || 0.6,
b: color.B || 0.6,
},
transparency: props.Transparency || 0,
material: props.Material || 'Plastic',
shape: props.Shape || 'Block',
visible: true,
children: (instance.children || []).map((child: any, i: number) =>
convertToPreviewInstance(child, `${id}-${i}`)
),
properties: props,
};
}
/**
* Material to Three.js mapping
*/
export const MATERIAL_PROPERTIES: Record<string, {
roughness: number;
metalness: number;
emissive?: boolean;
}> = {
Plastic: { roughness: 0.5, metalness: 0.0 },
SmoothPlastic: { roughness: 0.2, metalness: 0.0 },
Wood: { roughness: 0.8, metalness: 0.0 },
Metal: { roughness: 0.3, metalness: 0.9 },
Glass: { roughness: 0.1, metalness: 0.0 },
Neon: { roughness: 0.0, metalness: 0.0, emissive: true },
Grass: { roughness: 0.9, metalness: 0.0 },
Sand: { roughness: 1.0, metalness: 0.0 },
Brick: { roughness: 0.9, metalness: 0.0 },
Concrete: { roughness: 0.95, metalness: 0.0 },
Ice: { roughness: 0.1, metalness: 0.0 },
Marble: { roughness: 0.2, metalness: 0.1 },
Granite: { roughness: 0.85, metalness: 0.05 },
ForceField: { roughness: 0.0, metalness: 0.0, emissive: true },
};

View file

@ -0,0 +1,493 @@
/**
* AeThex Avatar Templates
* Pre-configured avatar presets and styles for different platforms
*/
import { AvatarPlatformId } from './avatar-platforms';
export interface AvatarTemplate {
id: string;
name: string;
description: string;
thumbnail: string;
category: 'humanoid' | 'stylized' | 'anime' | 'robot' | 'creature' | 'custom';
style: 'realistic' | 'cartoon' | 'anime' | 'lowpoly' | 'voxel' | 'chibi';
platforms: AvatarPlatformId[];
features: string[];
polyCount: 'low' | 'medium' | 'high' | 'very-high';
rigged: boolean;
animated: boolean;
blendShapes: boolean;
tags: string[];
}
export interface AvatarPreset {
id: string;
name: string;
platform: AvatarPlatformId;
description: string;
settings: {
targetPolygons: number;
targetBones: number;
targetMaterials: number;
targetTextureSize: number;
preserveBlendShapes: boolean;
optimizeForVR: boolean;
generateLODs: boolean;
};
}
export const avatarTemplates: AvatarTemplate[] = [
// Universal Templates
{
id: 'universal-humanoid',
name: 'Universal Humanoid',
description: 'Standard humanoid avatar compatible with all platforms',
thumbnail: '/templates/universal-humanoid.png',
category: 'humanoid',
style: 'realistic',
platforms: ['universal', 'vrchat', 'roblox', 'recroom', 'spatial', 'sandbox'],
features: ['Full body', 'Finger bones', 'Face rig', 'Eye tracking ready'],
polyCount: 'medium',
rigged: true,
animated: true,
blendShapes: true,
tags: ['starter', 'universal', 'humanoid'],
},
{
id: 'universal-stylized',
name: 'Stylized Character',
description: 'Stylized human character with exaggerated proportions',
thumbnail: '/templates/universal-stylized.png',
category: 'stylized',
style: 'cartoon',
platforms: ['universal', 'roblox', 'recroom', 'sandbox'],
features: ['Cartoon proportions', 'Simple rig', 'Expressive face'],
polyCount: 'low',
rigged: true,
animated: true,
blendShapes: true,
tags: ['stylized', 'cartoon', 'beginner'],
},
{
id: 'universal-anime',
name: 'Anime Character',
description: 'Anime-style humanoid with VRM-ready setup',
thumbnail: '/templates/universal-anime.png',
category: 'anime',
style: 'anime',
platforms: ['universal', 'vrchat', 'spatial', 'neos', 'resonite', 'chilloutvr'],
features: ['Anime shading', 'MToon material', 'VRM expressions', 'Hair physics'],
polyCount: 'medium',
rigged: true,
animated: true,
blendShapes: true,
tags: ['anime', 'vrm', 'vtuber'],
},
// VRChat Specific
{
id: 'vrchat-quest-optimized',
name: 'Quest-Optimized Avatar',
description: 'Low-poly avatar optimized for Meta Quest standalone',
thumbnail: '/templates/vrchat-quest.png',
category: 'humanoid',
style: 'lowpoly',
platforms: ['vrchat'],
features: ['Quest compatible', 'Single material', 'Optimized bones', 'Mobile shaders'],
polyCount: 'low',
rigged: true,
animated: true,
blendShapes: true,
tags: ['vrchat', 'quest', 'mobile', 'optimized'],
},
{
id: 'vrchat-full-body',
name: 'Full Body Tracking Avatar',
description: 'Avatar with full body tracking support and PhysBones',
thumbnail: '/templates/vrchat-fbt.png',
category: 'humanoid',
style: 'realistic',
platforms: ['vrchat'],
features: ['FBT ready', 'PhysBones', 'Eye tracking', 'Face tracking', 'OSC support'],
polyCount: 'high',
rigged: true,
animated: true,
blendShapes: true,
tags: ['vrchat', 'fbt', 'fullbody', 'advanced'],
},
{
id: 'vrchat-furry',
name: 'Furry/Anthro Base',
description: 'Anthropomorphic character base with digitigrade legs',
thumbnail: '/templates/vrchat-furry.png',
category: 'creature',
style: 'cartoon',
platforms: ['vrchat', 'chilloutvr', 'neos', 'resonite'],
features: ['Digitigrade legs', 'Tail physics', 'Ear physics', 'Custom expressions'],
polyCount: 'medium',
rigged: true,
animated: true,
blendShapes: true,
tags: ['furry', 'anthro', 'creature'],
},
// Roblox Specific
{
id: 'roblox-r15',
name: 'Roblox R15 Character',
description: 'Standard Roblox R15 avatar with all body parts',
thumbnail: '/templates/roblox-r15.png',
category: 'humanoid',
style: 'cartoon',
platforms: ['roblox'],
features: ['R15 compatible', 'Layered clothing support', 'Dynamic head ready'],
polyCount: 'low',
rigged: true,
animated: true,
blendShapes: true,
tags: ['roblox', 'r15', 'standard'],
},
{
id: 'roblox-rthro',
name: 'Roblox Rthro Character',
description: 'Realistic proportioned Roblox avatar',
thumbnail: '/templates/roblox-rthro.png',
category: 'humanoid',
style: 'realistic',
platforms: ['roblox'],
features: ['Rthro proportions', 'Extended skeleton', 'Face animations'],
polyCount: 'medium',
rigged: true,
animated: true,
blendShapes: true,
tags: ['roblox', 'rthro', 'realistic'],
},
{
id: 'roblox-blocky',
name: 'Classic Blocky Avatar',
description: 'Classic blocky Roblox-style character',
thumbnail: '/templates/roblox-blocky.png',
category: 'stylized',
style: 'voxel',
platforms: ['roblox', 'sandbox'],
features: ['Classic look', 'Simple rig', 'Accessory slots'],
polyCount: 'low',
rigged: true,
animated: false,
blendShapes: false,
tags: ['roblox', 'classic', 'blocky', 'nostalgic'],
},
// RecRoom Specific
{
id: 'recroom-standard',
name: 'Rec Room Character',
description: 'Fun, stylized Rec Room avatar',
thumbnail: '/templates/recroom-standard.png',
category: 'stylized',
style: 'cartoon',
platforms: ['recroom'],
features: ['Rec Room style', 'Simple materials', 'Props ready'],
polyCount: 'low',
rigged: true,
animated: true,
blendShapes: true,
tags: ['recroom', 'stylized', 'fun'],
},
// Spatial Specific
{
id: 'spatial-professional',
name: 'Professional Avatar',
description: 'Business-ready avatar for Spatial meetings',
thumbnail: '/templates/spatial-professional.png',
category: 'humanoid',
style: 'realistic',
platforms: ['spatial', 'meta-horizon'],
features: ['Professional look', 'Business attire', 'Clean design'],
polyCount: 'medium',
rigged: true,
animated: true,
blendShapes: true,
tags: ['spatial', 'professional', 'business'],
},
// Sandbox/Metaverse
{
id: 'sandbox-voxel',
name: 'Sandbox Voxel Character',
description: 'Voxel-style character for The Sandbox',
thumbnail: '/templates/sandbox-voxel.png',
category: 'stylized',
style: 'voxel',
platforms: ['sandbox', 'decentraland'],
features: ['Voxel aesthetic', 'NFT ready', 'Equipment slots'],
polyCount: 'low',
rigged: true,
animated: true,
blendShapes: false,
tags: ['sandbox', 'voxel', 'nft', 'metaverse'],
},
{
id: 'decentraland-wearable',
name: 'Decentraland Avatar',
description: 'Web3-optimized avatar for Decentraland',
thumbnail: '/templates/decentraland.png',
category: 'humanoid',
style: 'lowpoly',
platforms: ['decentraland'],
features: ['Wearable slots', 'Ultra optimized', 'Blockchain ready'],
polyCount: 'low',
rigged: true,
animated: true,
blendShapes: false,
tags: ['decentraland', 'web3', 'nft', 'optimized'],
},
// Specialty
{
id: 'robot-mech',
name: 'Robot/Mech Avatar',
description: 'Mechanical humanoid robot character',
thumbnail: '/templates/robot-mech.png',
category: 'robot',
style: 'realistic',
platforms: ['universal', 'vrchat', 'roblox', 'spatial'],
features: ['Hard surface', 'Mechanical joints', 'LED effects', 'Transform ready'],
polyCount: 'high',
rigged: true,
animated: true,
blendShapes: false,
tags: ['robot', 'mech', 'scifi'],
},
{
id: 'chibi-cute',
name: 'Chibi Character',
description: 'Super-deformed cute chibi avatar',
thumbnail: '/templates/chibi.png',
category: 'anime',
style: 'chibi',
platforms: ['universal', 'vrchat', 'recroom', 'roblox'],
features: ['Chibi proportions', 'Big head', 'Cute expressions'],
polyCount: 'low',
rigged: true,
animated: true,
blendShapes: true,
tags: ['chibi', 'cute', 'anime', 'kawaii'],
},
];
export const platformPresets: AvatarPreset[] = [
// VRChat Presets
{
id: 'vrchat-excellent',
name: 'VRChat Excellent',
platform: 'vrchat',
description: 'Optimized for VRChat Excellent performance ranking',
settings: {
targetPolygons: 32000,
targetBones: 75,
targetMaterials: 4,
targetTextureSize: 1024,
preserveBlendShapes: true,
optimizeForVR: true,
generateLODs: true,
},
},
{
id: 'vrchat-good',
name: 'VRChat Good',
platform: 'vrchat',
description: 'Balanced quality for VRChat Good performance ranking',
settings: {
targetPolygons: 50000,
targetBones: 150,
targetMaterials: 8,
targetTextureSize: 2048,
preserveBlendShapes: true,
optimizeForVR: true,
generateLODs: true,
},
},
{
id: 'vrchat-quest',
name: 'VRChat Quest',
platform: 'vrchat',
description: 'Quest standalone compatible settings',
settings: {
targetPolygons: 10000,
targetBones: 75,
targetMaterials: 2,
targetTextureSize: 512,
preserveBlendShapes: true,
optimizeForVR: true,
generateLODs: false,
},
},
// Roblox Presets
{
id: 'roblox-ugc',
name: 'Roblox UGC',
platform: 'roblox',
description: 'Optimized for Roblox UGC marketplace',
settings: {
targetPolygons: 8000,
targetBones: 76,
targetMaterials: 1,
targetTextureSize: 1024,
preserveBlendShapes: true,
optimizeForVR: false,
generateLODs: false,
},
},
{
id: 'roblox-mobile',
name: 'Roblox Mobile',
platform: 'roblox',
description: 'Extra optimization for mobile devices',
settings: {
targetPolygons: 4000,
targetBones: 50,
targetMaterials: 1,
targetTextureSize: 512,
preserveBlendShapes: false,
optimizeForVR: false,
generateLODs: false,
},
},
// RecRoom Presets
{
id: 'recroom-standard',
name: 'RecRoom Standard',
platform: 'recroom',
description: 'Standard RecRoom avatar settings',
settings: {
targetPolygons: 10000,
targetBones: 52,
targetMaterials: 4,
targetTextureSize: 512,
preserveBlendShapes: true,
optimizeForVR: true,
generateLODs: false,
},
},
// Spatial Presets
{
id: 'spatial-quality',
name: 'Spatial Quality',
platform: 'spatial',
description: 'High quality Spatial avatar',
settings: {
targetPolygons: 40000,
targetBones: 100,
targetMaterials: 8,
targetTextureSize: 2048,
preserveBlendShapes: true,
optimizeForVR: true,
generateLODs: true,
},
},
// Sandbox Presets
{
id: 'sandbox-standard',
name: 'Sandbox Standard',
platform: 'sandbox',
description: 'Standard Sandbox metaverse avatar',
settings: {
targetPolygons: 15000,
targetBones: 60,
targetMaterials: 8,
targetTextureSize: 1024,
preserveBlendShapes: false,
optimizeForVR: false,
generateLODs: false,
},
},
// Decentraland Presets
{
id: 'decentraland-wearable',
name: 'Decentraland Wearable',
platform: 'decentraland',
description: 'Ultra-optimized for Decentraland wearables',
settings: {
targetPolygons: 1500,
targetBones: 52,
targetMaterials: 2,
targetTextureSize: 512,
preserveBlendShapes: false,
optimizeForVR: false,
generateLODs: false,
},
},
// Universal Presets
{
id: 'universal-balanced',
name: 'Universal Balanced',
platform: 'universal',
description: 'Balanced settings for cross-platform use',
settings: {
targetPolygons: 30000,
targetBones: 75,
targetMaterials: 4,
targetTextureSize: 1024,
preserveBlendShapes: true,
optimizeForVR: true,
generateLODs: true,
},
},
{
id: 'universal-maximum',
name: 'Universal Maximum',
platform: 'universal',
description: 'Maximum quality for archival purposes',
settings: {
targetPolygons: 100000,
targetBones: 256,
targetMaterials: 32,
targetTextureSize: 4096,
preserveBlendShapes: true,
optimizeForVR: false,
generateLODs: true,
},
},
];
export function getTemplatesForPlatform(platform: AvatarPlatformId): AvatarTemplate[] {
return avatarTemplates.filter(t => t.platforms.includes(platform));
}
export function getTemplatesByCategory(category: AvatarTemplate['category']): AvatarTemplate[] {
return avatarTemplates.filter(t => t.category === category);
}
export function getTemplatesByStyle(style: AvatarTemplate['style']): AvatarTemplate[] {
return avatarTemplates.filter(t => t.style === style);
}
export function getPresetsForPlatform(platform: AvatarPlatformId): AvatarPreset[] {
return platformPresets.filter(p => p.platform === platform);
}
export function getTemplateById(id: string): AvatarTemplate | undefined {
return avatarTemplates.find(t => t.id === id);
}
export function getPresetById(id: string): AvatarPreset | undefined {
return platformPresets.find(p => p.id === id);
}
export function searchTemplates(query: string): AvatarTemplate[] {
const lowerQuery = query.toLowerCase();
return avatarTemplates.filter(t =>
t.name.toLowerCase().includes(lowerQuery) ||
t.description.toLowerCase().includes(lowerQuery) ||
t.tags.some(tag => tag.toLowerCase().includes(lowerQuery))
);
}

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

404
src/stores/asset-store.ts Normal file
View file

@ -0,0 +1,404 @@
/**
* AeThex Asset Library - State Store
* Zustand store for managing game assets
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import {
Asset,
AssetFolder,
AssetFilter,
AssetSortOptions,
AssetType,
AssetCategory,
AssetUploadOptions,
getAssetTypeFromFile,
generateAssetId,
} from '../lib/assets/types';
interface AssetState {
// Asset data
assets: Asset[];
folders: AssetFolder[];
// UI state
selectedAssetId: string | null;
selectedFolderId: string | null;
viewMode: 'grid' | 'list';
filter: AssetFilter;
sort: AssetSortOptions;
// Actions
addAsset: (file: File, options?: AssetUploadOptions) => Promise<Asset>;
removeAsset: (id: string) => void;
updateAsset: (id: string, updates: Partial<Asset>) => void;
getAsset: (id: string) => Asset | undefined;
addFolder: (name: string, parentId?: string) => AssetFolder;
removeFolder: (id: string) => void;
updateFolder: (id: string, updates: Partial<AssetFolder>) => void;
setSelectedAsset: (id: string | null) => void;
setSelectedFolder: (id: string | null) => void;
setViewMode: (mode: 'grid' | 'list') => void;
setFilter: (filter: Partial<AssetFilter>) => void;
setSort: (sort: AssetSortOptions) => void;
clearFilter: () => void;
toggleFavorite: (id: string) => void;
addTag: (id: string, tag: string) => void;
removeTag: (id: string, tag: string) => void;
getFilteredAssets: () => Asset[];
getAssetsByFolder: (folderId?: string) => Asset[];
getAssetsByType: (type: AssetType) => Asset[];
getAssetsByCategory: (category: AssetCategory) => Asset[];
getFavorites: () => Asset[];
searchAssets: (query: string) => Asset[];
}
// Helper to create thumbnail from image
async function createImageThumbnail(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
const maxSize = 128;
let width = img.width;
let height = img.height;
if (width > height) {
if (width > maxSize) {
height *= maxSize / width;
width = maxSize;
}
} else {
if (height > maxSize) {
width *= maxSize / height;
height = maxSize;
}
}
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL('image/jpeg', 0.7));
};
img.onerror = reject;
img.src = e.target?.result as string;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// Helper to read file as data URL
async function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target?.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// Helper to get audio metadata
async function getAudioMetadata(file: File): Promise<{ duration: number }> {
return new Promise((resolve) => {
const audio = new Audio();
audio.onloadedmetadata = () => {
resolve({ duration: audio.duration });
};
audio.onerror = () => resolve({ duration: 0 });
audio.src = URL.createObjectURL(file);
});
}
// Helper to get image metadata
async function getImageMetadata(file: File): Promise<{ width: number; height: number }> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
resolve({ width: img.width, height: img.height });
URL.revokeObjectURL(img.src);
};
img.onerror = () => resolve({ width: 0, height: 0 });
img.src = URL.createObjectURL(file);
});
}
export const useAssetStore = create<AssetState>()(
persist(
(set, get) => ({
assets: [],
folders: [
{ id: 'default', name: 'My Assets', createdAt: new Date() },
],
selectedAssetId: null,
selectedFolderId: null,
viewMode: 'grid',
filter: {},
sort: { field: 'createdAt', direction: 'desc' },
addAsset: async (file: File, options?: AssetUploadOptions): Promise<Asset> => {
const assetType = getAssetTypeFromFile(file);
const id = generateAssetId();
// Get metadata based on type
let metadata: any = {
fileName: file.name,
fileSize: file.size,
mimeType: file.type,
extension: file.name.split('.').pop()?.toLowerCase() || '',
};
let thumbnailUrl: string | undefined;
let dataUrl: string | undefined;
// Process based on asset type
if (assetType === 'texture') {
const imgMeta = await getImageMetadata(file);
metadata = { ...metadata, ...imgMeta };
thumbnailUrl = await createImageThumbnail(file);
if (file.size < 5 * 1024 * 1024) { // Under 5MB, store as data URL
dataUrl = await readFileAsDataUrl(file);
}
} else if (assetType === 'audio') {
const audioMeta = await getAudioMetadata(file);
metadata = { ...metadata, ...audioMeta };
if (file.size < 10 * 1024 * 1024) { // Under 10MB
dataUrl = await readFileAsDataUrl(file);
}
} else if (file.size < 5 * 1024 * 1024) {
dataUrl = await readFileAsDataUrl(file);
}
const asset: Asset = {
id,
name: options?.name || file.name.replace(/\.[^.]+$/, ''),
type: assetType,
category: options?.category || 'uncategorized',
tags: options?.tags || [],
file,
dataUrl,
thumbnailUrl,
metadata,
folderId: options?.folderId || 'default',
favorite: false,
createdAt: new Date(),
updatedAt: new Date(),
platforms: ['universal'],
};
set((state) => ({
assets: [...state.assets, asset],
}));
return asset;
},
removeAsset: (id: string) => {
const asset = get().assets.find((a) => a.id === id);
if (asset?.blobUrl) {
URL.revokeObjectURL(asset.blobUrl);
}
set((state) => ({
assets: state.assets.filter((a) => a.id !== id),
selectedAssetId: state.selectedAssetId === id ? null : state.selectedAssetId,
}));
},
updateAsset: (id: string, updates: Partial<Asset>) => {
set((state) => ({
assets: state.assets.map((a) =>
a.id === id ? { ...a, ...updates, updatedAt: new Date() } : a
),
}));
},
getAsset: (id: string) => {
return get().assets.find((a) => a.id === id);
},
addFolder: (name: string, parentId?: string): AssetFolder => {
const folder: AssetFolder = {
id: `folder-${Date.now()}`,
name,
parentId,
createdAt: new Date(),
};
set((state) => ({
folders: [...state.folders, folder],
}));
return folder;
},
removeFolder: (id: string) => {
set((state) => ({
folders: state.folders.filter((f) => f.id !== id),
assets: state.assets.map((a) =>
a.folderId === id ? { ...a, folderId: 'default' } : a
),
selectedFolderId: state.selectedFolderId === id ? null : state.selectedFolderId,
}));
},
updateFolder: (id: string, updates: Partial<AssetFolder>) => {
set((state) => ({
folders: state.folders.map((f) =>
f.id === id ? { ...f, ...updates } : f
),
}));
},
setSelectedAsset: (id: string | null) => set({ selectedAssetId: id }),
setSelectedFolder: (id: string | null) => set({ selectedFolderId: id }),
setViewMode: (mode: 'grid' | 'list') => set({ viewMode: mode }),
setFilter: (filter: Partial<AssetFilter>) => {
set((state) => ({
filter: { ...state.filter, ...filter },
}));
},
setSort: (sort: AssetSortOptions) => set({ sort }),
clearFilter: () => set({ filter: {} }),
toggleFavorite: (id: string) => {
set((state) => ({
assets: state.assets.map((a) =>
a.id === id ? { ...a, favorite: !a.favorite } : a
),
}));
},
addTag: (id: string, tag: string) => {
set((state) => ({
assets: state.assets.map((a) =>
a.id === id && !a.tags.includes(tag)
? { ...a, tags: [...a.tags, tag] }
: a
),
}));
},
removeTag: (id: string, tag: string) => {
set((state) => ({
assets: state.assets.map((a) =>
a.id === id ? { ...a, tags: a.tags.filter((t) => t !== tag) } : a
),
}));
},
getFilteredAssets: () => {
const { assets, filter, sort } = get();
let filtered = [...assets];
// Apply filters
if (filter.type) {
const types = Array.isArray(filter.type) ? filter.type : [filter.type];
filtered = filtered.filter((a) => types.includes(a.type));
}
if (filter.category) {
const categories = Array.isArray(filter.category)
? filter.category
: [filter.category];
filtered = filtered.filter((a) => categories.includes(a.category));
}
if (filter.tags && filter.tags.length > 0) {
filtered = filtered.filter((a) =>
filter.tags!.some((tag) => a.tags.includes(tag))
);
}
if (filter.folderId) {
filtered = filtered.filter((a) => a.folderId === filter.folderId);
}
if (filter.favorite !== undefined) {
filtered = filtered.filter((a) => a.favorite === filter.favorite);
}
if (filter.search) {
const query = filter.search.toLowerCase();
filtered = filtered.filter(
(a) =>
a.name.toLowerCase().includes(query) ||
a.tags.some((t) => t.toLowerCase().includes(query)) ||
a.description?.toLowerCase().includes(query)
);
}
// Apply sorting
filtered.sort((a, b) => {
let comparison = 0;
switch (sort.field) {
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'createdAt':
comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
break;
case 'updatedAt':
comparison = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
break;
case 'fileSize':
comparison = a.metadata.fileSize - b.metadata.fileSize;
break;
case 'type':
comparison = a.type.localeCompare(b.type);
break;
}
return sort.direction === 'asc' ? comparison : -comparison;
});
return filtered;
},
getAssetsByFolder: (folderId?: string) => {
return get().assets.filter((a) => a.folderId === (folderId || 'default'));
},
getAssetsByType: (type: AssetType) => {
return get().assets.filter((a) => a.type === type);
},
getAssetsByCategory: (category: AssetCategory) => {
return get().assets.filter((a) => a.category === category);
},
getFavorites: () => {
return get().assets.filter((a) => a.favorite);
},
searchAssets: (query: string) => {
const lowerQuery = query.toLowerCase();
return get().assets.filter(
(a) =>
a.name.toLowerCase().includes(lowerQuery) ||
a.tags.some((t) => t.toLowerCase().includes(lowerQuery))
);
},
}),
{
name: 'aethex-assets',
partialize: (state) => ({
assets: state.assets.map((a) => ({
...a,
file: undefined, // Don't persist File objects
blobUrl: undefined, // Don't persist blob URLs
})),
folders: state.folders,
viewMode: state.viewMode,
sort: state.sort,
}),
}
)
);

View file

@ -0,0 +1,527 @@
/**
* Real-time Collaboration Store
* Manages collaboration state, sessions, and real-time updates
*/
import { create } from 'zustand';
import { io, Socket } from 'socket.io-client';
import {
CollaboratorInfo,
CollaborationSession,
ChatMessage,
CursorPosition,
SelectionRange,
CodeChange,
ConnectionState,
SessionSettings,
DEFAULT_SESSION_SETTINGS,
getCollaboratorColor,
PermissionLevel,
} from '@/lib/collaboration/types';
interface CollaborationState {
// Connection
socket: Socket | null;
connectionState: ConnectionState;
reconnectAttempts: number;
// Session
currentSession: CollaborationSession | null;
collaborators: CollaboratorInfo[];
myInfo: CollaboratorInfo | null;
myPermission: PermissionLevel;
// Chat
chatMessages: ChatMessage[];
unreadChatCount: number;
// Settings
settings: SessionSettings;
// UI State
isChatOpen: boolean;
isCollaboratorsPanelOpen: boolean;
// Actions
connect: (serverUrl?: string) => void;
disconnect: () => void;
createSession: (name: string, fileId: string, fileName: string, isPublic: boolean) => Promise<string>;
joinSession: (sessionId: string, name: string, avatarUrl?: string) => Promise<boolean>;
leaveSession: () => void;
// Real-time updates
updateCursor: (position: CursorPosition) => void;
updateSelection: (selection: SelectionRange | null) => void;
sendCodeChange: (change: CodeChange) => void;
setTyping: (isTyping: boolean) => void;
// Chat
sendChatMessage: (content: string) => void;
markChatAsRead: () => void;
// Settings
updateSettings: (settings: Partial<SessionSettings>) => void;
setFollowUser: (userId: string | null) => void;
// UI
toggleChat: () => void;
toggleCollaboratorsPanel: () => void;
// Permissions
updateCollaboratorPermission: (collaboratorId: string, permission: PermissionLevel) => void;
kickCollaborator: (collaboratorId: string) => void;
}
// Generate unique ID
const generateId = () => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
export const useCollaborationStore = create<CollaborationState>((set, get) => ({
// Initial state
socket: null,
connectionState: 'disconnected',
reconnectAttempts: 0,
currentSession: null,
collaborators: [],
myInfo: null,
myPermission: 'viewer',
chatMessages: [],
unreadChatCount: 0,
settings: DEFAULT_SESSION_SETTINGS,
isChatOpen: false,
isCollaboratorsPanelOpen: true,
// Connection
connect: (serverUrl = 'ws://localhost:3001') => {
const { socket, connectionState } = get();
if (socket || connectionState === 'connecting') return;
set({ connectionState: 'connecting' });
const newSocket = io(serverUrl, {
transports: ['websocket'],
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
});
newSocket.on('connect', () => {
set({
connectionState: 'connected',
reconnectAttempts: 0,
});
});
newSocket.on('disconnect', () => {
set({ connectionState: 'disconnected' });
});
newSocket.on('reconnect_attempt', (attempt) => {
set({
connectionState: 'reconnecting',
reconnectAttempts: attempt,
});
});
newSocket.on('connect_error', () => {
set({ connectionState: 'error' });
});
// Session events
newSocket.on('session_joined', (data: { session: CollaborationSession; collaborators: CollaboratorInfo[] }) => {
set({
currentSession: data.session,
collaborators: data.collaborators,
});
});
newSocket.on('collaborator_joined', (collaborator: CollaboratorInfo) => {
set((state) => ({
collaborators: [...state.collaborators, collaborator],
}));
});
newSocket.on('collaborator_left', (collaboratorId: string) => {
set((state) => ({
collaborators: state.collaborators.filter((c) => c.id !== collaboratorId),
}));
});
newSocket.on('cursor_updated', (data: { id: string; cursor: CursorPosition }) => {
set((state) => ({
collaborators: state.collaborators.map((c) =>
c.id === data.id ? { ...c, cursor: data.cursor, lastActive: Date.now() } : c
),
}));
});
newSocket.on('selection_updated', (data: { id: string; selection: SelectionRange | null }) => {
set((state) => ({
collaborators: state.collaborators.map((c) =>
c.id === data.id ? { ...c, selection: data.selection ?? undefined, lastActive: Date.now() } : c
),
}));
});
newSocket.on('typing_changed', (data: { id: string; isTyping: boolean }) => {
set((state) => ({
collaborators: state.collaborators.map((c) =>
c.id === data.id ? { ...c, isTyping: data.isTyping } : c
),
}));
});
newSocket.on('chat_message', (message: ChatMessage) => {
set((state) => ({
chatMessages: [...state.chatMessages, message],
unreadChatCount: state.isChatOpen ? 0 : state.unreadChatCount + 1,
}));
});
newSocket.on('code_changed', (change: CodeChange & { senderId: string }) => {
// This would be handled by the editor integration
// The editor should listen to this event and apply the change
});
set({ socket: newSocket });
},
disconnect: () => {
const { socket } = get();
if (socket) {
socket.disconnect();
}
set({
socket: null,
connectionState: 'disconnected',
currentSession: null,
collaborators: [],
myInfo: null,
chatMessages: [],
});
},
createSession: async (name: string, fileId: string, fileName: string, isPublic: boolean): Promise<string> => {
const { socket, myInfo } = get();
if (!socket || !myInfo) {
throw new Error('Not connected');
}
return new Promise((resolve, reject) => {
const sessionId = generateId();
socket.emit('create_session', {
sessionId,
name,
fileId,
fileName,
isPublic,
ownerId: myInfo.id,
ownerName: myInfo.name,
});
socket.once('session_created', (data: { sessionId: string }) => {
const session: CollaborationSession = {
id: data.sessionId,
name,
ownerId: myInfo.id,
ownerName: myInfo.name,
createdAt: Date.now(),
collaborators: [myInfo],
fileId,
fileName,
isPublic,
maxCollaborators: 10,
};
set({
currentSession: session,
collaborators: [myInfo],
myPermission: 'owner',
});
resolve(data.sessionId);
});
socket.once('session_error', (error: string) => {
reject(new Error(error));
});
// Timeout
setTimeout(() => reject(new Error('Session creation timed out')), 10000);
});
},
joinSession: async (sessionId: string, name: string, avatarUrl?: string): Promise<boolean> => {
const { socket } = get();
if (!socket) {
throw new Error('Not connected');
}
const myId = generateId();
const colorIndex = Math.floor(Math.random() * 10);
const myInfo: CollaboratorInfo = {
id: myId,
name,
color: getCollaboratorColor(colorIndex),
avatarUrl,
lastActive: Date.now(),
isTyping: false,
};
set({ myInfo });
return new Promise((resolve, reject) => {
socket.emit('join_session', {
sessionId,
collaborator: myInfo,
});
socket.once('session_joined', () => {
set({ myPermission: 'editor' });
resolve(true);
});
socket.once('session_error', (error: string) => {
reject(new Error(error));
});
setTimeout(() => reject(new Error('Join timed out')), 10000);
});
},
leaveSession: () => {
const { socket, currentSession, myInfo } = get();
if (socket && currentSession && myInfo) {
socket.emit('leave_session', {
sessionId: currentSession.id,
collaboratorId: myInfo.id,
});
}
set({
currentSession: null,
collaborators: [],
myPermission: 'viewer',
chatMessages: [],
});
},
updateCursor: (position: CursorPosition) => {
const { socket, currentSession, myInfo, settings } = get();
if (!socket || !currentSession || !myInfo || !settings.showCursors) return;
socket.emit('cursor_update', {
sessionId: currentSession.id,
collaboratorId: myInfo.id,
cursor: position,
});
},
updateSelection: (selection: SelectionRange | null) => {
const { socket, currentSession, myInfo, settings } = get();
if (!socket || !currentSession || !myInfo || !settings.showSelections) return;
socket.emit('selection_update', {
sessionId: currentSession.id,
collaboratorId: myInfo.id,
selection,
});
},
sendCodeChange: (change: CodeChange) => {
const { socket, currentSession, myInfo, myPermission } = get();
if (!socket || !currentSession || !myInfo) return;
if (!['editor', 'admin', 'owner'].includes(myPermission)) return;
socket.emit('code_change', {
sessionId: currentSession.id,
collaboratorId: myInfo.id,
change,
});
},
setTyping: (isTyping: boolean) => {
const { socket, currentSession, myInfo } = get();
if (!socket || !currentSession || !myInfo) return;
socket.emit('typing_change', {
sessionId: currentSession.id,
collaboratorId: myInfo.id,
isTyping,
});
set((state) => ({
myInfo: state.myInfo ? { ...state.myInfo, isTyping } : null,
}));
},
sendChatMessage: (content: string) => {
const { socket, currentSession, myInfo, settings } = get();
if (!socket || !currentSession || !myInfo || !settings.allowChat) return;
if (!content.trim()) return;
const message: ChatMessage = {
id: generateId(),
senderId: myInfo.id,
senderName: myInfo.name,
senderColor: myInfo.color,
content: content.trim(),
timestamp: Date.now(),
};
socket.emit('chat_message', {
sessionId: currentSession.id,
message,
});
// Add to local state immediately
set((state) => ({
chatMessages: [...state.chatMessages, message],
}));
},
markChatAsRead: () => {
set({ unreadChatCount: 0 });
},
updateSettings: (newSettings: Partial<SessionSettings>) => {
set((state) => ({
settings: { ...state.settings, ...newSettings },
}));
},
setFollowUser: (userId: string | null) => {
set((state) => ({
settings: {
...state.settings,
followMode: userId !== null,
followUserId: userId ?? undefined,
},
}));
},
toggleChat: () => {
set((state) => ({
isChatOpen: !state.isChatOpen,
unreadChatCount: !state.isChatOpen ? 0 : state.unreadChatCount,
}));
},
toggleCollaboratorsPanel: () => {
set((state) => ({
isCollaboratorsPanelOpen: !state.isCollaboratorsPanelOpen,
}));
},
updateCollaboratorPermission: (collaboratorId: string, permission: PermissionLevel) => {
const { socket, currentSession, myPermission } = get();
if (!socket || !currentSession) return;
if (myPermission !== 'owner' && myPermission !== 'admin') return;
socket.emit('update_permission', {
sessionId: currentSession.id,
collaboratorId,
permission,
});
},
kickCollaborator: (collaboratorId: string) => {
const { socket, currentSession, myPermission } = get();
if (!socket || !currentSession) return;
if (myPermission !== 'owner' && myPermission !== 'admin') return;
socket.emit('kick_collaborator', {
sessionId: currentSession.id,
collaboratorId,
});
},
}));
// Demo/mock mode for when no server is available
export function enableMockMode() {
const store = useCollaborationStore.getState();
// Create mock session
const mockMyInfo: CollaboratorInfo = {
id: 'mock-user-1',
name: 'You',
color: getCollaboratorColor(0),
lastActive: Date.now(),
isTyping: false,
};
const mockCollaborators: CollaboratorInfo[] = [
mockMyInfo,
{
id: 'mock-user-2',
name: 'Alice',
color: getCollaboratorColor(1),
cursor: { lineNumber: 5, column: 10 },
lastActive: Date.now(),
isTyping: false,
},
{
id: 'mock-user-3',
name: 'Bob',
color: getCollaboratorColor(2),
cursor: { lineNumber: 12, column: 25 },
lastActive: Date.now() - 30000,
isTyping: true,
},
];
const mockSession: CollaborationSession = {
id: 'mock-session',
name: 'Demo Collaboration',
ownerId: mockMyInfo.id,
ownerName: mockMyInfo.name,
createdAt: Date.now(),
collaborators: mockCollaborators,
fileId: 'file-1',
fileName: 'script.lua',
isPublic: true,
maxCollaborators: 10,
};
useCollaborationStore.setState({
connectionState: 'connected',
currentSession: mockSession,
collaborators: mockCollaborators,
myInfo: mockMyInfo,
myPermission: 'owner',
chatMessages: [
{
id: '1',
senderId: 'mock-user-2',
senderName: 'Alice',
senderColor: getCollaboratorColor(1),
content: 'Hey, I just pushed some changes to the combat system!',
timestamp: Date.now() - 120000,
},
{
id: '2',
senderId: 'mock-user-3',
senderName: 'Bob',
senderColor: getCollaboratorColor(2),
content: 'Nice! Let me take a look at the damage calculation.',
timestamp: Date.now() - 60000,
},
],
});
}

220
src/stores/preview-store.ts Normal file
View file

@ -0,0 +1,220 @@
/**
* Live Preview Store
* Manages preview state, console output, and runtime
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import {
PreviewScene,
PreviewInstance,
ConsoleOutput,
PreviewSettings,
DEFAULT_PREVIEW_SETTINGS,
createDefaultScene,
convertToPreviewInstance,
} from '@/lib/preview/types';
import { createLuaRuntime, LuaRuntime, LuaRuntimeOutput } from '@/lib/preview/lua-runtime';
interface PreviewState {
// Scene
scene: PreviewScene;
// Runtime
isRunning: boolean;
isPaused: boolean;
runtime: LuaRuntime | null;
// Console
consoleOutputs: ConsoleOutput[];
maxConsoleOutputs: number;
// Settings
settings: PreviewSettings;
// Code
currentCode: string;
// Actions
setScene: (scene: PreviewScene) => void;
addInstance: (instance: PreviewInstance) => void;
removeInstance: (id: string) => void;
updateInstance: (id: string, updates: Partial<PreviewInstance>) => void;
clearInstances: () => void;
// Runtime actions
runScript: (code: string) => Promise<void>;
stopScript: () => void;
pauseScript: () => void;
resumeScript: () => void;
resetScene: () => void;
// Console actions
addConsoleOutput: (output: Omit<ConsoleOutput, 'id'>) => void;
clearConsole: () => void;
// Settings actions
updateSettings: (settings: Partial<PreviewSettings>) => void;
}
let outputIdCounter = 0;
export const usePreviewStore = create<PreviewState>()(
persist(
(set, get) => ({
// Initial state
scene: createDefaultScene(),
isRunning: false,
isPaused: false,
runtime: null,
consoleOutputs: [],
maxConsoleOutputs: 1000,
settings: DEFAULT_PREVIEW_SETTINGS,
currentCode: '',
// Scene actions
setScene: (scene) => set({ scene }),
addInstance: (instance) =>
set((state) => ({
scene: {
...state.scene,
instances: [...state.scene.instances, instance],
},
})),
removeInstance: (id) =>
set((state) => ({
scene: {
...state.scene,
instances: state.scene.instances.filter((i) => i.id !== id),
},
})),
updateInstance: (id, updates) =>
set((state) => ({
scene: {
...state.scene,
instances: state.scene.instances.map((i) =>
i.id === id ? { ...i, ...updates } : i
),
},
})),
clearInstances: () =>
set((state) => ({
scene: {
...state.scene,
instances: state.scene.instances.filter(
(i) => i.id === 'baseplate' || i.id === 'spawnlocation'
),
},
})),
// Runtime actions
runScript: async (code: string) => {
const { stopScript, addConsoleOutput } = get();
// Stop any existing script
stopScript();
// Create new runtime
const runtime = createLuaRuntime();
// Set up output listener
runtime.onOutput((output: LuaRuntimeOutput) => {
addConsoleOutput({
type: output.type,
message: output.message,
timestamp: output.timestamp,
});
});
// Set up instance listener
runtime.onInstanceUpdate((instances) => {
set((state) => {
const baseInstances = state.scene.instances.filter(
(i) => i.id === 'baseplate' || i.id === 'spawnlocation'
);
const newInstances = instances.map((inst, idx) =>
convertToPreviewInstance(inst, `runtime-${idx}`)
);
return {
scene: {
...state.scene,
instances: [...baseInstances, ...newInstances],
},
};
});
});
set({ runtime, isRunning: true, isPaused: false, currentCode: code });
// Execute script
await runtime.execute(code);
set({ isRunning: false });
},
stopScript: () => {
const { runtime } = get();
if (runtime) {
runtime.stop();
}
set({ runtime: null, isRunning: false, isPaused: false });
},
pauseScript: () => {
set({ isPaused: true });
},
resumeScript: () => {
set({ isPaused: false });
},
resetScene: () => {
const { stopScript, clearConsole } = get();
stopScript();
clearConsole();
set({
scene: createDefaultScene(),
currentCode: '',
});
},
// Console actions
addConsoleOutput: (output) =>
set((state) => {
const newOutput: ConsoleOutput = {
...output,
id: `output-${outputIdCounter++}`,
};
let outputs = [...state.consoleOutputs, newOutput];
// Trim if over limit
if (outputs.length > state.maxConsoleOutputs) {
outputs = outputs.slice(-state.maxConsoleOutputs);
}
return { consoleOutputs: outputs };
}),
clearConsole: () => set({ consoleOutputs: [] }),
// Settings actions
updateSettings: (newSettings) =>
set((state) => ({
settings: { ...state.settings, ...newSettings },
})),
}),
{
name: 'aethex-preview',
partialize: (state) => ({
settings: state.settings,
}),
}
)
);

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