diff options
| author | 2026-01-20 13:44:21 +0800 | |
|---|---|---|
| committer | 2026-01-20 13:44:21 +0800 | |
| commit | 7338b8627833018a0e3c05c3c4f3cc7fc182e8d2 (patch) | |
| tree | 23e39e5603aadc10d6b2d7efad063c68f357a860 /packages/ui/src/lib | |
| parent | dbf781a35b96252e0199fec4337515651e49a8f6 (diff) | |
| parent | cf6cd6ba667b35e352b705946420cf4acab2b004 (diff) | |
| download | DropOut-7338b8627833018a0e3c05c3c4f3cc7fc182e8d2.tar.gz DropOut-7338b8627833018a0e3c05c3c4f3cc7fc182e8d2.zip | |
[Chore] branch: Sync with main (#75)
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 | 340 | ||||
| -rw-r--r-- | packages/ui/src/lib/modLoaderApi.ts | 106 |
6 files changed, 1123 insertions, 0 deletions
diff --git a/packages/ui/src/lib/Counter.svelte b/packages/ui/src/lib/Counter.svelte new file mode 100644 index 0000000..37d75ce --- /dev/null +++ b/packages/ui/src/lib/Counter.svelte @@ -0,0 +1,10 @@ +<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 new file mode 100644 index 0000000..860952c --- /dev/null +++ b/packages/ui/src/lib/DownloadMonitor.svelte @@ -0,0 +1,201 @@ +<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 new file mode 100644 index 0000000..bc5edbc --- /dev/null +++ b/packages/ui/src/lib/GameConsole.svelte @@ -0,0 +1,304 @@ +<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 new file mode 100644 index 0000000..d2db529 --- /dev/null +++ b/packages/ui/src/lib/effects/ConstellationEffect.ts @@ -0,0 +1,162 @@ +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 new file mode 100644 index 0000000..357da9d --- /dev/null +++ b/packages/ui/src/lib/effects/SaturnEffect.ts @@ -0,0 +1,340 @@ +// Optimized Saturn Effect for low-end hardware +// Uses TypedArrays for memory efficiency and reduced particle density + +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 + + 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 + })!; + + // Initial resize will set up everything + this.resize(window.innerWidth, window.innerHeight); + this.initParticles(); + + this.animate = this.animate.bind(this); + this.animate(); + } + + // Public methods for external mouse event handling + // These can be called from any element that wants to control the Saturn rotation + + handleMouseDown(clientX: number) { + this.isDragging = true; + this.lastMouseX = clientX; + this.lastMouseTime = performance.now(); + this.mouseVelocities = []; + } + + 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) + this.mouseVelocities.push(velocity); + if (this.mouseVelocities.length > 5) { + this.mouseVelocities.shift(); + } + + // Apply direct rotation while dragging + this.angle += deltaX * 0.002; + } + + this.lastMouseX = clientX; + this.lastMouseTime = currentTime; + } + + handleMouseUp() { + if (this.isDragging && this.mouseVelocities.length > 0) { + this.applyFlingVelocity(); + } + this.isDragging = false; + } + + handleTouchStart(clientX: number) { + this.isDragging = true; + this.lastMouseX = clientX; + this.lastMouseTime = performance.now(); + this.mouseVelocities = []; + } + + 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; + } + + 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) + } + + 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); + + // Dynamic scaling based on screen size + const minDim = Math.min(width, height); + this.scaleFactor = 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) + 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++) { + 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++; + } + + // 2. Rings + const ringInner = 1.4; + const ringOuter = 2.3; + + for (let i = 0; i < ringCount; i++) { + const angle = Math.random() * Math.PI * 2; + const dist = Math.sqrt( + 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++; + } + } + + animate() { + this.ctx.clearRect(0, 0, this.width, this.height); + + // Normal blending + this.ctx.globalCompositeOperation = "source-over"; + + // Update rotation speed - decay towards base speed while maintaining direction + 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 + if (this.currentSpeed - this.baseSpeed < 0.00001) { + this.currentSpeed = this.baseSpeed; + } + } + + // Apply rotation with current speed and direction + this.angle += this.currentSpeed * this.rotationDirection; + } + + const cx = this.width * 0.6; + const cy = this.height * 0.5; + + // Pre-calculate rotation matrices + const rotationY = this.angle; + const rotationX = 0.4; + const rotationZ = 0.15; + + const sinY = Math.sin(rotationY); + const cosY = Math.cos(rotationY); + const sinX = Math.sin(rotationX); + const cosX = Math.cos(rotationX); + const sinZ = Math.sin(rotationZ); + const cosZ = Math.cos(rotationZ); + + const fov = 1500; + const scaleFactor = this.scaleFactor; + + if (!this.xyz || !this.types) return; + + 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 + const px = x * scaleFactor; + const py = y * scaleFactor; + const pz = z * scaleFactor; + + // 1. Rotate Y + 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; + + const scale = fov / (fov + z3); + + if (z3 > -fov) { + 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 + + // Optimization: Planet color vs Ring color + if (type === 0) { + // Planet: Warm White + this.ctx.fillStyle = `rgba(255, 240, 220, ${alpha})`; + } else { + // Ring: Cool White + 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. + this.ctx.fillRect(x2d, y2d, size, size); + } + } + + this.animationId = requestAnimationFrame(this.animate); + } + + destroy() { + cancelAnimationFrame(this.animationId); + } +} diff --git a/packages/ui/src/lib/modLoaderApi.ts b/packages/ui/src/lib/modLoaderApi.ts new file mode 100644 index 0000000..75f404a --- /dev/null +++ b/packages/ui/src/lib/modLoaderApi.ts @@ -0,0 +1,106 @@ +/** + * 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, + }); +} |