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:
parent
5feb186c05
commit
159e40f02c
13 changed files with 3184 additions and 124 deletions
787
package-lock.json
generated
787
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
15
src/App.tsx
15
src/App.tsx
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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, 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>
|
||||
|
|
|
|||
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';
|
||||
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 },
|
||||
};
|
||||
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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
Loading…
Reference in a new issue