aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/ui/src/lib
diff options
context:
space:
mode:
author简律纯 <i@jyunko.cn>2026-01-20 13:44:21 +0800
committerGitHub <noreply@github.com>2026-01-20 13:44:21 +0800
commit7338b8627833018a0e3c05c3c4f3cc7fc182e8d2 (patch)
tree23e39e5603aadc10d6b2d7efad063c68f357a860 /packages/ui/src/lib
parentdbf781a35b96252e0199fec4337515651e49a8f6 (diff)
parentcf6cd6ba667b35e352b705946420cf4acab2b004 (diff)
downloadDropOut-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.svelte10
-rw-r--r--packages/ui/src/lib/DownloadMonitor.svelte201
-rw-r--r--packages/ui/src/lib/GameConsole.svelte304
-rw-r--r--packages/ui/src/lib/effects/ConstellationEffect.ts162
-rw-r--r--packages/ui/src/lib/effects/SaturnEffect.ts340
-rw-r--r--packages/ui/src/lib/modLoaderApi.ts106
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,
+ });
+}