feat: Add Live Game Preview with 3D viewport and Lua interpreter

- Create mock Roblox API (Vector3, Color3, CFrame, TweenService, RunService)
- Implement Lua-to-JavaScript transpiler for basic Roblox script execution
- Build 3D viewport using React Three Fiber with shadows, grid, and controls
- Add preview console with filtering, search, and output types
- Create LivePreview component with run/stop/pause controls and settings
- Add 3D Preview button to Toolbar (desktop and mobile)
- Fix pre-existing syntax error in FileTree.tsx toggleFolder function
This commit is contained in:
Claude 2026-01-23 23:06:16 +00:00
parent 5feb186c05
commit 159e40f02c
No known key found for this signature in database
13 changed files with 3184 additions and 124 deletions

787
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -34,6 +34,8 @@
"@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",
@ -41,6 +43,7 @@
"@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",
@ -57,6 +60,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"three": "^0.182.0",
"zustand": "^5.0.10"
},
"devDependencies": {
@ -66,6 +70,7 @@
"@types/node": "22.19.7",
"@types/react": "18.3.27",
"@types/react-dom": "^18",
"@types/three": "^0.182.0",
"@vitejs/plugin-react": "^5.1.2",
"autoprefixer": "^10.4.23",
"eslint": "^8",

View file

@ -35,6 +35,7 @@ const TranslationPanel = lazy(() => import('./components/TranslationPanel').then
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'));
function App() {
const [currentCode, setCurrentCode] = useState('');
@ -48,6 +49,7 @@ function App() {
const [showAvatarToolkit, setShowAvatarToolkit] = useState(false);
const [showVisualScripting, setShowVisualScripting] = useState(false);
const [showAssetLibrary, setShowAssetLibrary] = useState(false);
const [showLivePreview, setShowLivePreview] = useState(false);
const [code, setCode] = useState('');
const [currentPlatform, setCurrentPlatform] = useState<PlatformId>('roblox');
const isMobile = useIsMobile();
@ -486,6 +488,7 @@ end)`,
onAvatarToolkitClick={() => setShowAvatarToolkit(true)}
onVisualScriptingClick={() => setShowVisualScripting(true)}
onAssetLibraryClick={() => setShowAssetLibrary(true)}
onLivePreviewClick={() => setShowLivePreview(true)}
/>
</div>
@ -639,6 +642,18 @@ end)`,
/>
)}
</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}>
<WelcomeDialog />
</Suspense>

View file

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

View file

@ -7,7 +7,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Copy, FileCode, Download, Info, Play, FolderPlus, User, SignOut, List, ArrowsLeftRight, UserCircle, GitBranch, Package } from '@phosphor-icons/react';
import { Copy, FileCode, Download, Info, Play, FolderPlus, User, SignOut, List, ArrowsLeftRight, UserCircle, GitBranch, Package, Cube } from '@phosphor-icons/react';
import { toast } from 'sonner';
import { useState, useEffect, useCallback, memo } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
@ -27,9 +27,10 @@ interface ToolbarProps {
onAvatarToolkitClick?: () => void;
onVisualScriptingClick?: () => void;
onAssetLibraryClick?: () => void;
onLivePreviewClick?: () => void;
}
export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectClick, currentPlatform, onPlatformChange, onTranslateClick, onAvatarToolkitClick, onVisualScriptingClick, onAssetLibraryClick }: ToolbarProps) {
export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectClick, currentPlatform, onPlatformChange, onTranslateClick, onAvatarToolkitClick, onVisualScriptingClick, onAssetLibraryClick, onLivePreviewClick }: ToolbarProps) {
const [showInfo, setShowInfo] = useState(false);
const [user, setUser] = useState<{ login: string; avatarUrl: string; email: string } | null>(null);
@ -158,6 +159,25 @@ export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectCl
</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>
)}
<div className="h-6 w-px bg-border mx-1" />
<Tooltip>
@ -290,6 +310,12 @@ export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectCl
<span>Asset Library</span>
</DropdownMenuItem>
)}
{onLivePreviewClick && (
<DropdownMenuItem onClick={onLivePreviewClick}>
<Cube className="mr-2" size={16} />
<span>3D Preview</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleCopy}>
<Copy className="mr-2" size={16} />
<span>Copy Code</span>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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