diff --git a/README.md b/README.md index 8307320..86a3cb4 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ It enhances the osu! experience with quality-of-life features, advanced launcher - [x] **Live Player Stats**: Displays rank, PP, accuracy, playcount, and server latency directly on the launcher dashboard. - [x] **Library Indexing**: Automatically counts imported beatmapsets and skins. - [x] **OpenTabletDriver Integration**: Automatically starts and stops the OpenTabletDriver daemon on launch/exit (Windows only). +- [x] **Competitive Game Optimization Mode**: Optional low-latency engine setting that boosts osu! process priority to High and requests sub-millisecond Windows timer resolution (1ms) during gameplay. - [x] **Performance Display Overlay**: In-game HUD overlay (available in the experimental patcher stream). ### Visual Theme Engine v2 & In-App Studio @@ -32,6 +33,7 @@ It enhances the osu! experience with quality-of-life features, advanced launcher - [x] **Logic & Scripting System**: Visual WHEN/IF/THEN rule builder and sandboxed rule scripting (expressions, branches, loops, visibility toggles) without `eval`. - [x] **Dynamic Placeholders & Clock Expressions**: Real-time variables (`{username}`, `{rank}`, `{pp}`) and second-accurate clock variables (`{time}`, `{hour}`, `{minute}`). - [x] **Typography & Icon Families**: Gradient fills, outlines, drop shadows, custom `@fontsource` webfonts, and bundled Lucide, Phosphor, and Tabler icon catalogs. +- [x] **Dynamic Particle Emitters & 3D Cursor Parallax**: Configurable background particles (snow, stars, petals, bubbles, and floating embers) plus smooth cursor-driven 3D parallax depth for layers and backgrounds. - [x] **Backup & Rollback System**: Automatic versioned theme snapshots with one-click restoration, plus security trust prompts for executable components. ### Discord Rich Presence Studio diff --git a/THEME_API_V2.md b/THEME_API_V2.md index c8663e2..92ad8f3 100644 --- a/THEME_API_V2.md +++ b/THEME_API_V2.md @@ -196,12 +196,24 @@ clamped to `0`–`1`. { "type": "stars", "density": 40, "speed": 1, "color": "#ffd6f2" } ``` -Supported types: `none`, `stars`, `snow`, `petals`, `bubbles`. +Supported types: `none`, `stars`, `snow`, `petals`, `bubbles`, `embers`. - `density` is clamped to `0`–`120` particles. - `speed` is clamped to `0.2`–`3`. - Effects are disabled when the user turns on **Reduce animations**. +### Parallax + +Themes can enable subtle 3D cursor parallax on the background and freeform layers: + +```json +"parallax": { "enabled": true, "intensity": 0.25 } +``` + +- `intensity` is clamped to `0.05`–`1.0` (defaults to `0.25`). +- Individual layers and groups can define their own `parallax` depth factor from `-2.0` to `+2.0` in the inspector (where negative shifts with background and positive shifts with foreground). +- Parallax is automatically disabled when the user turns on **Reduce animations**. + ### Audio ```json diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index af80713..e49512d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -559,12 +559,17 @@ pub async fn run_osu_updater(folder: String) -> Result<(), String> { } #[tauri::command] -pub async fn run_osu(folder: String, patch: bool) -> Result<(), String> { - log::info!(target: "osu", "Launching osu! from {folder} (patch={patch})"); +pub async fn run_osu( + folder: String, + patch: bool, + game_optimization: Option, +) -> Result<(), String> { + let optimize = game_optimization.unwrap_or(false); + log::info!(target: "osu", "Launching osu! from {folder} (patch={patch}, optimize={optimize})"); #[cfg(target_os = "macos")] { - return crate::macos::run_osu(&folder, patch).await; + return crate::macos::run_osu(&folder, patch, optimize).await; } #[cfg(not(target_os = "macos"))] @@ -574,6 +579,18 @@ pub async fn run_osu(folder: String, patch: bool) -> Result<(), String> { #[cfg(windows)] const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; + #[cfg(windows)] + if optimize { + #[link(name = "winmm")] + extern "system" { + fn timeBeginPeriod(uPeriod: u32) -> u32; + } + unsafe { + timeBeginPeriod(1); + } + log::info!(target: "osu", "Game optimization: requested 1ms timer resolution"); + } + let mut game_process = { #[cfg(windows)] { @@ -611,6 +628,22 @@ pub async fn run_osu(folder: String, patch: bool) -> Result<(), String> { if let Some(pid) = game_process.id() { log::info!(target: "osu", "osu! started (pid {pid})"); + + #[cfg(windows)] + if optimize { + unsafe { + use winapi::um::processthreadsapi::{OpenProcess, SetPriorityClass}; + use winapi::um::winbase::HIGH_PRIORITY_CLASS; + use winapi::um::winnt::PROCESS_SET_INFORMATION; + let handle = OpenProcess(PROCESS_SET_INFORMATION, 0, pid); + if !handle.is_null() { + if SetPriorityClass(handle, HIGH_PRIORITY_CLASS) != 0 { + log::info!(target: "osu", "Game optimization: boosted osu! process priority to High (pid {pid})"); + } + winapi::um::handleapi::CloseHandle(handle); + } + } + } } crate::logging::capture_osu_output(&mut game_process); @@ -691,7 +724,21 @@ pub async fn run_osu(folder: String, patch: bool) -> Result<(), String> { } } - match game_process.wait().await { + let wait_res = game_process.wait().await; + + #[cfg(windows)] + if optimize { + #[link(name = "winmm")] + extern "system" { + fn timeEndPeriod(uPeriod: u32) -> u32; + } + unsafe { + timeEndPeriod(1); + } + log::info!(target: "osu", "Game optimization: restored timer resolution"); + } + + match wait_res { Ok(status) => log::info!(target: "osu", "osu! exited with {status}"), Err(error) => { log::warn!(target: "osu", "Could not wait for osu!: {error}"); diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index afa1442..3a36d21 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -582,7 +582,14 @@ async fn clean_wine_font_registry(runtime: &WineRuntime) { } /// Run osu! (`-devserver ez-pp.farm`) and wait until the game exits. -pub async fn run_osu(osu_folder: &str, patch: bool) -> Result<(), String> { +pub async fn run_osu( + osu_folder: &str, + patch: bool, + game_optimization: bool, +) -> Result<(), String> { + if game_optimization { + log::info!(target: "osu", "Game optimization active on macOS"); + } let folder = Path::new(osu_folder); let runtime = WineRuntime::for_osu_folder(folder).map_err(|error| { log::error!(target: "osu", "Could not prepare the Wine runtime: {error}"); diff --git a/src/lib/components/theme/ThemeEditorWindow.svelte b/src/lib/components/theme/ThemeEditorWindow.svelte index 5fad399..1762cb1 100644 --- a/src/lib/components/theme/ThemeEditorWindow.svelte +++ b/src/lib/components/theme/ThemeEditorWindow.svelte @@ -700,6 +700,7 @@ { value: 'snow', label: 'Snow' }, { value: 'petals', label: 'Petals' }, { value: 'bubbles', label: 'Bubbles' }, + { value: 'embers', label: 'Floating Embers' }, ] as const; const notifyError = (description: string) => sileo.error({ title: 'Theme editor', description }); @@ -5390,6 +5391,48 @@ {/if} + +
+
+
+ +

+ 3D depth perspective on mouse move +

+
+ { + if (!definition.parallax) { + definition.parallax = { enabled: false, intensity: 0.25 }; + } + definition.parallax.enabled = checked; + }} + /> +
+ {#if definition.parallax?.enabled} +
+
+ + + {Math.round((definition.parallax.intensity ?? 0.25) * 100)}% + +
+ { + if (!definition.parallax) { + definition.parallax = { enabled: true, intensity: 0.25 }; + } + definition.parallax.intensity = val; + }} + /> +
+ {/if} +
diff --git a/src/lib/components/theme/ThemeTransformControls.svelte b/src/lib/components/theme/ThemeTransformControls.svelte index 0a42f8c..6ebb241 100644 --- a/src/lib/components/theme/ThemeTransformControls.svelte +++ b/src/lib/components/theme/ThemeTransformControls.svelte @@ -34,6 +34,7 @@ (transform.skewY ?? 0) === 0 && !transform.flipX && !transform.flipY && + (transform.parallax ?? 0) === 0 && (!includeTranslate || ((transform.offsetX ?? 0) === 0 && (transform.offsetY ?? 0) === 0)) ); @@ -87,6 +88,7 @@ skewY: 0, flipX: false, flipY: false, + parallax: 0, ...(includeTranslate ? { offsetX: 0, offsetY: 0 } : {}), }); } @@ -257,4 +259,25 @@ + +
+
+ Parallax depth + 3D cursor perspective +
+
+ onchange({ parallax: Number((e.target as HTMLInputElement).value) })} + class="h-1.5 w-24 cursor-pointer appearance-none rounded-lg bg-theme-800 accent-primary" + /> + + {Math.round((transform.parallax ?? 0) * 100)}% + +
+
diff --git a/src/lib/components/theme/ThemeV2Background.svelte b/src/lib/components/theme/ThemeV2Background.svelte index 8c3712f..8aa9fae 100644 --- a/src/lib/components/theme/ThemeV2Background.svelte +++ b/src/lib/components/theme/ThemeV2Background.svelte @@ -5,6 +5,7 @@ import { themeAssetUrl } from '$lib/themeV2'; import { reduceAnimations } from '$lib/userSettings'; import { trackMediaContainer } from '$lib/utils'; + import { onDestroy } from 'svelte'; let { definition, @@ -19,6 +20,51 @@ } = $props(); let audioElement = $state(undefined); + let mousePx = $state(0); + let mousePy = $state(0); + let targetPx = 0; + let targetPy = 0; + let animFrame: number | undefined; + + function onWindowMouseMove(e: MouseEvent) { + if ($reduceAnimations || !definition.parallax?.enabled) return; + const intensity = definition.parallax.intensity ?? 0.25; + const w = window.innerWidth || 1000; + const h = window.innerHeight || 650; + targetPx = (e.clientX / w - 0.5) * 2 * intensity; + targetPy = (e.clientY / h - 0.5) * 2 * intensity; + if (animFrame === undefined) { + animFrame = requestAnimationFrame(updateParallax); + } + } + + function updateParallax() { + animFrame = undefined; + mousePx += (targetPx - mousePx) * 0.12; + mousePy += (targetPy - mousePy) * 0.12; + if (Math.abs(targetPx - mousePx) > 0.001 || Math.abs(targetPy - mousePy) > 0.001) { + animFrame = requestAnimationFrame(updateParallax); + } + } + + $effect(() => { + if (!definition.parallax?.enabled) { + mousePx = 0; + mousePy = 0; + targetPx = 0; + targetPy = 0; + if (animFrame !== undefined) { + cancelAnimationFrame(animFrame); + animFrame = undefined; + } + } + }); + + onDestroy(() => { + if (animFrame !== undefined) { + cancelAnimationFrame(animFrame); + } + }); const assetUrl = (path: string | undefined) => { if (!path) return ''; @@ -43,17 +89,19 @@ return Array.from({ length: count }, (_, index) => { const value = (offset: number) => random(index + 1 + offset); const isStar = effects.type === 'stars'; - const size = isStar ? 1 + value(1) * 2.5 : 4 + value(1) * 7; - const duration = (isStar ? 2 + value(2) * 3 : 9 + value(2) * 11) / speed; + const isEmber = effects.type === 'embers'; + const size = isStar ? 1 + value(1) * 2.5 : isEmber ? 2.5 + value(1) * 4.5 : 4 + value(1) * 7; + const duration = + (isStar ? 2 + value(2) * 3 : isEmber ? 5 + value(2) * 6 : 9 + value(2) * 11) / speed; return { id: index, left: value(3) * 100, - top: isStar ? value(4) * 100 : value(4) * 15, + top: isStar ? value(4) * 100 : isEmber ? 88 + value(4) * 16 : value(4) * 15, size, duration, delay: value(5) * duration, - drift: -90 + value(6) * 180, - opacity: 0.25 + value(7) * 0.65, + drift: isEmber ? -50 + value(6) * 100 : -90 + value(6) * 180, + opacity: isEmber ? 0.35 + value(7) * 0.65 : 0.25 + value(7) * 0.65, rotate: value(8) * 360, }; }); @@ -86,7 +134,13 @@ }); -
+ + +
{#if background.type === 'color'}
{/if} {:else if background.type === 'video'} @@ -126,7 +184,11 @@ style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ?? 1};filter:{background.blur ? `blur(${background.blur}px)` - : 'none'};transform:{background.blur ? 'scale(1.05)' : 'none'};" + : 'none'};transform:{background.blur + ? 'scale(1.08)' + : definition.parallax?.enabled + ? 'scale(1.06)' + : 'none'} translate3d({mousePx * -18}px, {mousePy * -18}px, 0);" > {/if} {/if} @@ -204,6 +266,33 @@ animation-name: v2-stars; animation-timing-function: ease-in-out; } + + &.embers { + border-radius: 9999px; + box-shadow: + 0 0 6px var(--particle-color), + 0 0 12px var(--particle-color); + animation-name: v2-embers; + animation-timing-function: ease-out; + } + } + + @keyframes v2-embers { + 0% { + transform: translate3d(0, 0, 0) scale(0.6); + opacity: 0.1; + } + 20% { + opacity: 0.95; + } + 80% { + opacity: 0.8; + transform: translate3d(calc(var(--particle-drift) * 0.65), -85vh, 0) scale(1.1); + } + 100% { + transform: translate3d(var(--particle-drift), -115vh, 0) scale(0.3); + opacity: 0; + } } @keyframes v2-snow { diff --git a/src/lib/components/theme/ThemeV2Elements.svelte b/src/lib/components/theme/ThemeV2Elements.svelte index 6135305..cdc852b 100644 --- a/src/lib/components/theme/ThemeV2Elements.svelte +++ b/src/lib/components/theme/ThemeV2Elements.svelte @@ -1227,7 +1227,9 @@ element, elements, groups - )};transform-origin:0 0;touch-action:none;outline:{selected + )}{element.parallax + ? ` translate3d(calc(var(--mouse-px, 0) * ${element.parallax * 30}px), calc(var(--mouse-py, 0) * ${element.parallax * 30}px), 0)` + : ''};transform-origin:0 0;touch-action:none;outline:{selected ? `${selectedIds.length > 1 ? 1 : 2}px solid #ff006e` : 'none'};outline-offset:2px;" role={interactive || element.interactive ? 'button' : undefined} diff --git a/src/lib/osuUtil.ts b/src/lib/osuUtil.ts index 4510a5f..7f5b351 100644 --- a/src/lib/osuUtil.ts +++ b/src/lib/osuUtil.ts @@ -88,8 +88,8 @@ export const getSkin = async (folder: string) => { }; export const runUpdater = async (folder: string) => await invoke('run_osu_updater', { folder }); -export const runOsu = async (folder: string, patch: boolean) => - await invoke('run_osu', { folder, patch }); +export const runOsu = async (folder: string, patch: boolean, gameOptimization: boolean = false) => + await invoke('run_osu', { folder, patch, gameOptimization }); export const getEZPPLauncherStreams = async () => { const resp = await betterFetch<{ streams: string[] }>(updateUrl, { diff --git a/src/lib/themePreview.ts b/src/lib/themePreview.ts index c2fbc31..9d8318c 100644 --- a/src/lib/themePreview.ts +++ b/src/lib/themePreview.ts @@ -699,6 +699,12 @@ const drawParticles = (context: PreviewContext) => { ctx.beginPath(); ctx.arc(x, y, size / 2, 0, Math.PI * 2); ctx.stroke(); + } else if (effects.type === 'embers') { + ctx.shadowColor = color; + ctx.shadowBlur = 8; + ctx.beginPath(); + ctx.arc(x, y, size / 2.2, 0, Math.PI * 2); + ctx.fill(); } else if (effects.type === 'petals') { ctx.translate(x, y); ctx.rotate(value(8) * Math.PI * 2); diff --git a/src/lib/themeV2.ts b/src/lib/themeV2.ts index 2ce8573..5fbd89a 100644 --- a/src/lib/themeV2.ts +++ b/src/lib/themeV2.ts @@ -48,7 +48,13 @@ export type ThemeV2Background = muted?: boolean; }; -export type ThemeV2EffectType = 'none' | 'snow' | 'stars' | 'petals' | 'bubbles'; +export type ThemeV2EffectType = 'none' | 'snow' | 'stars' | 'petals' | 'bubbles' | 'embers'; + +export type ThemeV2Parallax = { + enabled?: boolean; + /** Intensity multiplier from 0.05 to 1.0, defaults to 0.25 */ + intensity?: number; +}; export type ThemeV2Effects = { type: ThemeV2EffectType; @@ -121,6 +127,8 @@ export type ThemeV2Transform = { skewY?: number; flipX?: boolean; flipY?: boolean; + /** Parallax depth multiplier (-2 to 2); when parallax is enabled, offsets layer based on cursor. */ + parallax?: number; }; /** A group in the layer list. Groups may be nested through `parentId`. */ @@ -644,6 +652,7 @@ export type ThemeV2Definition = { background: ThemeV2Background; overlay: ThemeV2Overlay; effects: ThemeV2Effects; + parallax?: ThemeV2Parallax; audio?: ThemeV2Audio; /** Theme-wide fonts and freeform layers rendered over the launcher UI. */ typography: ThemeV2Typography; @@ -789,6 +798,9 @@ const normalizeTransform = (source: Record): ThemeV2Transform = skewY: clamp(source.skewY, 0, -89, 89), flipX: source.flipX === true, flipY: source.flipY === true, + ...(typeof source.parallax === 'number' && Number.isFinite(source.parallax) + ? { parallax: clamp(source.parallax, 0, -2, 2) } + : {}), }; }; @@ -1756,6 +1768,10 @@ export const createDefaultThemeV2 = (): ThemeV2Definition => ({ speed: 1, color: '#ffffff', }, + parallax: { + enabled: false, + intensity: 0.25, + }, typography: { body: 'Sora Variable', heading: 'Sora Variable', @@ -1830,12 +1846,18 @@ export const normalizeThemeV2 = (raw: Partial): ThemeV2Defini } const sourceEffects = (raw.effects ?? {}) as Record; - const effectType = ['none', 'snow', 'stars', 'petals', 'bubbles'].includes( + const effectType = ['none', 'snow', 'stars', 'petals', 'bubbles', 'embers'].includes( String(sourceEffects.type) ) ? (String(sourceEffects.type) as ThemeV2EffectType) : 'none'; + const sourceParallax = (raw.parallax ?? {}) as Record; + const parallax: ThemeV2Parallax = { + enabled: sourceParallax.enabled === true, + intensity: clamp(sourceParallax.intensity, 0.25, 0.05, 1), + }; + const sourceOverlay = (raw.overlay ?? {}) as Record; const sourceAudio = raw.audio as Record | undefined; const sourceTypography = (raw.typography ?? {}) as Record; @@ -1868,6 +1890,7 @@ export const normalizeThemeV2 = (raw: Partial): ThemeV2Defini speed: clamp(sourceEffects.speed, 1, 0.2, 3), color: color(sourceEffects.color, '#ffffff'), }, + parallax, // A video background owns the soundtrack; combining it with theme music // would play two sources at once. audio: diff --git a/src/lib/userSettings.ts b/src/lib/userSettings.ts index 56df459..0959c39 100644 --- a/src/lib/userSettings.ts +++ b/src/lib/userSettings.ts @@ -4,6 +4,7 @@ export const customCursor = writable(false); export const cursorSmoothening = writable(false); export const cursorSmoothness = writable(14); export const reduceAnimations = writable(false); +export const gameOptimization = writable(false); export const patch = writable(true); export const loggingEnabled = writable(true); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 83a3ac1..79bbe62 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -51,6 +51,7 @@ import { cursorSmoothening, customCursor, + gameOptimization, loggingEnabled, patch, reduceAnimations, @@ -260,6 +261,7 @@ const config_custom_cursor = config.value('custom_cursor'); const config_cursor_smoothening = config.value('cursor_smoothening'); const config_reduce_animations = config.value('reduce_animations'); + const config_game_optimization = config.value('game_optimization'); const config_logging_enabled = config.value('logging_enabled'); const config_discord_presence = config.value('discord_presence'); const config_tracking_enabled = config.value('tracking_consent'); @@ -291,6 +293,7 @@ customCursor.set(await config_custom_cursor.get(true)); cursorSmoothening.set(await config_cursor_smoothening.get(true)); reduceAnimations.set(await config_reduce_animations.get(false)); + gameOptimization.set(await config_game_optimization.get(false)); loggingEnabled.set(await config_logging_enabled.get(true)); discordPresence.set(await config_discord_presence.get(true)); openTabletDriverEnabled.set(await config_otd_enabled.get(false)); @@ -303,6 +306,7 @@ customCursor.subscribe(async (val) => await config_custom_cursor.set(val)); cursorSmoothening.subscribe(async (val) => await config_cursor_smoothening.set(val)); reduceAnimations.subscribe(async (val) => await config_reduce_animations.set(val)); + gameOptimization.subscribe(async (val) => await config_game_optimization.set(val)); loggingEnabled.subscribe(async (val) => { await config_logging_enabled.set(val); await setLoggingEnabled(val); diff --git a/src/screens/Launch.svelte b/src/screens/Launch.svelte index 881976c..e64da38 100644 --- a/src/screens/Launch.svelte +++ b/src/screens/Launch.svelte @@ -131,6 +131,7 @@ import { cursorSmoothening, customCursor, + gameOptimization, loggingEnabled, osuInstallationPath, patch, @@ -1201,7 +1202,7 @@ }, 1000 * 2); } - await runOsu(osuPath, $patch); + await runOsu(osuPath, $patch, $gameOptimization); if ($trackingEnabled) umami.track('app_exit_osu'); cleanup = true; launchInfo = 'Cleaning up...'; @@ -3192,6 +3193,27 @@ class="mt-0.5" />
+ +
+
+ +

+ Boosts osu! process priority to High and requests maximum + sub-millisecond Windows timer resolution (1ms) during gameplay + to reduce input latency. +

+
+ { + gameOptimization.set(e); + }} + class="mt-0.5" + /> +
{:else if activeSettingsTab === 'integrations'}