Compare commits
8 commits
main
...
claude/ava
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffd3140fc8 | ||
|
|
c08627a561 | ||
|
|
9c54fb3386 | ||
|
|
159e40f02c | ||
|
|
5feb186c05 | ||
|
|
6aff5ac183 | ||
|
|
4fa6d0c3ed | ||
|
|
96163c8256 |
35 changed files with 15469 additions and 141 deletions
503
ROADMAP.md
Normal file
503
ROADMAP.md
Normal 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
|
||||
464
docs/FEATURE_ARCHITECTURE.md
Normal file
464
docs/FEATURE_ARCHITECTURE.md
Normal 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
358
docs/VISUAL_GUIDE.md
Normal 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
1481
package-lock.json
generated
File diff suppressed because it is too large
Load diff
13
package.json
13
package.json
|
|
@ -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",
|
||||
|
|
|
|||
82
src/App.tsx
82
src/App.tsx
|
|
@ -10,6 +10,7 @@ import { FileSearchModal } from './components/FileSearchModal';
|
|||
import { SearchInFilesPanel } from './components/SearchInFilesPanel';
|
||||
import { CommandPalette, createDefaultCommands } from './components/CommandPalette';
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from './components/ui/resizable';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './components/ui/dialog';
|
||||
import { useIsMobile } from './hooks/use-mobile';
|
||||
import { useKeyboardShortcuts } from './hooks/use-keyboard-shortcuts';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './components/ui/tabs';
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
925
src/components/AvatarToolkit.tsx
Normal file
925
src/components/AvatarToolkit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
560
src/components/ai-generation/AIGenerationPanel.tsx
Normal file
560
src/components/ai-generation/AIGenerationPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/components/ai-generation/index.ts
Normal file
1
src/components/ai-generation/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as AIGenerationPanel } from './AIGenerationPanel';
|
||||
771
src/components/assets/AssetLibrary.tsx
Normal file
771
src/components/assets/AssetLibrary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
363
src/components/preview/LivePreview.tsx
Normal file
363
src/components/preview/LivePreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
263
src/components/preview/PreviewConsole.tsx
Normal file
263
src/components/preview/PreviewConsole.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
252
src/components/preview/PreviewViewport.tsx
Normal file
252
src/components/preview/PreviewViewport.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
src/components/preview/index.ts
Normal file
3
src/components/preview/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { default as LivePreview } from './LivePreview';
|
||||
export { default as PreviewViewport } from './PreviewViewport';
|
||||
export { default as PreviewConsole } from './PreviewConsole';
|
||||
447
src/components/visual-scripting/VisualScriptingCanvas.tsx
Normal file
447
src/components/visual-scripting/VisualScriptingCanvas.tsx
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useRef, useState, useMemo } from 'react';
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
Node,
|
||||
Edge,
|
||||
Connection,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
Panel,
|
||||
BackgroundVariant,
|
||||
} from 'reactflow';
|
||||
import 'reactflow/dist/style.css';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Play,
|
||||
Code,
|
||||
Trash2,
|
||||
Undo,
|
||||
Redo,
|
||||
Save,
|
||||
Download,
|
||||
Upload,
|
||||
Search,
|
||||
Zap,
|
||||
GitBranch,
|
||||
Box,
|
||||
Hash,
|
||||
Globe,
|
||||
Settings,
|
||||
Copy,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useVisualScriptStore } from '@/stores/visual-script-store';
|
||||
import {
|
||||
ALL_NODES,
|
||||
CATEGORY_COLORS,
|
||||
NodeCategory,
|
||||
NodeDefinition,
|
||||
getNodesByCategory,
|
||||
} from '@/lib/visual-scripting/node-definitions';
|
||||
import {
|
||||
generateCode,
|
||||
validateScript,
|
||||
Platform,
|
||||
NodeData,
|
||||
} from '@/lib/visual-scripting/code-generator';
|
||||
import { CustomNode } from './nodes/CustomNode';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Custom node types
|
||||
const nodeTypes = {
|
||||
custom: CustomNode,
|
||||
};
|
||||
|
||||
// Category icons
|
||||
const CATEGORY_ICONS: Record<NodeCategory, React.ReactNode> = {
|
||||
events: <Zap className="h-4 w-4" />,
|
||||
logic: <GitBranch className="h-4 w-4" />,
|
||||
actions: <Play className="h-4 w-4" />,
|
||||
data: <Hash className="h-4 w-4" />,
|
||||
references: <Globe className="h-4 w-4" />,
|
||||
custom: <Settings className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
interface VisualScriptingCanvasProps {
|
||||
platform: Platform;
|
||||
onCodeGenerated?: (code: string) => void;
|
||||
}
|
||||
|
||||
function VisualScriptingCanvasInner({
|
||||
platform,
|
||||
onCodeGenerated,
|
||||
}: VisualScriptingCanvasProps) {
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
onNodesChange,
|
||||
onEdgesChange,
|
||||
onConnect,
|
||||
addNode,
|
||||
clearScript,
|
||||
undo,
|
||||
redo,
|
||||
setGeneratedCode,
|
||||
generatedCode,
|
||||
history,
|
||||
historyIndex,
|
||||
} = useVisualScriptStore();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showCodePreview, setShowCodePreview] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<{
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
} | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Filter nodes by search and category
|
||||
const filteredNodes = useMemo(() => {
|
||||
if (!searchQuery) return ALL_NODES;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return ALL_NODES.filter(
|
||||
(n) =>
|
||||
n.label.toLowerCase().includes(query) ||
|
||||
n.description.toLowerCase().includes(query) ||
|
||||
n.category.toLowerCase().includes(query)
|
||||
);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Handle node drop from palette
|
||||
const onDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const nodeType = event.dataTransfer.getData('application/reactflow');
|
||||
if (!nodeType) return;
|
||||
|
||||
const nodeDef = ALL_NODES.find((n) => n.type === nodeType);
|
||||
if (!nodeDef) return;
|
||||
|
||||
const position = screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
const newNode: Node<NodeData> = {
|
||||
id: `${nodeType}-${Date.now()}`,
|
||||
type: 'custom',
|
||||
position,
|
||||
data: {
|
||||
type: nodeType,
|
||||
label: nodeDef.label,
|
||||
values: {},
|
||||
},
|
||||
};
|
||||
|
||||
addNode(newNode);
|
||||
},
|
||||
[screenToFlowPosition, addNode]
|
||||
);
|
||||
|
||||
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}, []);
|
||||
|
||||
// Generate code from nodes
|
||||
const handleGenerateCode = useCallback(() => {
|
||||
const result = generateCode(nodes, edges, platform);
|
||||
setGeneratedCode(result.code);
|
||||
onCodeGenerated?.(result.code);
|
||||
|
||||
const validation = validateScript(nodes, edges, platform);
|
||||
setValidationResult(validation);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
toast.error(`Generation errors: ${result.errors.join(', ')}`);
|
||||
} else if (result.warnings.length > 0) {
|
||||
toast.warning(`Warnings: ${result.warnings.join(', ')}`);
|
||||
} else {
|
||||
toast.success('Code generated successfully!');
|
||||
}
|
||||
|
||||
setShowCodePreview(true);
|
||||
}, [nodes, edges, platform, setGeneratedCode, onCodeGenerated]);
|
||||
|
||||
// Copy code to clipboard
|
||||
const handleCopyCode = useCallback(async () => {
|
||||
await navigator.clipboard.writeText(generatedCode);
|
||||
setCopied(true);
|
||||
toast.success('Code copied to clipboard!');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [generatedCode]);
|
||||
|
||||
// Drag start from palette
|
||||
const onDragStart = (event: React.DragEvent, nodeType: string) => {
|
||||
event.dataTransfer.setData('application/reactflow', nodeType);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const categories: NodeCategory[] = [
|
||||
'events',
|
||||
'logic',
|
||||
'actions',
|
||||
'data',
|
||||
'references',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
{/* Node Palette (Left Panel) */}
|
||||
<div className="w-64 border-r bg-card flex flex-col">
|
||||
<div className="p-3 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search nodes..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="events" className="flex-1 flex flex-col">
|
||||
<TabsList className="grid grid-cols-5 mx-2 mt-2">
|
||||
{categories.map((cat) => (
|
||||
<TabsTrigger
|
||||
key={cat}
|
||||
value={cat}
|
||||
className="px-2"
|
||||
title={cat.charAt(0).toUpperCase() + cat.slice(1)}
|
||||
>
|
||||
{CATEGORY_ICONS[cat]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
{categories.map((category) => (
|
||||
<TabsContent key={category} value={category} className="mt-0 p-2">
|
||||
<div className="space-y-1">
|
||||
{(searchQuery
|
||||
? filteredNodes.filter((n) => n.category === category)
|
||||
: getNodesByCategory(category)
|
||||
)
|
||||
.filter((n) => n.platforms.includes(platform))
|
||||
.map((nodeDef) => (
|
||||
<NodePaletteItem
|
||||
key={nodeDef.type}
|
||||
node={nodeDef}
|
||||
onDragStart={onDragStart}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Main Canvas */}
|
||||
<div ref={reactFlowWrapper} className="flex-1 relative">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
snapToGrid
|
||||
snapGrid={[15, 15]}
|
||||
defaultEdgeOptions={{
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
}}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={15} size={1} />
|
||||
<Controls />
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
const def = ALL_NODES.find((n) => n.type === node.data?.type);
|
||||
return def?.color || '#6b7280';
|
||||
}}
|
||||
className="!bg-card"
|
||||
/>
|
||||
|
||||
{/* Top Toolbar */}
|
||||
<Panel position="top-center" className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={undo}
|
||||
disabled={historyIndex <= 0}
|
||||
>
|
||||
<Undo className="h-4 w-4 mr-1" />
|
||||
Undo
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={redo}
|
||||
disabled={historyIndex >= history.length - 1}
|
||||
>
|
||||
<Redo className="h-4 w-4 mr-1" />
|
||||
Redo
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="h-8" />
|
||||
<Button variant="outline" size="sm" onClick={clearScript}>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="h-8" />
|
||||
<Button variant="default" size="sm" onClick={handleGenerateCode}>
|
||||
<Code className="h-4 w-4 mr-1" />
|
||||
Generate Code
|
||||
</Button>
|
||||
</Panel>
|
||||
|
||||
{/* Platform Badge */}
|
||||
<Panel position="top-right">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{platform.toUpperCase()}
|
||||
</Badge>
|
||||
</Panel>
|
||||
|
||||
{/* Stats */}
|
||||
<Panel position="bottom-left" className="text-xs text-muted-foreground">
|
||||
{nodes.length} nodes • {edges.length} connections
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* Code Preview Dialog */}
|
||||
<Dialog open={showCodePreview} onOpenChange={setShowCodePreview}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Code className="h-5 w-5" />
|
||||
Generated Code
|
||||
{validationResult && (
|
||||
<Badge
|
||||
variant={validationResult.valid ? 'default' : 'destructive'}
|
||||
>
|
||||
{validationResult.valid ? 'Valid' : 'Has Issues'}
|
||||
</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{validationResult && validationResult.errors.length > 0 && (
|
||||
<div className="bg-destructive/10 border border-destructive/20 rounded-md p-3 mb-4">
|
||||
<p className="font-medium text-destructive text-sm flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Errors
|
||||
</p>
|
||||
<ul className="text-sm text-destructive/80 mt-1 list-disc list-inside">
|
||||
{validationResult.errors.map((err, i) => (
|
||||
<li key={i}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validationResult && validationResult.warnings.length > 0 && (
|
||||
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-md p-3 mb-4">
|
||||
<p className="font-medium text-yellow-600 text-sm flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Warnings
|
||||
</p>
|
||||
<ul className="text-sm text-yellow-600/80 mt-1 list-disc list-inside">
|
||||
{validationResult.warnings.map((warn, i) => (
|
||||
<li key={i}>{warn}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScrollArea className="h-[400px] w-full rounded-md border bg-muted/50">
|
||||
<pre className="p-4 text-sm font-mono whitespace-pre-wrap">
|
||||
{generatedCode}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button variant="outline" onClick={handleCopyCode}>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{copied ? 'Copied!' : 'Copy Code'}
|
||||
</Button>
|
||||
<Button onClick={() => setShowCodePreview(false)}>Done</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Node Palette Item Component
|
||||
function NodePaletteItem({
|
||||
node,
|
||||
onDragStart,
|
||||
}: {
|
||||
node: NodeDefinition;
|
||||
onDragStart: (event: React.DragEvent, nodeType: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, node.type)}
|
||||
className="flex items-center gap-2 p-2 rounded-md border bg-background hover:bg-accent cursor-grab active:cursor-grabbing transition-colors"
|
||||
style={{ borderLeftColor: node.color, borderLeftWidth: 3 }}
|
||||
>
|
||||
<div
|
||||
className="w-6 h-6 rounded flex items-center justify-center text-white"
|
||||
style={{ backgroundColor: node.color }}
|
||||
>
|
||||
{CATEGORY_ICONS[node.category]}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{node.label}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{node.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Export with provider wrapper
|
||||
export default function VisualScriptingCanvas(
|
||||
props: VisualScriptingCanvasProps
|
||||
) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<VisualScriptingCanvasInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
326
src/components/visual-scripting/nodes/CustomNode.tsx
Normal file
326
src/components/visual-scripting/nodes/CustomNode.tsx
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
'use client';
|
||||
|
||||
import { memo, useState } from 'react';
|
||||
import { Handle, Position, NodeProps } from 'reactflow';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import {
|
||||
getNodeDefinition,
|
||||
CATEGORY_COLORS,
|
||||
PORT_COLORS,
|
||||
NodePort,
|
||||
} from '@/lib/visual-scripting/node-definitions';
|
||||
import { NodeData } from '@/lib/visual-scripting/code-generator';
|
||||
import { useVisualScriptStore } from '@/stores/visual-script-store';
|
||||
|
||||
export const CustomNode = memo(({ id, data, selected }: NodeProps<NodeData>) => {
|
||||
const definition = getNodeDefinition(data.type);
|
||||
const { updateNodeValue, removeNode } = useVisualScriptStore();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
if (!definition) {
|
||||
return (
|
||||
<div className="bg-destructive/20 border border-destructive rounded p-2">
|
||||
<span className="text-xs text-destructive">Unknown node: {data.type}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleValueChange = (key: string, value: any) => {
|
||||
updateNodeValue(id, key, value);
|
||||
};
|
||||
|
||||
// Filter out flow inputs for the input section
|
||||
const editableInputs = definition.inputs.filter(
|
||||
(input) => input.type !== 'flow'
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-card rounded-lg border-2 shadow-lg min-w-[180px] max-w-[280px] transition-all ${
|
||||
selected ? 'border-primary ring-2 ring-primary/20' : 'border-border'
|
||||
}`}
|
||||
style={{
|
||||
borderTopColor: definition.color,
|
||||
borderTopWidth: 3,
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="px-3 py-2 rounded-t-md flex items-center justify-between"
|
||||
style={{ backgroundColor: `${definition.color}15` }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-5 h-5 rounded flex items-center justify-center text-white text-xs"
|
||||
style={{ backgroundColor: definition.color }}
|
||||
>
|
||||
{definition.category.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="font-medium text-sm">{definition.label}</span>
|
||||
</div>
|
||||
{isHovered && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeNode(id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input Handles (Left Side) */}
|
||||
<div className="relative">
|
||||
{definition.inputs.map((input, index) => (
|
||||
<div key={input.id} className="relative">
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id={input.id}
|
||||
className="!w-3 !h-3 !border-2 !border-background"
|
||||
style={{
|
||||
backgroundColor: PORT_COLORS[input.type],
|
||||
top: `${
|
||||
(editableInputs.length > 0 ? 45 : 20) +
|
||||
index * 28 +
|
||||
(input.type === 'flow' ? 0 : editableInputs.indexOf(input) * 36)
|
||||
}px`,
|
||||
}}
|
||||
isConnectable={true}
|
||||
/>
|
||||
{input.type === 'flow' && (
|
||||
<div
|
||||
className="absolute left-4 text-xs text-muted-foreground"
|
||||
style={{
|
||||
top: `${20 + index * 28}px`,
|
||||
}}
|
||||
>
|
||||
{input.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Editable Inputs */}
|
||||
{editableInputs.length > 0 && (
|
||||
<div className="px-3 py-2 space-y-2 border-t border-border/50">
|
||||
{editableInputs.map((input) => (
|
||||
<NodeInput
|
||||
key={input.id}
|
||||
input={input}
|
||||
value={data.values?.[input.id] ?? input.defaultValue}
|
||||
onChange={(value) => handleValueChange(input.id, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output Handles (Right Side) */}
|
||||
<div className="relative min-h-[20px]">
|
||||
{definition.outputs.map((output, index) => (
|
||||
<div key={output.id} className="relative">
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id={output.id}
|
||||
className="!w-3 !h-3 !border-2 !border-background"
|
||||
style={{
|
||||
backgroundColor: PORT_COLORS[output.type],
|
||||
top: `${10 + index * 24}px`,
|
||||
}}
|
||||
isConnectable={true}
|
||||
/>
|
||||
<div
|
||||
className="absolute right-4 text-xs text-muted-foreground text-right"
|
||||
style={{
|
||||
top: `${6 + index * 24}px`,
|
||||
}}
|
||||
>
|
||||
{output.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Spacer for outputs */}
|
||||
<div
|
||||
style={{
|
||||
height: Math.max(definition.outputs.length * 24, 20),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
CustomNode.displayName = 'CustomNode';
|
||||
|
||||
// Input field component for node properties
|
||||
function NodeInput({
|
||||
input,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
input: NodePort;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
}) {
|
||||
switch (input.type) {
|
||||
case 'number':
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{input.name}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'string':
|
||||
if (input.id === 'operation') {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{input.name}</Label>
|
||||
<Select value={value || 'add'} onValueChange={onChange}>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="add">+ Add</SelectItem>
|
||||
<SelectItem value="subtract">- Subtract</SelectItem>
|
||||
<SelectItem value="multiply">× Multiply</SelectItem>
|
||||
<SelectItem value="divide">÷ Divide</SelectItem>
|
||||
<SelectItem value="modulo">% Modulo</SelectItem>
|
||||
<SelectItem value="power">^ Power</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (input.id === 'comparison') {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{input.name}</Label>
|
||||
<Select value={value || 'equals'} onValueChange={onChange}>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="equals">= Equals</SelectItem>
|
||||
<SelectItem value="notEquals">≠ Not Equals</SelectItem>
|
||||
<SelectItem value="greater">> Greater</SelectItem>
|
||||
<SelectItem value="less">< Less</SelectItem>
|
||||
<SelectItem value="greaterEqual">≥ Greater or Equal</SelectItem>
|
||||
<SelectItem value="lessEqual">≤ Less or Equal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (input.id === 'key') {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{input.name}</Label>
|
||||
<Select value={value || 'E'} onValueChange={onChange}>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['E', 'F', 'G', 'Q', 'R', 'Space', 'Shift', 'One', 'Two', 'Three'].map(
|
||||
(key) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{key}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (input.id === 'service') {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{input.name}</Label>
|
||||
<Select value={value || 'Players'} onValueChange={onChange}>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[
|
||||
'Players',
|
||||
'ReplicatedStorage',
|
||||
'ServerStorage',
|
||||
'Lighting',
|
||||
'TweenService',
|
||||
'UserInputService',
|
||||
'RunService',
|
||||
'SoundService',
|
||||
'DataStoreService',
|
||||
].map((svc) => (
|
||||
<SelectItem key={svc} value={svc}>
|
||||
{svc}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{input.name}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
placeholder={input.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">{input.name}</Label>
|
||||
<Switch
|
||||
checked={value ?? input.defaultValue ?? false}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{input.name}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
placeholder={`${input.name} (${input.type})`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
433
src/lib/ai-generation/generation-prompts.ts
Normal file
433
src/lib/ai-generation/generation-prompts.ts
Normal 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 || {};
|
||||
}
|
||||
1649
src/lib/ai-generation/system-templates.ts
Normal file
1649
src/lib/ai-generation/system-templates.ts
Normal file
File diff suppressed because it is too large
Load diff
222
src/lib/assets/types.ts
Normal file
222
src/lib/assets/types.ts
Normal 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
577
src/lib/avatar-formats.ts
Normal 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
548
src/lib/avatar-platforms.ts
Normal 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
488
src/lib/avatar-rigging.ts
Normal 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;
|
||||
}
|
||||
139
src/lib/collaboration/types.ts
Normal file
139
src/lib/collaboration/types.ts
Normal 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';
|
||||
}
|
||||
425
src/lib/preview/lua-runtime.ts
Normal file
425
src/lib/preview/lua-runtime.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
664
src/lib/preview/roblox-api.ts
Normal file
664
src/lib/preview/roblox-api.ts
Normal 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
220
src/lib/preview/types.ts
Normal 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 },
|
||||
};
|
||||
493
src/lib/templates-avatars.ts
Normal file
493
src/lib/templates-avatars.ts
Normal 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))
|
||||
);
|
||||
}
|
||||
553
src/lib/visual-scripting/code-generator.ts
Normal file
553
src/lib/visual-scripting/code-generator.ts
Normal file
|
|
@ -0,0 +1,553 @@
|
|||
/**
|
||||
* AeThex Visual Scripting - Code Generator
|
||||
* Converts visual script nodes into platform-specific code
|
||||
*/
|
||||
|
||||
import { Node, Edge } from 'reactflow';
|
||||
import {
|
||||
getNodeDefinition,
|
||||
NodeDefinition,
|
||||
PortType,
|
||||
ALL_NODES
|
||||
} from './node-definitions';
|
||||
|
||||
export type Platform = 'roblox' | 'uefn' | 'spatial';
|
||||
|
||||
export interface GenerationResult {
|
||||
success: boolean;
|
||||
code: string;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
nodeCount: number;
|
||||
connectionCount: number;
|
||||
}
|
||||
|
||||
export interface NodeData {
|
||||
type: string;
|
||||
label: string;
|
||||
values: Record<string, any>;
|
||||
}
|
||||
|
||||
// Track visited nodes to prevent infinite loops
|
||||
type VisitedSet = Set<string>;
|
||||
|
||||
/**
|
||||
* Generate code from visual script nodes
|
||||
*/
|
||||
export function generateCode(
|
||||
nodes: Node<NodeData>[],
|
||||
edges: Edge[],
|
||||
platform: Platform
|
||||
): GenerationResult {
|
||||
const result: GenerationResult = {
|
||||
success: true,
|
||||
code: '',
|
||||
errors: [],
|
||||
warnings: [],
|
||||
nodeCount: nodes.length,
|
||||
connectionCount: edges.length,
|
||||
};
|
||||
|
||||
if (nodes.length === 0) {
|
||||
result.code = getEmptyTemplate(platform);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Find all event nodes (entry points)
|
||||
const eventNodes = nodes.filter(n => {
|
||||
const def = getNodeDefinition(n.data.type);
|
||||
return def?.category === 'events';
|
||||
});
|
||||
|
||||
if (eventNodes.length === 0) {
|
||||
result.warnings.push('No event nodes found. Add an event node to start your script.');
|
||||
result.code = getEmptyTemplate(platform);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Build adjacency map for traversal
|
||||
const adjacencyMap = buildAdjacencyMap(edges);
|
||||
const reverseAdjacencyMap = buildReverseAdjacencyMap(edges);
|
||||
|
||||
// Generate code for each event node
|
||||
const codeBlocks: string[] = [];
|
||||
|
||||
for (const eventNode of eventNodes) {
|
||||
try {
|
||||
const visited: VisitedSet = new Set();
|
||||
const blockCode = generateNodeCode(
|
||||
eventNode,
|
||||
nodes,
|
||||
adjacencyMap,
|
||||
reverseAdjacencyMap,
|
||||
platform,
|
||||
visited,
|
||||
0
|
||||
);
|
||||
codeBlocks.push(blockCode);
|
||||
} catch (error: any) {
|
||||
result.errors.push(`Error in node "${eventNode.data.label}": ${error.message}`);
|
||||
result.success = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Combine code blocks
|
||||
result.code = wrapCode(codeBlocks.join('\n\n'), platform);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build adjacency map from edges (source -> targets)
|
||||
*/
|
||||
function buildAdjacencyMap(edges: Edge[]): Map<string, Edge[]> {
|
||||
const map = new Map<string, Edge[]>();
|
||||
|
||||
for (const edge of edges) {
|
||||
const key = `${edge.source}.${edge.sourceHandle}`;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, []);
|
||||
}
|
||||
map.get(key)!.push(edge);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build reverse adjacency map (target -> sources)
|
||||
*/
|
||||
function buildReverseAdjacencyMap(edges: Edge[]): Map<string, Edge[]> {
|
||||
const map = new Map<string, Edge[]>();
|
||||
|
||||
for (const edge of edges) {
|
||||
const key = `${edge.target}.${edge.targetHandle}`;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, []);
|
||||
}
|
||||
map.get(key)!.push(edge);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate code for a single node and its connected nodes
|
||||
*/
|
||||
function generateNodeCode(
|
||||
node: Node<NodeData>,
|
||||
allNodes: Node<NodeData>[],
|
||||
adjacencyMap: Map<string, Edge[]>,
|
||||
reverseMap: Map<string, Edge[]>,
|
||||
platform: Platform,
|
||||
visited: VisitedSet,
|
||||
depth: number
|
||||
): string {
|
||||
if (visited.has(node.id)) {
|
||||
return '-- [Circular reference detected]';
|
||||
}
|
||||
visited.add(node.id);
|
||||
|
||||
const definition = getNodeDefinition(node.data.type);
|
||||
if (!definition) {
|
||||
return `-- Unknown node type: ${node.data.type}`;
|
||||
}
|
||||
|
||||
const template = definition.codeTemplate[platform];
|
||||
if (!template) {
|
||||
return `-- Node "${definition.label}" not supported on ${platform}`;
|
||||
}
|
||||
|
||||
// Resolve input values
|
||||
const resolvedValues: Record<string, string> = {};
|
||||
|
||||
for (const input of definition.inputs) {
|
||||
// Check if there's a connection to this input
|
||||
const connectionKey = `${node.id}.${input.id}`;
|
||||
const incomingEdges = reverseMap.get(connectionKey) || [];
|
||||
|
||||
if (incomingEdges.length > 0 && input.type !== 'flow') {
|
||||
// Get value from connected node
|
||||
const sourceEdge = incomingEdges[0];
|
||||
const sourceNode = allNodes.find(n => n.id === sourceEdge.source);
|
||||
|
||||
if (sourceNode) {
|
||||
resolvedValues[input.id] = generateValueExpression(
|
||||
sourceNode,
|
||||
sourceEdge.sourceHandle || 'value',
|
||||
allNodes,
|
||||
reverseMap,
|
||||
platform,
|
||||
new Set(visited),
|
||||
depth + 1
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Use node's stored value or default
|
||||
const value = node.data.values?.[input.id] ?? input.defaultValue ?? getDefaultForType(input.type);
|
||||
resolvedValues[input.id] = formatValue(value, input.type, platform);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate body code (connected flow outputs)
|
||||
let code = template;
|
||||
|
||||
// Replace placeholders with values
|
||||
for (const [key, value] of Object.entries(resolvedValues)) {
|
||||
code = code.replace(new RegExp(`{{${key}}}`, 'g'), value);
|
||||
}
|
||||
|
||||
// Handle flow outputs (execution continues)
|
||||
for (const output of definition.outputs) {
|
||||
if (output.type === 'flow') {
|
||||
const connectionKey = `${node.id}.${output.id}`;
|
||||
const outgoingEdges = adjacencyMap.get(connectionKey) || [];
|
||||
|
||||
let bodyCode = '';
|
||||
for (const edge of outgoingEdges) {
|
||||
const targetNode = allNodes.find(n => n.id === edge.target);
|
||||
if (targetNode) {
|
||||
bodyCode += generateNodeCode(
|
||||
targetNode,
|
||||
allNodes,
|
||||
adjacencyMap,
|
||||
reverseMap,
|
||||
platform,
|
||||
visited,
|
||||
depth + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Replace body placeholder
|
||||
const bodyPlaceholder = getBodyPlaceholder(output.id);
|
||||
code = code.replace(new RegExp(bodyPlaceholder, 'g'), indent(bodyCode, depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any remaining placeholders
|
||||
code = code.replace(/{{[A-Z_]+_BODY}}/g, '');
|
||||
code = code.replace(/{{BODY}}/g, '');
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a value expression for a data node
|
||||
*/
|
||||
function generateValueExpression(
|
||||
node: Node<NodeData>,
|
||||
outputHandle: string,
|
||||
allNodes: Node<NodeData>[],
|
||||
reverseMap: Map<string, Edge[]>,
|
||||
platform: Platform,
|
||||
visited: VisitedSet,
|
||||
depth: number
|
||||
): string {
|
||||
if (visited.has(node.id)) {
|
||||
return 'nil';
|
||||
}
|
||||
visited.add(node.id);
|
||||
|
||||
const definition = getNodeDefinition(node.data.type);
|
||||
if (!definition) {
|
||||
return 'nil';
|
||||
}
|
||||
|
||||
const template = definition.codeTemplate[platform];
|
||||
if (!template) {
|
||||
return 'nil';
|
||||
}
|
||||
|
||||
// Resolve input values recursively
|
||||
let code = template;
|
||||
|
||||
for (const input of definition.inputs) {
|
||||
const connectionKey = `${node.id}.${input.id}`;
|
||||
const incomingEdges = reverseMap.get(connectionKey) || [];
|
||||
|
||||
let value: string;
|
||||
if (incomingEdges.length > 0 && input.type !== 'flow') {
|
||||
const sourceEdge = incomingEdges[0];
|
||||
const sourceNode = allNodes.find(n => n.id === sourceEdge.source);
|
||||
|
||||
if (sourceNode) {
|
||||
value = generateValueExpression(
|
||||
sourceNode,
|
||||
sourceEdge.sourceHandle || 'value',
|
||||
allNodes,
|
||||
reverseMap,
|
||||
platform,
|
||||
visited,
|
||||
depth + 1
|
||||
);
|
||||
} else {
|
||||
value = formatValue(input.defaultValue, input.type, platform);
|
||||
}
|
||||
} else {
|
||||
const nodeValue = node.data.values?.[input.id] ?? input.defaultValue ?? getDefaultForType(input.type);
|
||||
value = formatValue(nodeValue, input.type, platform);
|
||||
}
|
||||
|
||||
code = code.replace(new RegExp(`{{${input.id}}}`, 'g'), value);
|
||||
}
|
||||
|
||||
// Handle special operations
|
||||
if (node.data.type === 'math') {
|
||||
const op = node.data.values?.operation || 'add';
|
||||
const opSymbol = getOperationSymbol(op);
|
||||
code = code.replace('{{OPERATION}}', opSymbol);
|
||||
}
|
||||
|
||||
if (node.data.type === 'compare') {
|
||||
const comp = node.data.values?.comparison || 'equals';
|
||||
const compSymbol = getComparisonSymbol(comp, platform);
|
||||
code = code.replace('{{COMPARISON}}', compSymbol);
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value for the target platform
|
||||
*/
|
||||
function formatValue(value: any, type: PortType, platform: Platform): string {
|
||||
if (value === null || value === undefined) {
|
||||
return getDefaultForType(type);
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return `"${String(value).replace(/"/g, '\\"')}"`;
|
||||
case 'number':
|
||||
return String(Number(value) || 0);
|
||||
case 'boolean':
|
||||
if (platform === 'roblox') {
|
||||
return value ? 'true' : 'false';
|
||||
} else if (platform === 'uefn') {
|
||||
return value ? 'true' : 'false';
|
||||
}
|
||||
return value ? 'true' : 'false';
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default value for a port type
|
||||
*/
|
||||
function getDefaultForType(type: PortType): string {
|
||||
switch (type) {
|
||||
case 'number': return '0';
|
||||
case 'string': return '""';
|
||||
case 'boolean': return 'false';
|
||||
case 'object': return 'nil';
|
||||
case 'array': return '{}';
|
||||
default: return 'nil';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the body placeholder name for an output
|
||||
*/
|
||||
function getBodyPlaceholder(outputId: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
'flow': '{{BODY}}',
|
||||
'true': '{{TRUE_BODY}}',
|
||||
'false': '{{FALSE_BODY}}',
|
||||
'loop': '{{LOOP_BODY}}',
|
||||
'complete': '{{COMPLETE_BODY}}',
|
||||
};
|
||||
return mapping[outputId] || '{{BODY}}';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get math operation symbol
|
||||
*/
|
||||
function getOperationSymbol(operation: string): string {
|
||||
const symbols: Record<string, string> = {
|
||||
'add': '+',
|
||||
'subtract': '-',
|
||||
'multiply': '*',
|
||||
'divide': '/',
|
||||
'modulo': '%',
|
||||
'power': '^',
|
||||
};
|
||||
return symbols[operation] || '+';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comparison symbol for platform
|
||||
*/
|
||||
function getComparisonSymbol(comparison: string, platform: Platform): string {
|
||||
const symbols: Record<string, string> = {
|
||||
'equals': '==',
|
||||
'notEquals': platform === 'roblox' ? '~=' : '!=',
|
||||
'greater': '>',
|
||||
'less': '<',
|
||||
'greaterEqual': '>=',
|
||||
'lessEqual': '<=',
|
||||
};
|
||||
return symbols[comparison] || '==';
|
||||
}
|
||||
|
||||
/**
|
||||
* Indent code block
|
||||
*/
|
||||
function indent(code: string, level: number): string {
|
||||
const tab = '\t';
|
||||
const prefix = tab.repeat(level);
|
||||
return code
|
||||
.split('\n')
|
||||
.map(line => line.trim() ? prefix + line : line)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap generated code with platform boilerplate
|
||||
*/
|
||||
function wrapCode(code: string, platform: Platform): string {
|
||||
switch (platform) {
|
||||
case 'roblox':
|
||||
return `-- Generated by AeThex Visual Scripting
|
||||
-- Platform: Roblox (Lua)
|
||||
|
||||
${code}`;
|
||||
|
||||
case 'uefn':
|
||||
return `# Generated by AeThex Visual Scripting
|
||||
# Platform: UEFN (Verse)
|
||||
|
||||
using { /Fortnite.com/Devices }
|
||||
using { /Verse.org/Simulation }
|
||||
using { /UnrealEngine.com/Temporary/Diagnostics }
|
||||
|
||||
aethex_generated_device := class(creative_device):
|
||||
|
||||
${indent(code, 1)}`;
|
||||
|
||||
case 'spatial':
|
||||
return `// Generated by AeThex Visual Scripting
|
||||
// Platform: Spatial (TypeScript)
|
||||
|
||||
import { SpaceService, World } from '@spatial/core';
|
||||
|
||||
${code}`;
|
||||
|
||||
default:
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get empty template for a platform
|
||||
*/
|
||||
function getEmptyTemplate(platform: Platform): string {
|
||||
switch (platform) {
|
||||
case 'roblox':
|
||||
return `-- Generated by AeThex Visual Scripting
|
||||
-- Add event nodes to start building your script!
|
||||
|
||||
-- Example:
|
||||
-- 1. Drag an "On Player Join" event node
|
||||
-- 2. Connect it to an action like "Print"
|
||||
-- 3. Click "Generate Code"
|
||||
|
||||
print("Hello from AeThex!")`;
|
||||
|
||||
case 'uefn':
|
||||
return `# Generated by AeThex Visual Scripting
|
||||
# Add event nodes to start building your script!
|
||||
|
||||
using { /Fortnite.com/Devices }
|
||||
using { /Verse.org/Simulation }
|
||||
|
||||
aethex_generated_device := class(creative_device):
|
||||
OnBegin<override>(): void =
|
||||
Print("Hello from AeThex!")`;
|
||||
|
||||
case 'spatial':
|
||||
return `// Generated by AeThex Visual Scripting
|
||||
// Add event nodes to start building your script!
|
||||
|
||||
import { SpaceService } from '@spatial/core';
|
||||
|
||||
SpaceService.onSpaceReady.on(() => {
|
||||
console.log("Hello from AeThex!");
|
||||
});`;
|
||||
|
||||
default:
|
||||
return '// No code generated';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate visual script for errors
|
||||
*/
|
||||
export function validateScript(
|
||||
nodes: Node<NodeData>[],
|
||||
edges: Edge[],
|
||||
platform: Platform
|
||||
): { valid: boolean; errors: string[]; warnings: string[] } {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check for event nodes
|
||||
const eventNodes = nodes.filter(n => {
|
||||
const def = getNodeDefinition(n.data.type);
|
||||
return def?.category === 'events';
|
||||
});
|
||||
|
||||
if (eventNodes.length === 0) {
|
||||
warnings.push('No event nodes found. Your script needs at least one event to run.');
|
||||
}
|
||||
|
||||
// Check for disconnected nodes
|
||||
const connectedNodes = new Set<string>();
|
||||
for (const edge of edges) {
|
||||
connectedNodes.add(edge.source);
|
||||
connectedNodes.add(edge.target);
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!connectedNodes.has(node.id) && eventNodes.findIndex(e => e.id === node.id) === -1) {
|
||||
const def = getNodeDefinition(node.data.type);
|
||||
if (def?.category !== 'events') {
|
||||
warnings.push(`Node "${node.data.label || node.data.type}" is not connected.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for required inputs
|
||||
for (const node of nodes) {
|
||||
const def = getNodeDefinition(node.data.type);
|
||||
if (!def) continue;
|
||||
|
||||
for (const input of def.inputs) {
|
||||
if (input.required && input.type !== 'flow') {
|
||||
const hasConnection = edges.some(
|
||||
e => e.target === node.id && e.targetHandle === input.id
|
||||
);
|
||||
const hasValue = node.data.values?.[input.id] !== undefined;
|
||||
|
||||
if (!hasConnection && !hasValue) {
|
||||
errors.push(`Node "${node.data.label || def.label}" is missing required input: ${input.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for platform compatibility
|
||||
for (const node of nodes) {
|
||||
const def = getNodeDefinition(node.data.type);
|
||||
if (def && !def.platforms.includes(platform)) {
|
||||
errors.push(`Node "${def.label}" is not available on ${platform}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
828
src/lib/visual-scripting/node-definitions.ts
Normal file
828
src/lib/visual-scripting/node-definitions.ts
Normal file
|
|
@ -0,0 +1,828 @@
|
|||
/**
|
||||
* AeThex Visual Scripting - Node Definitions
|
||||
* All available nodes for the visual scripting system
|
||||
*/
|
||||
|
||||
import { Node, Edge } from 'reactflow';
|
||||
|
||||
// Node Categories
|
||||
export type NodeCategory =
|
||||
| 'events' // Entry points (green)
|
||||
| 'logic' // Control flow (blue)
|
||||
| 'actions' // Do things (purple)
|
||||
| 'data' // Values and operations (orange)
|
||||
| 'references' // Game objects (yellow)
|
||||
| 'custom'; // User-defined (gray)
|
||||
|
||||
// Port types for connections
|
||||
export type PortType =
|
||||
| 'flow' // Execution flow (white)
|
||||
| 'number' // Number values (blue)
|
||||
| 'string' // Text values (pink)
|
||||
| 'boolean' // True/false (red)
|
||||
| 'object' // Game objects (yellow)
|
||||
| 'array' // Lists (green)
|
||||
| 'any'; // Any type (gray)
|
||||
|
||||
export interface NodePort {
|
||||
id: string;
|
||||
name: string;
|
||||
type: PortType;
|
||||
defaultValue?: any;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface NodeDefinition {
|
||||
type: string;
|
||||
category: NodeCategory;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
inputs: NodePort[];
|
||||
outputs: NodePort[];
|
||||
platforms: ('roblox' | 'uefn' | 'spatial')[];
|
||||
codeTemplate: {
|
||||
roblox?: string;
|
||||
uefn?: string;
|
||||
spatial?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Color mapping for categories
|
||||
export const CATEGORY_COLORS: Record<NodeCategory, string> = {
|
||||
events: '#22c55e', // Green
|
||||
logic: '#3b82f6', // Blue
|
||||
actions: '#a855f7', // Purple
|
||||
data: '#f97316', // Orange
|
||||
references: '#eab308', // Yellow
|
||||
custom: '#6b7280', // Gray
|
||||
};
|
||||
|
||||
// Color mapping for port types
|
||||
export const PORT_COLORS: Record<PortType, string> = {
|
||||
flow: '#ffffff',
|
||||
number: '#3b82f6',
|
||||
string: '#ec4899',
|
||||
boolean: '#ef4444',
|
||||
object: '#eab308',
|
||||
array: '#22c55e',
|
||||
any: '#6b7280',
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// EVENT NODES - Entry points for scripts
|
||||
// ============================================
|
||||
|
||||
export const EVENT_NODES: NodeDefinition[] = [
|
||||
{
|
||||
type: 'onPlayerJoin',
|
||||
category: 'events',
|
||||
label: 'On Player Join',
|
||||
description: 'Triggered when a player joins the game',
|
||||
icon: 'UserPlus',
|
||||
color: CATEGORY_COLORS.events,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'player', name: 'Player', type: 'object' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `game.Players.PlayerAdded:Connect(function(player)\n{{BODY}}\nend)`,
|
||||
uefn: `OnPlayerAdded<public>(): void =\n{{BODY}}`,
|
||||
spatial: `SpaceService.onPlayerJoined.on((player) => {\n{{BODY}}\n});`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'onPlayerLeave',
|
||||
category: 'events',
|
||||
label: 'On Player Leave',
|
||||
description: 'Triggered when a player leaves the game',
|
||||
icon: 'UserMinus',
|
||||
color: CATEGORY_COLORS.events,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'player', name: 'Player', type: 'object' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `game.Players.PlayerRemoving:Connect(function(player)\n{{BODY}}\nend)`,
|
||||
uefn: `OnPlayerRemoved<public>(): void =\n{{BODY}}`,
|
||||
spatial: `SpaceService.onPlayerLeft.on((player) => {\n{{BODY}}\n});`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'onPartTouch',
|
||||
category: 'events',
|
||||
label: 'On Part Touched',
|
||||
description: 'Triggered when something touches a part',
|
||||
icon: 'Hand',
|
||||
color: CATEGORY_COLORS.events,
|
||||
inputs: [
|
||||
{ id: 'part', name: 'Part', type: 'object', required: true },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'otherPart', name: 'Other Part', type: 'object' },
|
||||
{ id: 'player', name: 'Player (if any)', type: 'object' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `{{part}}.Touched:Connect(function(otherPart)\n\tlocal player = game.Players:GetPlayerFromCharacter(otherPart.Parent)\n{{BODY}}\nend)`,
|
||||
uefn: `{{part}}.OnBeginOverlap.Subscribe(function(OtherActor: actor):\n{{BODY}}`,
|
||||
spatial: `{{part}}.onCollisionEnter.on((collision) => {\n{{BODY}}\n});`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'onKeyPress',
|
||||
category: 'events',
|
||||
label: 'On Key Press',
|
||||
description: 'Triggered when a key is pressed',
|
||||
icon: 'Keyboard',
|
||||
color: CATEGORY_COLORS.events,
|
||||
inputs: [
|
||||
{ id: 'key', name: 'Key', type: 'string', defaultValue: 'E' },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'player', name: 'Player', type: 'object' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `local UserInputService = game:GetService("UserInputService")\nUserInputService.InputBegan:Connect(function(input, gameProcessed)\n\tif not gameProcessed and input.KeyCode == Enum.KeyCode.{{key}} then\n{{BODY}}\n\tend\nend)`,
|
||||
uefn: `OnKeyPressed(Key: keycode_{{key}}): void =\n{{BODY}}`,
|
||||
spatial: `Input.onKeyDown("{{key}}", () => {\n{{BODY}}\n});`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'onTimer',
|
||||
category: 'events',
|
||||
label: 'On Timer',
|
||||
description: 'Triggers repeatedly at an interval',
|
||||
icon: 'Clock',
|
||||
color: CATEGORY_COLORS.events,
|
||||
inputs: [
|
||||
{ id: 'interval', name: 'Interval (sec)', type: 'number', defaultValue: 1 },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `while true do\n\ttask.wait({{interval}})\n{{BODY}}\nend`,
|
||||
uefn: `loop:\n\tSleep({{interval}})\n{{BODY}}`,
|
||||
spatial: `setInterval(() => {\n{{BODY}}\n}, {{interval}} * 1000);`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'onGameStart',
|
||||
category: 'events',
|
||||
label: 'On Game Start',
|
||||
description: 'Runs once when the game starts',
|
||||
icon: 'Play',
|
||||
color: CATEGORY_COLORS.events,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `-- Runs on game start\n{{BODY}}`,
|
||||
uefn: `OnBegin<override>(): void =\n{{BODY}}`,
|
||||
spatial: `SpaceService.onSpaceReady.on(() => {\n{{BODY}}\n});`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// LOGIC NODES - Control flow
|
||||
// ============================================
|
||||
|
||||
export const LOGIC_NODES: NodeDefinition[] = [
|
||||
{
|
||||
type: 'ifCondition',
|
||||
category: 'logic',
|
||||
label: 'If',
|
||||
description: 'Branch based on a condition',
|
||||
icon: 'GitBranch',
|
||||
color: CATEGORY_COLORS.logic,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'condition', name: 'Condition', type: 'boolean', required: true },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'true', name: 'True', type: 'flow' },
|
||||
{ id: 'false', name: 'False', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `if {{condition}} then\n{{TRUE_BODY}}\nelse\n{{FALSE_BODY}}\nend`,
|
||||
uefn: `if ({{condition}}):\n{{TRUE_BODY}}\nelse:\n{{FALSE_BODY}}`,
|
||||
spatial: `if ({{condition}}) {\n{{TRUE_BODY}}\n} else {\n{{FALSE_BODY}}\n}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'forLoop',
|
||||
category: 'logic',
|
||||
label: 'For Loop',
|
||||
description: 'Repeat a number of times',
|
||||
icon: 'Repeat',
|
||||
color: CATEGORY_COLORS.logic,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'start', name: 'Start', type: 'number', defaultValue: 1 },
|
||||
{ id: 'end', name: 'End', type: 'number', defaultValue: 10 },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'loop', name: 'Loop Body', type: 'flow' },
|
||||
{ id: 'index', name: 'Index', type: 'number' },
|
||||
{ id: 'complete', name: 'Completed', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `for i = {{start}}, {{end}} do\n{{LOOP_BODY}}\nend\n{{COMPLETE_BODY}}`,
|
||||
uefn: `for (Index := {{start}}..{{end}}):\n{{LOOP_BODY}}\n{{COMPLETE_BODY}}`,
|
||||
spatial: `for (let i = {{start}}; i <= {{end}}; i++) {\n{{LOOP_BODY}}\n}\n{{COMPLETE_BODY}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'forEach',
|
||||
category: 'logic',
|
||||
label: 'For Each',
|
||||
description: 'Loop through a list',
|
||||
icon: 'List',
|
||||
color: CATEGORY_COLORS.logic,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'array', name: 'Array', type: 'array', required: true },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'loop', name: 'Loop Body', type: 'flow' },
|
||||
{ id: 'item', name: 'Current Item', type: 'any' },
|
||||
{ id: 'index', name: 'Index', type: 'number' },
|
||||
{ id: 'complete', name: 'Completed', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `for index, item in ipairs({{array}}) do\n{{LOOP_BODY}}\nend\n{{COMPLETE_BODY}}`,
|
||||
uefn: `for (Index -> Item : {{array}}):\n{{LOOP_BODY}}\n{{COMPLETE_BODY}}`,
|
||||
spatial: `{{array}}.forEach((item, index) => {\n{{LOOP_BODY}}\n});\n{{COMPLETE_BODY}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'wait',
|
||||
category: 'logic',
|
||||
label: 'Wait',
|
||||
description: 'Pause execution for a duration',
|
||||
icon: 'Clock',
|
||||
color: CATEGORY_COLORS.logic,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'duration', name: 'Seconds', type: 'number', defaultValue: 1 },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Continue', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `task.wait({{duration}})\n{{BODY}}`,
|
||||
uefn: `Sleep({{duration}})\n{{BODY}}`,
|
||||
spatial: `await delay({{duration}} * 1000);\n{{BODY}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'whileLoop',
|
||||
category: 'logic',
|
||||
label: 'While',
|
||||
description: 'Loop while condition is true',
|
||||
icon: 'RefreshCw',
|
||||
color: CATEGORY_COLORS.logic,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'condition', name: 'Condition', type: 'boolean', required: true },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'loop', name: 'Loop Body', type: 'flow' },
|
||||
{ id: 'complete', name: 'Completed', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `while {{condition}} do\n{{LOOP_BODY}}\nend\n{{COMPLETE_BODY}}`,
|
||||
uefn: `loop:\n\tif (not {{condition}}): break\n{{LOOP_BODY}}`,
|
||||
spatial: `while ({{condition}}) {\n{{LOOP_BODY}}\n}\n{{COMPLETE_BODY}}`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// ACTION NODES - Do things
|
||||
// ============================================
|
||||
|
||||
export const ACTION_NODES: NodeDefinition[] = [
|
||||
{
|
||||
type: 'print',
|
||||
category: 'actions',
|
||||
label: 'Print',
|
||||
description: 'Print a message to the console',
|
||||
icon: 'MessageSquare',
|
||||
color: CATEGORY_COLORS.actions,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'message', name: 'Message', type: 'string', defaultValue: 'Hello!' },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Continue', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `print({{message}})\n{{BODY}}`,
|
||||
uefn: `Print({{message}})\n{{BODY}}`,
|
||||
spatial: `console.log({{message}});\n{{BODY}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'setProperty',
|
||||
category: 'actions',
|
||||
label: 'Set Property',
|
||||
description: 'Set a property on an object',
|
||||
icon: 'Settings',
|
||||
color: CATEGORY_COLORS.actions,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'object', name: 'Object', type: 'object', required: true },
|
||||
{ id: 'property', name: 'Property', type: 'string', required: true },
|
||||
{ id: 'value', name: 'Value', type: 'any', required: true },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Continue', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `{{object}}.{{property}} = {{value}}\n{{BODY}}`,
|
||||
uefn: `set {{object}}.{{property}} = {{value}}\n{{BODY}}`,
|
||||
spatial: `{{object}}.{{property}} = {{value}};\n{{BODY}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'createPart',
|
||||
category: 'actions',
|
||||
label: 'Create Part',
|
||||
description: 'Create a new part in the world',
|
||||
icon: 'Box',
|
||||
color: CATEGORY_COLORS.actions,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'position', name: 'Position', type: 'object' },
|
||||
{ id: 'size', name: 'Size', type: 'object' },
|
||||
{ id: 'color', name: 'Color', type: 'object' },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Continue', type: 'flow' },
|
||||
{ id: 'part', name: 'Created Part', type: 'object' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `local newPart = Instance.new("Part")\nnewPart.Position = {{position}}\nnewPart.Size = {{size}}\nnewPart.BrickColor = {{color}}\nnewPart.Parent = workspace\n{{BODY}}`,
|
||||
uefn: `var NewProp := SpawnProp(DefaultProp)\nNewProp.SetTransform({{position}})\n{{BODY}}`,
|
||||
spatial: `const newPart = createPart({{position}}, {{size}}, {{color}});\n{{BODY}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'destroy',
|
||||
category: 'actions',
|
||||
label: 'Destroy',
|
||||
description: 'Destroy an object',
|
||||
icon: 'Trash2',
|
||||
color: CATEGORY_COLORS.actions,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'object', name: 'Object', type: 'object', required: true },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Continue', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `{{object}}:Destroy()\n{{BODY}}`,
|
||||
uefn: `{{object}}.Dispose()\n{{BODY}}`,
|
||||
spatial: `{{object}}.destroy();\n{{BODY}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'playSound',
|
||||
category: 'actions',
|
||||
label: 'Play Sound',
|
||||
description: 'Play a sound effect',
|
||||
icon: 'Volume2',
|
||||
color: CATEGORY_COLORS.actions,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'soundId', name: 'Sound ID', type: 'string', required: true },
|
||||
{ id: 'volume', name: 'Volume', type: 'number', defaultValue: 1 },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Continue', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `local sound = Instance.new("Sound")\nsound.SoundId = "rbxassetid://{{soundId}}"\nsound.Volume = {{volume}}\nsound.Parent = workspace\nsound:Play()\n{{BODY}}`,
|
||||
uefn: `PlaySound({{soundId}}, {{volume}})\n{{BODY}}`,
|
||||
spatial: `playSound("{{soundId}}", { volume: {{volume}} });\n{{BODY}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'tween',
|
||||
category: 'actions',
|
||||
label: 'Tween Property',
|
||||
description: 'Smoothly animate a property',
|
||||
icon: 'Sparkles',
|
||||
color: CATEGORY_COLORS.actions,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'object', name: 'Object', type: 'object', required: true },
|
||||
{ id: 'property', name: 'Property', type: 'string', required: true },
|
||||
{ id: 'target', name: 'Target Value', type: 'any', required: true },
|
||||
{ id: 'duration', name: 'Duration', type: 'number', defaultValue: 1 },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'On Complete', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `local TweenService = game:GetService("TweenService")\nlocal tween = TweenService:Create({{object}}, TweenInfo.new({{duration}}), {{{property}} = {{target}}})\ntween:Play()\ntween.Completed:Wait()\n{{BODY}}`,
|
||||
uefn: `{{object}}.MoveAndRotateTo({{target}}, {{duration}})\n{{BODY}}`,
|
||||
spatial: `animate({{object}}, { {{property}}: {{target}} }, {{duration}} * 1000).then(() => {\n{{BODY}}\n});`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'teleport',
|
||||
category: 'actions',
|
||||
label: 'Teleport Player',
|
||||
description: 'Move a player to a position',
|
||||
icon: 'Zap',
|
||||
color: CATEGORY_COLORS.actions,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'player', name: 'Player', type: 'object', required: true },
|
||||
{ id: 'position', name: 'Position', type: 'object', required: true },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Continue', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `{{player}}.Character:SetPrimaryPartCFrame(CFrame.new({{position}}))\n{{BODY}}`,
|
||||
uefn: `{{player}}.Respawn({{position}}, {{player}}.GetRotation())\n{{BODY}}`,
|
||||
spatial: `{{player}}.teleportTo({{position}});\n{{BODY}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'giveItem',
|
||||
category: 'actions',
|
||||
label: 'Give Item',
|
||||
description: 'Give an item/tool to a player',
|
||||
icon: 'Gift',
|
||||
color: CATEGORY_COLORS.actions,
|
||||
inputs: [
|
||||
{ id: 'flow', name: 'Execute', type: 'flow' },
|
||||
{ id: 'player', name: 'Player', type: 'object', required: true },
|
||||
{ id: 'item', name: 'Item', type: 'object', required: true },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'flow', name: 'Continue', type: 'flow' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `local itemClone = {{item}}:Clone()\nitemClone.Parent = {{player}}.Backpack\n{{BODY}}`,
|
||||
uefn: `GrantItem({{player}}, {{item}})\n{{BODY}}`,
|
||||
spatial: `{{player}}.inventory.add({{item}});\n{{BODY}}`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// DATA NODES - Values and operations
|
||||
// ============================================
|
||||
|
||||
export const DATA_NODES: NodeDefinition[] = [
|
||||
{
|
||||
type: 'number',
|
||||
category: 'data',
|
||||
label: 'Number',
|
||||
description: 'A number value',
|
||||
icon: 'Hash',
|
||||
color: CATEGORY_COLORS.data,
|
||||
inputs: [
|
||||
{ id: 'value', name: 'Value', type: 'number', defaultValue: 0 },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'value', name: 'Value', type: 'number' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `{{value}}`,
|
||||
uefn: `{{value}}`,
|
||||
spatial: `{{value}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
category: 'data',
|
||||
label: 'Text',
|
||||
description: 'A text value',
|
||||
icon: 'Type',
|
||||
color: CATEGORY_COLORS.data,
|
||||
inputs: [
|
||||
{ id: 'value', name: 'Value', type: 'string', defaultValue: '' },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'value', name: 'Value', type: 'string' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `"{{value}}"`,
|
||||
uefn: `"{{value}}"`,
|
||||
spatial: `"{{value}}"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
category: 'data',
|
||||
label: 'Boolean',
|
||||
description: 'True or False',
|
||||
icon: 'ToggleLeft',
|
||||
color: CATEGORY_COLORS.data,
|
||||
inputs: [
|
||||
{ id: 'value', name: 'Value', type: 'boolean', defaultValue: true },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'value', name: 'Value', type: 'boolean' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `{{value}}`,
|
||||
uefn: `{{value}}`,
|
||||
spatial: `{{value}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'vector3',
|
||||
category: 'data',
|
||||
label: 'Vector3',
|
||||
description: '3D position/direction',
|
||||
icon: 'Move3d',
|
||||
color: CATEGORY_COLORS.data,
|
||||
inputs: [
|
||||
{ id: 'x', name: 'X', type: 'number', defaultValue: 0 },
|
||||
{ id: 'y', name: 'Y', type: 'number', defaultValue: 0 },
|
||||
{ id: 'z', name: 'Z', type: 'number', defaultValue: 0 },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'vector', name: 'Vector', type: 'object' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `Vector3.new({{x}}, {{y}}, {{z}})`,
|
||||
uefn: `vector3{X := {{x}}, Y := {{y}}, Z := {{z}}}`,
|
||||
spatial: `new Vector3({{x}}, {{y}}, {{z}})`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'color',
|
||||
category: 'data',
|
||||
label: 'Color',
|
||||
description: 'RGB color value',
|
||||
icon: 'Palette',
|
||||
color: CATEGORY_COLORS.data,
|
||||
inputs: [
|
||||
{ id: 'r', name: 'Red', type: 'number', defaultValue: 255 },
|
||||
{ id: 'g', name: 'Green', type: 'number', defaultValue: 255 },
|
||||
{ id: 'b', name: 'Blue', type: 'number', defaultValue: 255 },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'color', name: 'Color', type: 'object' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `Color3.fromRGB({{r}}, {{g}}, {{b}})`,
|
||||
uefn: `MakeColor({{r}}, {{g}}, {{b}})`,
|
||||
spatial: `new Color({{r}}, {{g}}, {{b}})`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'math',
|
||||
category: 'data',
|
||||
label: 'Math',
|
||||
description: 'Mathematical operation',
|
||||
icon: 'Calculator',
|
||||
color: CATEGORY_COLORS.data,
|
||||
inputs: [
|
||||
{ id: 'a', name: 'A', type: 'number', required: true },
|
||||
{ id: 'b', name: 'B', type: 'number', required: true },
|
||||
{ id: 'operation', name: 'Operation', type: 'string', defaultValue: 'add' },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'result', name: 'Result', type: 'number' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `({{a}} {{OPERATION}} {{b}})`,
|
||||
uefn: `({{a}} {{OPERATION}} {{b}})`,
|
||||
spatial: `({{a}} {{OPERATION}} {{b}})`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'compare',
|
||||
category: 'data',
|
||||
label: 'Compare',
|
||||
description: 'Compare two values',
|
||||
icon: 'Scale',
|
||||
color: CATEGORY_COLORS.data,
|
||||
inputs: [
|
||||
{ id: 'a', name: 'A', type: 'any', required: true },
|
||||
{ id: 'b', name: 'B', type: 'any', required: true },
|
||||
{ id: 'comparison', name: 'Comparison', type: 'string', defaultValue: 'equals' },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'result', name: 'Result', type: 'boolean' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `({{a}} {{COMPARISON}} {{b}})`,
|
||||
uefn: `({{a}} {{COMPARISON}} {{b}})`,
|
||||
spatial: `({{a}} {{COMPARISON}} {{b}})`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'random',
|
||||
category: 'data',
|
||||
label: 'Random',
|
||||
description: 'Random number in range',
|
||||
icon: 'Dices',
|
||||
color: CATEGORY_COLORS.data,
|
||||
inputs: [
|
||||
{ id: 'min', name: 'Min', type: 'number', defaultValue: 1 },
|
||||
{ id: 'max', name: 'Max', type: 'number', defaultValue: 100 },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'value', name: 'Value', type: 'number' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `math.random({{min}}, {{max}})`,
|
||||
uefn: `GetRandomInt({{min}}, {{max}})`,
|
||||
spatial: `Math.floor(Math.random() * ({{max}} - {{min}} + 1)) + {{min}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'variable',
|
||||
category: 'data',
|
||||
label: 'Variable',
|
||||
description: 'Store and retrieve a value',
|
||||
icon: 'Variable',
|
||||
color: CATEGORY_COLORS.data,
|
||||
inputs: [
|
||||
{ id: 'name', name: 'Name', type: 'string', required: true },
|
||||
{ id: 'setValue', name: 'Set Value', type: 'any' },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'getValue', name: 'Get Value', type: 'any' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `{{name}}`,
|
||||
uefn: `{{name}}`,
|
||||
spatial: `{{name}}`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// REFERENCE NODES - Game objects
|
||||
// ============================================
|
||||
|
||||
export const REFERENCE_NODES: NodeDefinition[] = [
|
||||
{
|
||||
type: 'getPlayer',
|
||||
category: 'references',
|
||||
label: 'Get Player',
|
||||
description: 'Get a player by name or index',
|
||||
icon: 'User',
|
||||
color: CATEGORY_COLORS.references,
|
||||
inputs: [
|
||||
{ id: 'name', name: 'Name (optional)', type: 'string' },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'player', name: 'Player', type: 'object' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `game.Players:FindFirstChild("{{name}}")`,
|
||||
uefn: `GetPlayer[{{name}}]`,
|
||||
spatial: `SpaceService.getPlayerByName("{{name}}")`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'getAllPlayers',
|
||||
category: 'references',
|
||||
label: 'Get All Players',
|
||||
description: 'Get list of all players',
|
||||
icon: 'Users',
|
||||
color: CATEGORY_COLORS.references,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ id: 'players', name: 'Players', type: 'array' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `game.Players:GetPlayers()`,
|
||||
uefn: `GetPlayspace().GetPlayers()`,
|
||||
spatial: `SpaceService.getAllPlayers()`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'findChild',
|
||||
category: 'references',
|
||||
label: 'Find Child',
|
||||
description: 'Find a child object by name',
|
||||
icon: 'Search',
|
||||
color: CATEGORY_COLORS.references,
|
||||
inputs: [
|
||||
{ id: 'parent', name: 'Parent', type: 'object', required: true },
|
||||
{ id: 'name', name: 'Name', type: 'string', required: true },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'child', name: 'Child', type: 'object' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `{{parent}}:FindFirstChild("{{name}}")`,
|
||||
uefn: `{{parent}}.GetChildren().Find("{{name}}")`,
|
||||
spatial: `{{parent}}.getChildByName("{{name}}")`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'workspace',
|
||||
category: 'references',
|
||||
label: 'Workspace',
|
||||
description: 'The game workspace/world',
|
||||
icon: 'Globe',
|
||||
color: CATEGORY_COLORS.references,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ id: 'workspace', name: 'Workspace', type: 'object' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `workspace`,
|
||||
uefn: `GetPlayspace()`,
|
||||
spatial: `World`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'getService',
|
||||
category: 'references',
|
||||
label: 'Get Service',
|
||||
description: 'Get a game service',
|
||||
icon: 'Server',
|
||||
color: CATEGORY_COLORS.references,
|
||||
inputs: [
|
||||
{ id: 'service', name: 'Service Name', type: 'string', required: true },
|
||||
],
|
||||
outputs: [
|
||||
{ id: 'service', name: 'Service', type: 'object' },
|
||||
],
|
||||
platforms: ['roblox', 'uefn', 'spatial'],
|
||||
codeTemplate: {
|
||||
roblox: `game:GetService("{{service}}")`,
|
||||
uefn: `Get{{service}}Service()`,
|
||||
spatial: `{{service}}Service`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Combine all nodes
|
||||
export const ALL_NODES: NodeDefinition[] = [
|
||||
...EVENT_NODES,
|
||||
...LOGIC_NODES,
|
||||
...ACTION_NODES,
|
||||
...DATA_NODES,
|
||||
...REFERENCE_NODES,
|
||||
];
|
||||
|
||||
// Get nodes by category
|
||||
export function getNodesByCategory(category: NodeCategory): NodeDefinition[] {
|
||||
return ALL_NODES.filter(n => n.category === category);
|
||||
}
|
||||
|
||||
// Get node by type
|
||||
export function getNodeDefinition(type: string): NodeDefinition | undefined {
|
||||
return ALL_NODES.find(n => n.type === type);
|
||||
}
|
||||
|
||||
// Get nodes for a specific platform
|
||||
export function getNodesForPlatform(platform: 'roblox' | 'uefn' | 'spatial'): NodeDefinition[] {
|
||||
return ALL_NODES.filter(n => n.platforms.includes(platform));
|
||||
}
|
||||
404
src/stores/asset-store.ts
Normal file
404
src/stores/asset-store.ts
Normal 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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
527
src/stores/collaboration-store.ts
Normal file
527
src/stores/collaboration-store.ts
Normal 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
220
src/stores/preview-store.ts
Normal 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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
213
src/stores/visual-script-store.ts
Normal file
213
src/stores/visual-script-store.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
/**
|
||||
* AeThex Visual Scripting - State Store
|
||||
* Zustand store for managing visual script state
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import {
|
||||
Node,
|
||||
Edge,
|
||||
Connection,
|
||||
addEdge,
|
||||
applyNodeChanges,
|
||||
applyEdgeChanges,
|
||||
NodeChange,
|
||||
EdgeChange,
|
||||
} from 'reactflow';
|
||||
import { NodeData } from '../lib/visual-scripting/code-generator';
|
||||
|
||||
interface VisualScriptState {
|
||||
// Current script data
|
||||
nodes: Node<NodeData>[];
|
||||
edges: Edge[];
|
||||
|
||||
// UI state
|
||||
selectedNodes: string[];
|
||||
selectedEdges: string[];
|
||||
|
||||
// Generated code
|
||||
generatedCode: string;
|
||||
|
||||
// History for undo/redo
|
||||
history: { nodes: Node<NodeData>[]; edges: Edge[] }[];
|
||||
historyIndex: number;
|
||||
|
||||
// Actions
|
||||
setNodes: (nodes: Node<NodeData>[]) => void;
|
||||
setEdges: (edges: Edge[]) => void;
|
||||
onNodesChange: (changes: NodeChange[]) => void;
|
||||
onEdgesChange: (changes: EdgeChange[]) => void;
|
||||
onConnect: (connection: Connection) => void;
|
||||
addNode: (node: Node<NodeData>) => void;
|
||||
removeNode: (nodeId: string) => void;
|
||||
updateNodeData: (nodeId: string, data: Partial<NodeData>) => void;
|
||||
updateNodeValue: (nodeId: string, key: string, value: any) => void;
|
||||
setSelectedNodes: (nodeIds: string[]) => void;
|
||||
setSelectedEdges: (edgeIds: string[]) => void;
|
||||
setGeneratedCode: (code: string) => void;
|
||||
clearScript: () => void;
|
||||
saveToHistory: () => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
loadScript: (nodes: Node<NodeData>[], edges: Edge[]) => void;
|
||||
}
|
||||
|
||||
export const useVisualScriptStore = create<VisualScriptState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
selectedNodes: [],
|
||||
selectedEdges: [],
|
||||
generatedCode: '',
|
||||
history: [],
|
||||
historyIndex: -1,
|
||||
|
||||
setNodes: (nodes) => set({ nodes }),
|
||||
|
||||
setEdges: (edges) => set({ edges }),
|
||||
|
||||
onNodesChange: (changes) => {
|
||||
set({
|
||||
nodes: applyNodeChanges(changes, get().nodes) as Node<NodeData>[],
|
||||
});
|
||||
},
|
||||
|
||||
onEdgesChange: (changes) => {
|
||||
set({
|
||||
edges: applyEdgeChanges(changes, get().edges),
|
||||
});
|
||||
},
|
||||
|
||||
onConnect: (connection) => {
|
||||
// Validate connection types could go here
|
||||
set({
|
||||
edges: addEdge(
|
||||
{
|
||||
...connection,
|
||||
id: `edge-${Date.now()}`,
|
||||
type: 'smoothstep',
|
||||
animated: connection.sourceHandle === 'flow',
|
||||
},
|
||||
get().edges
|
||||
),
|
||||
});
|
||||
get().saveToHistory();
|
||||
},
|
||||
|
||||
addNode: (node) => {
|
||||
set({ nodes: [...get().nodes, node] });
|
||||
get().saveToHistory();
|
||||
},
|
||||
|
||||
removeNode: (nodeId) => {
|
||||
set({
|
||||
nodes: get().nodes.filter((n) => n.id !== nodeId),
|
||||
edges: get().edges.filter(
|
||||
(e) => e.source !== nodeId && e.target !== nodeId
|
||||
),
|
||||
});
|
||||
get().saveToHistory();
|
||||
},
|
||||
|
||||
updateNodeData: (nodeId, data) => {
|
||||
set({
|
||||
nodes: get().nodes.map((node) =>
|
||||
node.id === nodeId
|
||||
? { ...node, data: { ...node.data, ...data } }
|
||||
: node
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
updateNodeValue: (nodeId, key, value) => {
|
||||
set({
|
||||
nodes: get().nodes.map((node) =>
|
||||
node.id === nodeId
|
||||
? {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
values: {
|
||||
...node.data.values,
|
||||
[key]: value,
|
||||
},
|
||||
},
|
||||
}
|
||||
: node
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
setSelectedNodes: (nodeIds) => set({ selectedNodes: nodeIds }),
|
||||
|
||||
setSelectedEdges: (edgeIds) => set({ selectedEdges: edgeIds }),
|
||||
|
||||
setGeneratedCode: (code) => set({ generatedCode: code }),
|
||||
|
||||
clearScript: () => {
|
||||
set({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
selectedNodes: [],
|
||||
selectedEdges: [],
|
||||
generatedCode: '',
|
||||
});
|
||||
get().saveToHistory();
|
||||
},
|
||||
|
||||
saveToHistory: () => {
|
||||
const { nodes, edges, history, historyIndex } = get();
|
||||
const newHistory = history.slice(0, historyIndex + 1);
|
||||
newHistory.push({ nodes: [...nodes], edges: [...edges] });
|
||||
|
||||
// Keep only last 50 states
|
||||
if (newHistory.length > 50) {
|
||||
newHistory.shift();
|
||||
}
|
||||
|
||||
set({
|
||||
history: newHistory,
|
||||
historyIndex: newHistory.length - 1,
|
||||
});
|
||||
},
|
||||
|
||||
undo: () => {
|
||||
const { history, historyIndex } = get();
|
||||
if (historyIndex > 0) {
|
||||
const prevState = history[historyIndex - 1];
|
||||
set({
|
||||
nodes: prevState.nodes,
|
||||
edges: prevState.edges,
|
||||
historyIndex: historyIndex - 1,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
redo: () => {
|
||||
const { history, historyIndex } = get();
|
||||
if (historyIndex < history.length - 1) {
|
||||
const nextState = history[historyIndex + 1];
|
||||
set({
|
||||
nodes: nextState.nodes,
|
||||
edges: nextState.edges,
|
||||
historyIndex: historyIndex + 1,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
loadScript: (nodes, edges) => {
|
||||
set({ nodes, edges });
|
||||
get().saveToHistory();
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'aethex-visual-script',
|
||||
partialize: (state) => ({
|
||||
nodes: state.nodes,
|
||||
edges: state.edges,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
Loading…
Reference in a new issue