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-tabs": "^1.1.2",
|
||||||
"@radix-ui/react-toast": "^1.2.4",
|
"@radix-ui/react-toast": "^1.2.4",
|
||||||
"@radix-ui/react-tooltip": "^1.1.6",
|
"@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/background": "^11.3.14",
|
||||||
"@reactflow/controls": "^11.2.14",
|
"@reactflow/controls": "^11.2.14",
|
||||||
"@reactflow/minimap": "^11.7.14",
|
"@reactflow/minimap": "^11.7.14",
|
||||||
|
|
@ -41,6 +43,7 @@
|
||||||
"@sentry/browser": "^10.34.0",
|
"@sentry/browser": "^10.34.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"fengari-web": "^0.1.4",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"immer": "^11.1.3",
|
"immer": "^11.1.3",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
|
|
@ -57,6 +60,7 @@
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"three": "^0.182.0",
|
||||||
"zustand": "^5.0.10"
|
"zustand": "^5.0.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -66,6 +70,7 @@
|
||||||
"@types/node": "22.19.7",
|
"@types/node": "22.19.7",
|
||||||
"@types/react": "18.3.27",
|
"@types/react": "18.3.27",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
"@types/three": "^0.182.0",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
"eslint": "^8",
|
"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 AvatarToolkit = lazy(() => import('./components/AvatarToolkit'));
|
||||||
const VisualScriptingCanvas = lazy(() => import('./components/visual-scripting/VisualScriptingCanvas'));
|
const VisualScriptingCanvas = lazy(() => import('./components/visual-scripting/VisualScriptingCanvas'));
|
||||||
const AssetLibrary = lazy(() => import('./components/assets/AssetLibrary'));
|
const AssetLibrary = lazy(() => import('./components/assets/AssetLibrary'));
|
||||||
|
const LivePreview = lazy(() => import('./components/preview/LivePreview'));
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [currentCode, setCurrentCode] = useState('');
|
const [currentCode, setCurrentCode] = useState('');
|
||||||
|
|
@ -48,6 +49,7 @@ function App() {
|
||||||
const [showAvatarToolkit, setShowAvatarToolkit] = useState(false);
|
const [showAvatarToolkit, setShowAvatarToolkit] = useState(false);
|
||||||
const [showVisualScripting, setShowVisualScripting] = useState(false);
|
const [showVisualScripting, setShowVisualScripting] = useState(false);
|
||||||
const [showAssetLibrary, setShowAssetLibrary] = useState(false);
|
const [showAssetLibrary, setShowAssetLibrary] = useState(false);
|
||||||
|
const [showLivePreview, setShowLivePreview] = useState(false);
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
const [currentPlatform, setCurrentPlatform] = useState<PlatformId>('roblox');
|
const [currentPlatform, setCurrentPlatform] = useState<PlatformId>('roblox');
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
@ -486,6 +488,7 @@ end)`,
|
||||||
onAvatarToolkitClick={() => setShowAvatarToolkit(true)}
|
onAvatarToolkitClick={() => setShowAvatarToolkit(true)}
|
||||||
onVisualScriptingClick={() => setShowVisualScripting(true)}
|
onVisualScriptingClick={() => setShowVisualScripting(true)}
|
||||||
onAssetLibraryClick={() => setShowAssetLibrary(true)}
|
onAssetLibraryClick={() => setShowAssetLibrary(true)}
|
||||||
|
onLivePreviewClick={() => setShowLivePreview(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -639,6 +642,18 @@ end)`,
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Suspense>
|
</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}>
|
<Suspense fallback={null}>
|
||||||
<WelcomeDialog />
|
<WelcomeDialog />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
|
||||||
|
|
@ -58,38 +58,35 @@ export function FileTree({
|
||||||
if (next.has(id)) {
|
if (next.has(id)) {
|
||||||
next.delete(id);
|
next.delete(id);
|
||||||
} else {
|
} else {
|
||||||
return (
|
next.add(id);
|
||||||
<ScrollArea className="flex-1 bg-muted/40 border-r border-border min-w-[180px] max-w-[260px]">
|
}
|
||||||
<div className="p-2">
|
return next;
|
||||||
{files.map((node) => (
|
});
|
||||||
<FileNodeComponent
|
}, []);
|
||||||
key={node.id}
|
|
||||||
node={node}
|
const startRename = useCallback((node: FileNode) => {
|
||||||
expandedFolders={expandedFolders}
|
setEditingId(node.id);
|
||||||
toggleFolder={toggleFolder}
|
setEditingName(node.name);
|
||||||
onFileSelect={onFileSelect}
|
}, []);
|
||||||
onFileCreate={onFileCreate}
|
|
||||||
onFileRename={onFileRename}
|
const finishRename = useCallback((id: string) => {
|
||||||
onFileDelete={onFileDelete}
|
if (editingName.trim()) {
|
||||||
onFileMove={onFileMove}
|
onFileRename(id, editingName.trim());
|
||||||
selectedFileId={selectedFileId}
|
}
|
||||||
startRename={startRename}
|
setEditingId(null);
|
||||||
finishRename={finishRename}
|
setEditingName('');
|
||||||
editingId={editingId}
|
}, [editingName, onFileRename]);
|
||||||
editingName={editingName}
|
|
||||||
setEditingName={setEditingName}
|
const handleDelete = useCallback((node: FileNode) => {
|
||||||
handleDelete={handleDelete}
|
if (confirm(`Are you sure you want to delete "${node.name}"?`)) {
|
||||||
handleDragStart={handleDragStart}
|
onFileDelete(node.id);
|
||||||
handleDragOver={handleDragOver}
|
}
|
||||||
handleDragLeave={handleDragLeave}
|
}, [onFileDelete]);
|
||||||
handleDrop={handleDrop}
|
|
||||||
draggedId={draggedId}
|
const handleDragStart = useCallback((e: React.DragEvent, node: FileNode) => {
|
||||||
dropTargetId={dropTargetId}
|
e.stopPropagation();
|
||||||
/>
|
setDraggedId(node.id);
|
||||||
))}
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: React.DragEvent, node: FileNode) => {
|
const handleDragOver = useCallback((e: React.DragEvent, node: FileNode) => {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} 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 { toast } from 'sonner';
|
||||||
import { useState, useEffect, useCallback, memo } from 'react';
|
import { useState, useEffect, useCallback, memo } from 'react';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
|
||||||
|
|
@ -27,9 +27,10 @@ interface ToolbarProps {
|
||||||
onAvatarToolkitClick?: () => void;
|
onAvatarToolkitClick?: () => void;
|
||||||
onVisualScriptingClick?: () => void;
|
onVisualScriptingClick?: () => void;
|
||||||
onAssetLibraryClick?: () => 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 [showInfo, setShowInfo] = useState(false);
|
||||||
const [user, setUser] = useState<{ login: string; avatarUrl: string; email: string } | null>(null);
|
const [user, setUser] = useState<{ login: string; avatarUrl: string; email: string } | null>(null);
|
||||||
|
|
||||||
|
|
@ -158,6 +159,25 @@ export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectCl
|
||||||
</Tooltip>
|
</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" />
|
<div className="h-6 w-px bg-border mx-1" />
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|
@ -290,6 +310,12 @@ export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectCl
|
||||||
<span>Asset Library</span>
|
<span>Asset Library</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{onLivePreviewClick && (
|
||||||
|
<DropdownMenuItem onClick={onLivePreviewClick}>
|
||||||
|
<Cube className="mr-2" size={16} />
|
||||||
|
<span>3D Preview</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem onClick={handleCopy}>
|
<DropdownMenuItem onClick={handleCopy}>
|
||||||
<Copy className="mr-2" size={16} />
|
<Copy className="mr-2" size={16} />
|
||||||
<span>Copy Code</span>
|
<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