diff options
Diffstat (limited to 'packages/ui/src/lib')
| -rw-r--r-- | packages/ui/src/lib/Counter.svelte | 10 | ||||
| -rw-r--r-- | packages/ui/src/lib/DownloadMonitor.svelte | 201 | ||||
| -rw-r--r-- | packages/ui/src/lib/GameConsole.svelte | 304 | ||||
| -rw-r--r-- | packages/ui/src/lib/effects/ConstellationEffect.ts | 162 | ||||
| -rw-r--r-- | packages/ui/src/lib/effects/SaturnEffect.ts | 303 | ||||
| -rw-r--r-- | packages/ui/src/lib/modLoaderApi.ts | 106 | ||||
| -rw-r--r-- | packages/ui/src/lib/tsrs-utils.ts | 67 | ||||
| -rw-r--r-- | packages/ui/src/lib/utils.ts | 6 |
8 files changed, 204 insertions, 955 deletions
diff --git a/packages/ui/src/lib/Counter.svelte b/packages/ui/src/lib/Counter.svelte deleted file mode 100644 index 37d75ce..0000000 --- a/packages/ui/src/lib/Counter.svelte +++ /dev/null @@ -1,10 +0,0 @@ -<script lang="ts"> - let count: number = $state(0) - const increment = () => { - count += 1 - } -</script> - -<button onclick={increment}> - count is {count} -</button> diff --git a/packages/ui/src/lib/DownloadMonitor.svelte b/packages/ui/src/lib/DownloadMonitor.svelte deleted file mode 100644 index 860952c..0000000 --- a/packages/ui/src/lib/DownloadMonitor.svelte +++ /dev/null @@ -1,201 +0,0 @@ -<script lang="ts"> - import { listen } from "@tauri-apps/api/event"; - import { onMount, onDestroy } from "svelte"; - - export let visible = false; - - interface DownloadEvent { - file: string; - downloaded: number; // in bytes - total: number; // in bytes - status: string; - completed_files: number; - total_files: number; - total_downloaded_bytes: number; - } - - let currentFile = ""; - let progress = 0; // percentage 0-100 (current file) - let totalProgress = 0; // percentage 0-100 (all files) - let totalFiles = 0; - let completedFiles = 0; - let statusText = "Preparing..."; - let unlistenProgress: () => void; - let unlistenStart: () => void; - let unlistenComplete: () => void; - let downloadedBytes = 0; - let totalBytes = 0; - - // Speed and ETA tracking - let downloadSpeed = 0; // bytes per second - let etaSeconds = 0; - let startTime = 0; - let totalDownloadedBytes = 0; - let lastUpdateTime = 0; - let lastTotalBytes = 0; - - onMount(async () => { - unlistenStart = await listen<number>("download-start", (event) => { - visible = true; - totalFiles = event.payload; - completedFiles = 0; - progress = 0; - totalProgress = 0; - statusText = "Starting download..."; - currentFile = ""; - // Reset speed tracking - startTime = Date.now(); - totalDownloadedBytes = 0; - downloadSpeed = 0; - etaSeconds = 0; - lastUpdateTime = Date.now(); - lastTotalBytes = 0; - }); - - unlistenProgress = await listen<DownloadEvent>( - "download-progress", - (event) => { - const payload = event.payload; - currentFile = payload.file; - - // Current file progress - downloadedBytes = payload.downloaded; - totalBytes = payload.total; - - statusText = payload.status; - - if (payload.total > 0) { - progress = (payload.downloaded / payload.total) * 100; - } - - // Total progress (all files) - completedFiles = payload.completed_files; - totalFiles = payload.total_files; - if (totalFiles > 0) { - const currentFileFraction = - payload.total > 0 ? payload.downloaded / payload.total : 0; - totalProgress = ((completedFiles + currentFileFraction) / totalFiles) * 100; - } - - // Calculate download speed (using moving average) - totalDownloadedBytes = payload.total_downloaded_bytes; - const now = Date.now(); - const timeDiff = (now - lastUpdateTime) / 1000; // seconds - - if (timeDiff >= 0.5) { // Update speed every 0.5 seconds - const bytesDiff = totalDownloadedBytes - lastTotalBytes; - const instantSpeed = bytesDiff / timeDiff; - // Smooth the speed with exponential moving average - downloadSpeed = downloadSpeed === 0 ? instantSpeed : downloadSpeed * 0.7 + instantSpeed * 0.3; - lastUpdateTime = now; - lastTotalBytes = totalDownloadedBytes; - } - - // Estimate remaining time - if (downloadSpeed > 0 && completedFiles < totalFiles) { - const remainingFiles = totalFiles - completedFiles; - let estimatedRemainingBytes: number; - - if (completedFiles > 0) { - // Use average size of completed files to estimate remaining files - const avgBytesPerCompletedFile = totalDownloadedBytes / completedFiles; - estimatedRemainingBytes = avgBytesPerCompletedFile * remainingFiles; - } else { - // No completed files yet: estimate based only on current file's remaining bytes - estimatedRemainingBytes = Math.max(totalBytes - downloadedBytes, 0); - } - etaSeconds = estimatedRemainingBytes / downloadSpeed; - } else { - etaSeconds = 0; - } - } - ); - - unlistenComplete = await listen("download-complete", () => { - statusText = "Done!"; - progress = 100; - totalProgress = 100; - setTimeout(() => { - visible = false; - }, 2000); - }); - }); - - onDestroy(() => { - if (unlistenProgress) unlistenProgress(); - if (unlistenStart) unlistenStart(); - if (unlistenComplete) unlistenComplete(); - }); - - function formatBytes(bytes: number) { - if (bytes === 0) return "0 B"; - const k = 1024; - const sizes = ["B", "KB", "MB", "GB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; - } - - function formatSpeed(bytesPerSecond: number) { - if (bytesPerSecond === 0) return "-- /s"; - return formatBytes(bytesPerSecond) + "/s"; - } - - function formatTime(seconds: number) { - if (seconds <= 0 || !isFinite(seconds)) return "--"; - if (seconds < 60) return `${Math.round(seconds)}s`; - if (seconds < 3600) { - const mins = Math.floor(seconds / 60); - const secs = Math.round(seconds % 60); - return `${mins}m ${secs}s`; - } - const hours = Math.floor(seconds / 3600); - const mins = Math.floor((seconds % 3600) / 60); - return `${hours}h ${mins}m`; - } -</script> - -{#if visible} - <div - class="fixed bottom-28 right-8 z-50 w-80 bg-zinc-900/90 border border-zinc-700 rounded-lg shadow-2xl p-4 animate-in slide-in-from-right-10 fade-in duration-300" - > - <div class="flex items-center justify-between mb-2"> - <h3 class="text-white font-bold text-sm">Downloads</h3> - <span class="text-xs text-zinc-400">{statusText}</span> - </div> - - <!-- Total Progress Bar --> - <div class="mb-3"> - <div class="flex justify-between text-[10px] text-zinc-400 mb-1"> - <span>Total Progress</span> - <span>{completedFiles} / {totalFiles} files</span> - </div> - <div class="w-full bg-zinc-800 rounded-full h-2.5 overflow-hidden"> - <div - class="bg-gradient-to-r from-blue-500 to-cyan-400 h-2.5 rounded-full transition-all duration-200" - style="width: {totalProgress}%" - ></div> - </div> - <div class="flex justify-between text-[10px] text-zinc-500 font-mono mt-0.5"> - <span>{formatSpeed(downloadSpeed)} · ETA: {formatTime(etaSeconds)}</span> - <span>{completedFiles < totalFiles ? Math.floor(totalProgress) : 100}%</span> - </div> - </div> - - <div class="text-xs text-zinc-300 truncate mb-1" title={currentFile}> - {currentFile || "Waiting..."} - </div> - - <!-- Current File Progress Bar --> - <div class="w-full bg-zinc-800 rounded-full h-2 mb-2 overflow-hidden"> - <div - class="bg-gradient-to-r from-green-500 to-emerald-400 h-2 rounded-full transition-all duration-200" - style="width: {progress}%" - ></div> - </div> - - <div class="flex justify-between text-[10px] text-zinc-500 font-mono"> - <span>{formatBytes(downloadedBytes)} / {formatBytes(totalBytes)}</span> - <span>{Math.round(progress)}%</span> - </div> - </div> -{/if} diff --git a/packages/ui/src/lib/GameConsole.svelte b/packages/ui/src/lib/GameConsole.svelte deleted file mode 100644 index bc5edbc..0000000 --- a/packages/ui/src/lib/GameConsole.svelte +++ /dev/null @@ -1,304 +0,0 @@ -<script lang="ts"> - import { logsState, type LogEntry } from "../stores/logs.svelte"; - import { uiState } from "../stores/ui.svelte"; - import { save } from "@tauri-apps/plugin-dialog"; - import { writeTextFile } from "@tauri-apps/plugin-fs"; - import { invoke } from "@tauri-apps/api/core"; - import { open } from "@tauri-apps/plugin-shell"; - import { onMount, tick } from "svelte"; - import CustomSelect from "../components/CustomSelect.svelte"; - import { ChevronDown, Check } from 'lucide-svelte'; - - let consoleElement: HTMLDivElement; - let autoScroll = $state(true); - - // Search & Filter - let searchQuery = $state(""); - let showInfo = $state(true); - let showWarn = $state(true); - let showError = $state(true); - let showDebug = $state(false); - - // Source filter: "all" or specific source name - let selectedSource = $state("all"); - - // Get sorted sources for dropdown - let sourceOptions = $derived([ - { value: "all", label: "All Sources" }, - ...[...logsState.sources].sort().map(s => ({ value: s, label: s })) - ]); - - // Derived filtered logs - let filteredLogs = $derived(logsState.logs.filter((log) => { - // Source Filter - if (selectedSource !== "all" && log.source !== selectedSource) return false; - - // Level Filter - if (!showInfo && log.level === "info") return false; - if (!showWarn && log.level === "warn") return false; - if (!showError && (log.level === "error" || log.level === "fatal")) return false; - if (!showDebug && log.level === "debug") return false; - - // Search Filter - if (searchQuery) { - const q = searchQuery.toLowerCase(); - return ( - log.message.toLowerCase().includes(q) || - log.source.toLowerCase().includes(q) - ); - } - return true; - })); - - // Auto-scroll logic - $effect(() => { - // Depend on filteredLogs length to trigger scroll - if (filteredLogs.length && autoScroll && consoleElement) { - // Use tick to wait for DOM update - tick().then(() => { - consoleElement.scrollTop = consoleElement.scrollHeight; - }); - } - }); - - function handleScroll() { - if (!consoleElement) return; - const { scrollTop, scrollHeight, clientHeight } = consoleElement; - // If user scrolls up (more than 50px from bottom), disable auto-scroll - if (scrollHeight - scrollTop - clientHeight > 50) { - autoScroll = false; - } else { - autoScroll = true; - } - } - - // Export only currently filtered logs - async function exportLogs() { - try { - const content = logsState.exportLogs(filteredLogs); - const path = await save({ - filters: [{ name: "Log File", extensions: ["txt", "log"] }], - defaultPath: `dropout-logs-${new Date().toISOString().replace(/[:.]/g, "-")}.log`, - }); - - if (path) { - await writeTextFile(path, content); - logsState.addLog("info", "Console", `Exported ${filteredLogs.length} logs to ${path}`); - } - } catch (e) { - console.error("Export failed", e); - logsState.addLog("error", "Console", `Export failed: ${e}`); - } - } - - // Upload only currently filtered logs - async function uploadLogs() { - try { - const content = logsState.exportLogs(filteredLogs); - logsState.addLog("info", "Console", `Uploading ${filteredLogs.length} logs...`); - - const response = await invoke<{ url: string }>("upload_to_pastebin", { content }); - - logsState.addLog("info", "Console", `Logs uploaded successfully: ${response.url}`); - await open(response.url); - } catch (e) { - console.error("Upload failed", e); - logsState.addLog("error", "Console", `Upload failed: ${e}`); - } - } - - function highlightText(text: string, query: string) { - if (!query) return text; - // Escape regex special chars in query - const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const parts = text.split(new RegExp(`(${escaped})`, "gi")); - return parts.map(part => - part.toLowerCase() === query.toLowerCase() - ? `<span class="bg-yellow-500/30 text-yellow-200 font-bold">${part}</span>` - : part - ).join(""); - } - - function getLevelColor(level: LogEntry["level"]) { - switch (level) { - case "info": return "text-blue-400"; - case "warn": return "text-yellow-400"; - case "error": - case "fatal": return "text-red-400"; - case "debug": return "text-purple-400"; - default: return "text-zinc-400"; - } - } - - function getLevelLabel(level: LogEntry["level"]) { - switch (level) { - case "info": return "INFO"; - case "warn": return "WARN"; - case "error": return "ERR"; - case "fatal": return "FATAL"; - case "debug": return "DEBUG"; - } - } - - function getMessageColor(log: LogEntry) { - if (log.level === "error" || log.level === "fatal") return "text-red-300"; - if (log.level === "warn") return "text-yellow-200"; - if (log.level === "debug") return "text-purple-200/70"; - if (log.source.startsWith("Game")) return "text-emerald-100/80"; - return ""; - } -</script> - -<div class="absolute inset-0 flex flex-col bg-[#1e1e1e] text-zinc-300 font-mono text-xs overflow-hidden"> - <!-- Toolbar --> - <div class="flex flex-wrap items-center justify-between p-2 bg-[#252526] border-b border-[#3e3e42] gap-2"> - <div class="flex items-center gap-3"> - <h3 class="font-bold text-zinc-100 uppercase tracking-wider px-2">Console</h3> - - <!-- Source Dropdown --> - <CustomSelect - options={sourceOptions} - bind:value={selectedSource} - class="w-36" - /> - - <!-- Level Filters --> - <div class="flex items-center bg-[#1e1e1e] rounded border border-[#3e3e42] overflow-hidden"> - <button - class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showInfo ? 'text-blue-400' : 'text-zinc-600'}" - onclick={() => showInfo = !showInfo} - title="Toggle Info" - >Info</button> - <div class="w-px h-3 bg-[#3e3e42]"></div> - <button - class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showWarn ? 'text-yellow-400' : 'text-zinc-600'}" - onclick={() => showWarn = !showWarn} - title="Toggle Warnings" - >Warn</button> - <div class="w-px h-3 bg-[#3e3e42]"></div> - <button - class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showError ? 'text-red-400' : 'text-zinc-600'}" - onclick={() => showError = !showError} - title="Toggle Errors" - >Error</button> - <div class="w-px h-3 bg-[#3e3e42]"></div> - <button - class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showDebug ? 'text-purple-400' : 'text-zinc-600'}" - onclick={() => showDebug = !showDebug} - title="Toggle Debug" - >Debug</button> - </div> - - <!-- Search --> - <div class="relative group"> - <input - type="text" - bind:value={searchQuery} - placeholder="Find..." - class="bg-[#1e1e1e] border border-[#3e3e42] rounded pl-8 pr-2 py-1 focus:border-indigo-500 focus:outline-none w-40 text-zinc-300 placeholder:text-zinc-600 transition-all focus:w-64" - /> - <svg class="w-3.5 h-3.5 text-zinc-500 absolute left-2.5 top-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> - {#if searchQuery} - <button class="absolute right-2 top-1.5 text-zinc-500 hover:text-white" onclick={() => searchQuery = ""}>✕</button> - {/if} - </div> - </div> - - <!-- Actions --> - <div class="flex items-center gap-2"> - <!-- Log count indicator --> - <span class="text-zinc-500 text-[10px] px-2">{filteredLogs.length} / {logsState.logs.length}</span> - - <button - onclick={() => logsState.clear()} - class="p-1.5 hover:bg-[#3e3e42] rounded text-zinc-400 hover:text-white transition-colors" - title="Clear Logs" - > - <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg> - </button> - <button - onclick={exportLogs} - class="p-1.5 hover:bg-[#3e3e42] rounded text-zinc-400 hover:text-white transition-colors" - title="Export Filtered Logs" - > - <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg> - </button> - <button - onclick={uploadLogs} - class="p-1.5 hover:bg-[#3e3e42] rounded text-zinc-400 hover:text-white transition-colors" - title="Upload Filtered Logs" - > - <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg> - </button> - <div class="w-px h-4 bg-[#3e3e42] mx-1"></div> - <button - onclick={() => uiState.toggleConsole()} - class="p-1.5 hover:bg-red-500/20 hover:text-red-400 rounded text-zinc-400 transition-colors" - title="Close" - > - <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg> - </button> - </div> - </div> - - <!-- Log Area --> - <div - bind:this={consoleElement} - onscroll={handleScroll} - class="flex-1 overflow-y-auto overflow-x-hidden p-2 select-text custom-scrollbar" - > - {#each filteredLogs as log (log.id)} - <div class="flex gap-2 leading-relaxed hover:bg-[#2a2d2e] px-1 rounded-sm group"> - <!-- Timestamp --> - <span class="text-zinc-500 shrink-0 select-none w-20 text-right opacity-50 group-hover:opacity-100">{log.timestamp.split('.')[0]}</span> - - <!-- Source & Level --> - <div class="flex shrink-0 min-w-[140px] gap-1 justify-end font-bold select-none truncate"> - <span class="text-zinc-500 truncate max-w-[90px]" title={log.source}>{log.source}</span> - <span class={getLevelColor(log.level)}>{getLevelLabel(log.level)}</span> - </div> - - <!-- Message --> - <div class="flex-1 break-all whitespace-pre-wrap text-zinc-300 min-w-0 {getMessageColor(log)}"> - {@html highlightText(log.message, searchQuery)} - </div> - </div> - {/each} - - {#if filteredLogs.length === 0} - <div class="text-center text-zinc-600 mt-10 italic select-none"> - {#if logsState.logs.length === 0} - Waiting for logs... - {:else} - No logs match current filters. - {/if} - </div> - {/if} - </div> - - <!-- Auto-scroll status --> - {#if !autoScroll} - <button - onclick={() => { autoScroll = true; consoleElement.scrollTop = consoleElement.scrollHeight; }} - class="absolute bottom-4 right-6 bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-1.5 rounded shadow-lg text-xs font-bold transition-all animate-bounce" - > - Resume Auto-scroll ⬇ - </button> - {/if} -</div> - -<style> - /* Custom Scrollbar for the log area */ - .custom-scrollbar::-webkit-scrollbar { - width: 10px; - background-color: #1e1e1e; - } - .custom-scrollbar::-webkit-scrollbar-thumb { - background-color: #424242; - border: 2px solid #1e1e1e; /* padding around thumb */ - border-radius: 0; - } - .custom-scrollbar::-webkit-scrollbar-thumb:hover { - background-color: #4f4f4f; - } -</style> diff --git a/packages/ui/src/lib/effects/ConstellationEffect.ts b/packages/ui/src/lib/effects/ConstellationEffect.ts deleted file mode 100644 index d2db529..0000000 --- a/packages/ui/src/lib/effects/ConstellationEffect.ts +++ /dev/null @@ -1,162 +0,0 @@ -export class ConstellationEffect { - private canvas: HTMLCanvasElement; - private ctx: CanvasRenderingContext2D; - private width: number = 0; - private height: number = 0; - private particles: Particle[] = []; - private animationId: number = 0; - private mouseX: number = -1000; - private mouseY: number = -1000; - - // Configuration - private readonly particleCount = 100; - private readonly connectionDistance = 150; - private readonly particleSpeed = 0.5; - - constructor(canvas: HTMLCanvasElement) { - this.canvas = canvas; - this.ctx = canvas.getContext("2d", { alpha: true })!; - - // Bind methods - this.animate = this.animate.bind(this); - this.handleMouseMove = this.handleMouseMove.bind(this); - - // Initial setup - this.resize(window.innerWidth, window.innerHeight); - this.initParticles(); - - // Mouse interaction - window.addEventListener("mousemove", this.handleMouseMove); - - // Start animation - this.animate(); - } - - resize(width: number, height: number) { - const dpr = window.devicePixelRatio || 1; - this.width = width; - this.height = height; - - this.canvas.width = width * dpr; - this.canvas.height = height * dpr; - this.canvas.style.width = `${width}px`; - this.canvas.style.height = `${height}px`; - - this.ctx.scale(dpr, dpr); - - // Re-initialize if screen size changes significantly to maintain density - if (this.particles.length === 0) { - this.initParticles(); - } - } - - private initParticles() { - this.particles = []; - // Adjust density based on screen area - const area = this.width * this.height; - const density = Math.floor(area / 15000); // 1 particle per 15000px² - const count = Math.min(Math.max(density, 50), 200); // Clamp between 50 and 200 - - for (let i = 0; i < count; i++) { - this.particles.push(new Particle(this.width, this.height, this.particleSpeed)); - } - } - - private handleMouseMove(e: MouseEvent) { - const rect = this.canvas.getBoundingClientRect(); - this.mouseX = e.clientX - rect.left; - this.mouseY = e.clientY - rect.top; - } - - animate() { - this.ctx.clearRect(0, 0, this.width, this.height); - - // Update and draw particles - this.particles.forEach((p) => { - p.update(this.width, this.height); - p.draw(this.ctx); - }); - - // Draw lines - this.drawConnections(); - - this.animationId = requestAnimationFrame(this.animate); - } - - private drawConnections() { - this.ctx.lineWidth = 1; - - for (let i = 0; i < this.particles.length; i++) { - const p1 = this.particles[i]; - - // Connect to mouse if close - const distMouse = Math.hypot(p1.x - this.mouseX, p1.y - this.mouseY); - if (distMouse < this.connectionDistance + 50) { - const alpha = 1 - distMouse / (this.connectionDistance + 50); - this.ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * 0.4})`; // Brighter near mouse - this.ctx.beginPath(); - this.ctx.moveTo(p1.x, p1.y); - this.ctx.lineTo(this.mouseX, this.mouseY); - this.ctx.stroke(); - - // Gently attract to mouse - if (distMouse > 10) { - p1.x += (this.mouseX - p1.x) * 0.005; - p1.y += (this.mouseY - p1.y) * 0.005; - } - } - - // Connect to other particles - for (let j = i + 1; j < this.particles.length; j++) { - const p2 = this.particles[j]; - const dist = Math.hypot(p1.x - p2.x, p1.y - p2.y); - - if (dist < this.connectionDistance) { - const alpha = 1 - dist / this.connectionDistance; - this.ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * 0.15})`; - this.ctx.beginPath(); - this.ctx.moveTo(p1.x, p1.y); - this.ctx.lineTo(p2.x, p2.y); - this.ctx.stroke(); - } - } - } - } - - destroy() { - cancelAnimationFrame(this.animationId); - window.removeEventListener("mousemove", this.handleMouseMove); - } -} - -class Particle { - x: number; - y: number; - vx: number; - vy: number; - size: number; - - constructor(w: number, h: number, speed: number) { - this.x = Math.random() * w; - this.y = Math.random() * h; - this.vx = (Math.random() - 0.5) * speed; - this.vy = (Math.random() - 0.5) * speed; - this.size = Math.random() * 2 + 1; - } - - update(w: number, h: number) { - this.x += this.vx; - this.y += this.vy; - - // Bounce off walls - if (this.x < 0 || this.x > w) this.vx *= -1; - if (this.y < 0 || this.y > h) this.vy *= -1; - } - - draw(ctx: CanvasRenderingContext2D) { - ctx.fillStyle = "rgba(255, 255, 255, 0.4)"; - ctx.beginPath(); - ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); - ctx.fill(); - } -} diff --git a/packages/ui/src/lib/effects/SaturnEffect.ts b/packages/ui/src/lib/effects/SaturnEffect.ts index 357da9d..497a340 100644 --- a/packages/ui/src/lib/effects/SaturnEffect.ts +++ b/packages/ui/src/lib/effects/SaturnEffect.ts @@ -1,46 +1,61 @@ -// Optimized Saturn Effect for low-end hardware -// Uses TypedArrays for memory efficiency and reduced particle density +/** + * Ported SaturnEffect for the React UI (ui-new). + * Adapted from the original Svelte implementation but written as a standalone + * TypeScript class that manages a 2D canvas particle effect resembling a + * rotating "Saturn" with rings. Designed to be instantiated and controlled + * from a React component (e.g. ParticleBackground). + * + * Usage: + * const effect = new SaturnEffect(canvasElement); + * effect.handleMouseDown(clientX); + * effect.handleMouseMove(clientX); + * effect.handleMouseUp(); + * // on resize: + * effect.resize(width, height); + * // on unmount: + * effect.destroy(); + */ export class SaturnEffect { private canvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; - private width: number = 0; - private height: number = 0; - - // Data-oriented design for performance - // xyz: Float32Array where [i*3, i*3+1, i*3+2] corresponds to x, y, z - private xyz: Float32Array | null = null; - // types: Uint8Array where 0 = planet, 1 = ring - private types: Uint8Array | null = null; - private count: number = 0; - - private animationId: number = 0; - private angle: number = 0; - private scaleFactor: number = 1; - - // Mouse interaction properties - private isDragging: boolean = false; - private lastMouseX: number = 0; - private lastMouseTime: number = 0; - private mouseVelocities: number[] = []; // Store recent velocities for averaging - - // Rotation speed control - private readonly baseSpeed: number = 0.005; // Original rotation speed - private currentSpeed: number = 0.005; // Current rotation speed (can be modified by mouse) - private rotationDirection: number = 1; // 1 for clockwise, -1 for counter-clockwise - private readonly speedDecayRate: number = 0.992; // How fast speed returns to normal (closer to 1 = slower decay) - private readonly minSpeedMultiplier: number = 1; // Minimum speed is baseSpeed - private readonly maxSpeedMultiplier: number = 50; // Maximum speed is 50x baseSpeed - private isStopped: boolean = false; // Whether the user has stopped the rotation + private width = 0; + private height = 0; + + // Particle storage + private xyz: Float32Array | null = null; // interleaved x,y,z + private types: Uint8Array | null = null; // 0 = planet, 1 = ring + private count = 0; + + // Animation + private animationId = 0; + private angle = 0; + private scaleFactor = 1; + + // Interaction + private isDragging = false; + private lastMouseX = 0; + private lastMouseTime = 0; + private mouseVelocities: number[] = []; + + // Speed control + private readonly baseSpeed = 0.005; + private currentSpeed = 0.005; + private rotationDirection = 1; + private readonly speedDecayRate = 0.992; + private readonly minSpeedMultiplier = 1; + private readonly maxSpeedMultiplier = 50; + private isStopped = false; constructor(canvas: HTMLCanvasElement) { this.canvas = canvas; - this.ctx = canvas.getContext("2d", { - alpha: true, - desynchronized: false, // default is usually fine, 'desynchronized' can help latency but might flicker - })!; + const ctx = canvas.getContext("2d", { alpha: true, desynchronized: false }); + if (!ctx) { + throw new Error("Failed to get 2D context for SaturnEffect"); + } + this.ctx = ctx; - // Initial resize will set up everything + // Initialize size & particles this.resize(window.innerWidth, window.innerHeight); this.initParticles(); @@ -48,9 +63,7 @@ export class SaturnEffect { this.animate(); } - // Public methods for external mouse event handling - // These can be called from any element that wants to control the Saturn rotation - + // External interaction handlers (accept clientX) handleMouseDown(clientX: number) { this.isDragging = true; this.lastMouseX = clientX; @@ -60,26 +73,18 @@ export class SaturnEffect { handleMouseMove(clientX: number) { if (!this.isDragging) return; - - const currentTime = performance.now(); - const deltaTime = currentTime - this.lastMouseTime; - - if (deltaTime > 0) { - const deltaX = clientX - this.lastMouseX; - const velocity = deltaX / deltaTime; // pixels per millisecond - - // Store recent velocities (keep last 5 for smoothing) + const now = performance.now(); + const dt = now - this.lastMouseTime; + if (dt > 0) { + const dx = clientX - this.lastMouseX; + const velocity = dx / dt; this.mouseVelocities.push(velocity); - if (this.mouseVelocities.length > 5) { - this.mouseVelocities.shift(); - } - - // Apply direct rotation while dragging - this.angle += deltaX * 0.002; + if (this.mouseVelocities.length > 5) this.mouseVelocities.shift(); + // Rotate directly while dragging for immediate feedback + this.angle += dx * 0.002; } - this.lastMouseX = clientX; - this.lastMouseTime = currentTime; + this.lastMouseTime = now; } handleMouseUp() { @@ -90,174 +95,130 @@ export class SaturnEffect { } handleTouchStart(clientX: number) { - this.isDragging = true; - this.lastMouseX = clientX; - this.lastMouseTime = performance.now(); - this.mouseVelocities = []; + this.handleMouseDown(clientX); } handleTouchMove(clientX: number) { - if (!this.isDragging) return; - - const currentTime = performance.now(); - const deltaTime = currentTime - this.lastMouseTime; - - if (deltaTime > 0) { - const deltaX = clientX - this.lastMouseX; - const velocity = deltaX / deltaTime; - - this.mouseVelocities.push(velocity); - if (this.mouseVelocities.length > 5) { - this.mouseVelocities.shift(); - } - - this.angle += deltaX * 0.002; - } - - this.lastMouseX = clientX; - this.lastMouseTime = currentTime; + this.handleMouseMove(clientX); } handleTouchEnd() { - if (this.isDragging && this.mouseVelocities.length > 0) { - this.applyFlingVelocity(); - } - this.isDragging = false; - } - - private applyFlingVelocity() { - // Calculate average velocity from recent samples - const avgVelocity = - this.mouseVelocities.reduce((a, b) => a + b, 0) / this.mouseVelocities.length; - - // Threshold for considering it a "fling" (pixels per millisecond) - const flingThreshold = 0.3; - // Threshold for considering the rotation as "stopped" by user - const stopThreshold = 0.1; - - if (Math.abs(avgVelocity) > flingThreshold) { - // User flung it - start rotating again - this.isStopped = false; - - // Determine new direction based on fling direction - const newDirection = avgVelocity > 0 ? 1 : -1; - - // If direction changed, update it permanently - if (newDirection !== this.rotationDirection) { - this.rotationDirection = newDirection; - } - - // Calculate speed boost based on fling strength - // Map velocity to speed multiplier (stronger fling = faster rotation) - const speedMultiplier = Math.min( - this.maxSpeedMultiplier, - this.minSpeedMultiplier + Math.abs(avgVelocity) * 10, - ); - - this.currentSpeed = this.baseSpeed * speedMultiplier; - } else if (Math.abs(avgVelocity) < stopThreshold) { - // User gently released - keep it stopped - this.isStopped = true; - this.currentSpeed = 0; - } - // If velocity is between stopThreshold and flingThreshold, - // keep current state (don't change isStopped) + this.handleMouseUp(); } + // Resize canvas & scale (call on window resize) resize(width: number, height: number) { const dpr = window.devicePixelRatio || 1; this.width = width; this.height = height; - this.canvas.width = width * dpr; - this.canvas.height = height * dpr; + // Update canvas pixel size and CSS size + this.canvas.width = Math.max(1, Math.floor(width * dpr)); + this.canvas.height = Math.max(1, Math.floor(height * dpr)); this.canvas.style.width = `${width}px`; this.canvas.style.height = `${height}px`; + // Reset transform and scale for devicePixelRatio + this.ctx.setTransform(1, 0, 0, 1, 0, 0); // reset this.ctx.scale(dpr, dpr); - // Dynamic scaling based on screen size const minDim = Math.min(width, height); - this.scaleFactor = minDim * 0.45; + this.scaleFactor = Math.max(1, minDim * 0.45); } - initParticles() { - // Significantly reduced particle count for CPU optimization - // Planet: 1800 -> 1000 - // Rings: 5000 -> 2500 - // Total approx 3500 vs 6800 previously (approx 50% reduction) + // Initialize particle arrays with reduced counts to keep performance reasonable + private initParticles() { + // Tuned particle counts for reasonable performance across platforms const planetCount = 1000; const ringCount = 2500; this.count = planetCount + ringCount; - // Use TypedArrays for better memory locality this.xyz = new Float32Array(this.count * 3); this.types = new Uint8Array(this.count); let idx = 0; - // 1. Planet - for (let i = 0; i < planetCount; i++) { + // Planet points + for (let i = 0; i < planetCount; i++, idx++) { const theta = Math.random() * Math.PI * 2; const phi = Math.acos(Math.random() * 2 - 1); const r = 1.0; - // x, y, z this.xyz[idx * 3] = r * Math.sin(phi) * Math.cos(theta); this.xyz[idx * 3 + 1] = r * Math.sin(phi) * Math.sin(theta); this.xyz[idx * 3 + 2] = r * Math.cos(phi); - this.types[idx] = 0; // 0 for planet - idx++; + this.types[idx] = 0; } - // 2. Rings + // Ring points const ringInner = 1.4; const ringOuter = 2.3; - - for (let i = 0; i < ringCount; i++) { + for (let i = 0; i < ringCount; i++, idx++) { const angle = Math.random() * Math.PI * 2; const dist = Math.sqrt( - Math.random() * (ringOuter * ringOuter - ringInner * ringInner) + ringInner * ringInner, + Math.random() * (ringOuter * ringOuter - ringInner * ringInner) + + ringInner * ringInner, ); - // x, y, z this.xyz[idx * 3] = dist * Math.cos(angle); this.xyz[idx * 3 + 1] = (Math.random() - 0.5) * 0.05; this.xyz[idx * 3 + 2] = dist * Math.sin(angle); - this.types[idx] = 1; // 1 for ring - idx++; + this.types[idx] = 1; } } - animate() { + // Map fling/velocity samples to a rotation speed and direction + private applyFlingVelocity() { + if (this.mouseVelocities.length === 0) return; + const avg = + this.mouseVelocities.reduce((a, b) => a + b, 0) / + this.mouseVelocities.length; + const flingThreshold = 0.3; + const stopThreshold = 0.1; + + if (Math.abs(avg) > flingThreshold) { + this.isStopped = false; + const newDir = avg > 0 ? 1 : -1; + if (newDir !== this.rotationDirection) this.rotationDirection = newDir; + const multiplier = Math.min( + this.maxSpeedMultiplier, + this.minSpeedMultiplier + Math.abs(avg) * 10, + ); + this.currentSpeed = this.baseSpeed * multiplier; + } else if (Math.abs(avg) < stopThreshold) { + this.isStopped = true; + this.currentSpeed = 0; + } + } + + // Main render loop + private animate() { + // Clear with full alpha to allow layering over background this.ctx.clearRect(0, 0, this.width, this.height); - // Normal blending + // Standard composition this.ctx.globalCompositeOperation = "source-over"; - // Update rotation speed - decay towards base speed while maintaining direction + // Update rotation speed (decay) if (!this.isDragging && !this.isStopped) { if (this.currentSpeed > this.baseSpeed) { - // Gradually decay speed back to base speed this.currentSpeed = - this.baseSpeed + (this.currentSpeed - this.baseSpeed) * this.speedDecayRate; - - // Snap to base speed when close enough + this.baseSpeed + + (this.currentSpeed - this.baseSpeed) * this.speedDecayRate; if (this.currentSpeed - this.baseSpeed < 0.00001) { this.currentSpeed = this.baseSpeed; } } - - // Apply rotation with current speed and direction this.angle += this.currentSpeed * this.rotationDirection; } + // Center positions const cx = this.width * 0.6; const cy = this.height * 0.5; - // Pre-calculate rotation matrices + // Pre-calc rotations const rotationY = this.angle; const rotationX = 0.4; const rotationZ = 0.15; @@ -272,29 +233,27 @@ export class SaturnEffect { const fov = 1500; const scaleFactor = this.scaleFactor; - if (!this.xyz || !this.types) return; + if (!this.xyz || !this.types) { + this.animationId = requestAnimationFrame(this.animate); + return; + } + // Loop particles for (let i = 0; i < this.count; i++) { const x = this.xyz[i * 3]; const y = this.xyz[i * 3 + 1]; const z = this.xyz[i * 3 + 2]; - // Apply Scale + // Scale to screen const px = x * scaleFactor; const py = y * scaleFactor; const pz = z * scaleFactor; - // 1. Rotate Y + // Rotate Y then X then Z const x1 = px * cosY - pz * sinY; const z1 = pz * cosY + px * sinY; - // y1 = py - - // 2. Rotate X const y2 = py * cosX - z1 * sinX; const z2 = z1 * cosX + py * sinX; - // x2 = x1 - - // 3. Rotate Z const x3 = x1 * cosZ - y2 * sinZ; const y3 = y2 * cosZ + x1 * sinZ; const z3 = z2; @@ -305,28 +264,23 @@ export class SaturnEffect { const x2d = cx + x3 * scale; const y2d = cy + y3 * scale; - // Size calculation - slightly larger dots to compensate for lower count - // Previously Planet 2.0 -> 2.4, Ring 1.3 -> 1.5 const type = this.types[i]; const sizeBase = type === 0 ? 2.4 : 1.5; const size = sizeBase * scale; - // Opacity let alpha = scale * scale * scale; if (alpha > 1) alpha = 1; - if (alpha < 0.15) continue; // Skip very faint particles for performance + if (alpha < 0.15) continue; - // Optimization: Planet color vs Ring color if (type === 0) { - // Planet: Warm White + // Planet: warm-ish this.ctx.fillStyle = `rgba(255, 240, 220, ${alpha})`; } else { - // Ring: Cool White + // Ring: cool-ish this.ctx.fillStyle = `rgba(220, 240, 255, ${alpha})`; } - // Render as squares (fillRect) instead of circles (arc) - // This is significantly faster for software rendering and reduces GPU usage. + // Render as small rectangles (faster than arc) this.ctx.fillRect(x2d, y2d, size, size); } } @@ -334,7 +288,12 @@ export class SaturnEffect { this.animationId = requestAnimationFrame(this.animate); } + // Stop animations and release resources destroy() { - cancelAnimationFrame(this.animationId); + if (this.animationId) { + cancelAnimationFrame(this.animationId); + this.animationId = 0; + } + // Intentionally do not null out arrays to allow reuse if desired. } } diff --git a/packages/ui/src/lib/modLoaderApi.ts b/packages/ui/src/lib/modLoaderApi.ts deleted file mode 100644 index 75f404a..0000000 --- a/packages/ui/src/lib/modLoaderApi.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Mod Loader API service for Fabric and Forge integration. - * This module provides functions to interact with the Tauri backend - * for mod loader version management. - */ - -import { invoke } from "@tauri-apps/api/core"; -import type { - FabricGameVersion, - FabricLoaderVersion, - FabricLoaderEntry, - InstalledFabricVersion, - ForgeVersion, - InstalledForgeVersion, -} from "../types"; - -// ==================== Fabric API ==================== - -/** - * Get all Minecraft versions supported by Fabric. - */ -export async function getFabricGameVersions(): Promise<FabricGameVersion[]> { - return invoke<FabricGameVersion[]>("get_fabric_game_versions"); -} - -/** - * Get all available Fabric loader versions. - */ -export async function getFabricLoaderVersions(): Promise<FabricLoaderVersion[]> { - return invoke<FabricLoaderVersion[]>("get_fabric_loader_versions"); -} - -/** - * Get Fabric loaders available for a specific Minecraft version. - */ -export async function getFabricLoadersForVersion( - gameVersion: string, -): Promise<FabricLoaderEntry[]> { - return invoke<FabricLoaderEntry[]>("get_fabric_loaders_for_version", { - gameVersion, - }); -} - -/** - * Install Fabric loader for a specific Minecraft version. - */ -export async function installFabric( - gameVersion: string, - loaderVersion: string, -): Promise<InstalledFabricVersion> { - return invoke<InstalledFabricVersion>("install_fabric", { - gameVersion, - loaderVersion, - }); -} - -/** - * List all installed Fabric versions. - */ -export async function listInstalledFabricVersions(): Promise<string[]> { - return invoke<string[]>("list_installed_fabric_versions"); -} - -/** - * Check if Fabric is installed for a specific version combination. - */ -export async function isFabricInstalled( - gameVersion: string, - loaderVersion: string, -): Promise<boolean> { - return invoke<boolean>("is_fabric_installed", { - gameVersion, - loaderVersion, - }); -} - -// ==================== Forge API ==================== - -/** - * Get all Minecraft versions supported by Forge. - */ -export async function getForgeGameVersions(): Promise<string[]> { - return invoke<string[]>("get_forge_game_versions"); -} - -/** - * Get Forge versions available for a specific Minecraft version. - */ -export async function getForgeVersionsForGame(gameVersion: string): Promise<ForgeVersion[]> { - return invoke<ForgeVersion[]>("get_forge_versions_for_game", { - gameVersion, - }); -} - -/** - * Install Forge for a specific Minecraft version. - */ -export async function installForge( - gameVersion: string, - forgeVersion: string, -): Promise<InstalledForgeVersion> { - return invoke<InstalledForgeVersion>("install_forge", { - gameVersion, - forgeVersion, - }); -} diff --git a/packages/ui/src/lib/tsrs-utils.ts b/packages/ui/src/lib/tsrs-utils.ts new file mode 100644 index 0000000..f48f851 --- /dev/null +++ b/packages/ui/src/lib/tsrs-utils.ts @@ -0,0 +1,67 @@ +export type Maybe<T> = T | null | undefined; + +export function toNumber( + value: Maybe<number | bigint | string>, + fallback = 0, +): number { + if (value === null || value === undefined) return fallback; + + if (typeof value === "number") { + if (Number.isFinite(value)) return value; + return fallback; + } + + if (typeof value === "bigint") { + // safe conversion for typical values (timestamps, sizes). Might overflow for huge bigint. + return Number(value); + } + + if (typeof value === "string") { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; + } + + return fallback; +} + +/** + * Like `toNumber` but ensures non-negative result (clamps at 0). + */ +export function toNonNegativeNumber( + value: Maybe<number | bigint | string>, + fallback = 0, +): number { + const n = toNumber(value, fallback); + return n < 0 ? 0 : n; +} + +export function toDate( + value: Maybe<number | bigint | string>, + opts?: { isSeconds?: boolean }, +): Date | null { + if (value === null || value === undefined) return null; + + const isSeconds = opts?.isSeconds ?? true; + + // accept bigint, number, numeric string + const n = toNumber(value, NaN); + if (Number.isNaN(n)) return null; + + const ms = isSeconds ? Math.floor(n) * 1000 : Math.floor(n); + return new Date(ms); +} + +/** + * Convert a binding boolean-ish value (0/1, "true"/"false", boolean) to boolean. + */ +export function toBoolean(value: unknown, fallback = false): boolean { + if (value === null || value === undefined) return fallback; + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value === "string") { + const s = value.toLowerCase().trim(); + if (s === "true" || s === "1") return true; + if (s === "false" || s === "0") return false; + } + return fallback; +} diff --git a/packages/ui/src/lib/utils.ts b/packages/ui/src/lib/utils.ts new file mode 100644 index 0000000..365058c --- /dev/null +++ b/packages/ui/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} |