feat: add game optimization mode, 3d cursor parallax, and embers particle effect

This commit is contained in:
HorizonCode committed 2026-11-01 12:36:10 +01:00
1 parent a75e723416
commit 62b31169cc
14 files changed
+301 -20

No files matched your search

@@ -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 @@
</div>
</div>
{/if}
<div class="space-y-2 border-t border-theme-800/60 pt-2.5">
<div class="flex items-center justify-between">
<div>
<Label class={labelClass}>Mouse parallax</Label>
<p class="text-[10px] text-muted-foreground">
3D depth perspective on mouse move
</p>
</div>
<Checkbox
checked={definition.parallax?.enabled ?? false}
onCheckedChange={(checked) => {
if (!definition.parallax) {
definition.parallax = { enabled: false, intensity: 0.25 };
}
definition.parallax.enabled = checked;
}}
/>
</div>
{#if definition.parallax?.enabled}
<div class="space-y-1.5 pt-1">
<div class="flex items-center justify-between">
<Label class={labelClass}>Parallax intensity</Label>
<span class="font-mono text-xs text-muted-foreground">
{Math.round((definition.parallax.intensity ?? 0.25) * 100)}%
</span>
</div>
<Slider
min={0.05}
max={1}
step={0.05}
value={definition.parallax.intensity ?? 0.25}
onValueChange={(val) => {
if (!definition.parallax) {
definition.parallax = { enabled: true, intensity: 0.25 };
}
definition.parallax.intensity = val;
}}
/>
</div>
{/if}
</div>
</ThemeSection>
<ThemeSection title="Typography" icon={Type} open={false} contentClass="space-y-3 pt-2.5">
@@ -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 @@
</Button>
</div>
</div>
<div class="mt-2.5 flex items-center justify-between border-t border-theme-800/40 pt-2">
<div class="flex flex-col">
<span class={label}>Parallax depth</span>
<span class="text-[9px] text-muted-foreground">3D cursor perspective</span>
</div>
<div class="flex items-center gap-2">
<input
type="range"
min="-1"
max="1"
step="0.05"
value={transform.parallax ?? 0}
oninput={(e) => 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"
/>
<span class="w-8 font-mono text-[10px] text-right text-muted-foreground">
{Math.round((transform.parallax ?? 0) * 100)}%
</span>
</div>
</div>
</ThemeSection>
@@ -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<HTMLAudioElement | undefined>(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 @@
});
</script>
<div class="theme-v2 absolute inset-0 overflow-hidden" use:trackMedia>
<svelte:window onmousemove={onWindowMouseMove} />
<div
class="theme-v2 absolute inset-0 overflow-hidden"
use:trackMedia
style="--mouse-px:{mousePx};--mouse-py:{mousePy};"
>
{#if background.type === 'color'}
<div
class="absolute inset-0"
@@ -108,7 +162,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}
{: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);"
></video>
{/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 {
@@ -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}
+2 -2
View File
@@ -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, {
+6
View File
@@ -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);
+25 -2
View File
@@ -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<string, unknown>): 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<ThemeV2Definition>): ThemeV2Defini
}
const sourceEffects = (raw.effects ?? {}) as Record<string, unknown>;
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<string, unknown>;
const parallax: ThemeV2Parallax = {
enabled: sourceParallax.enabled === true,
intensity: clamp(sourceParallax.intensity, 0.25, 0.05, 1),
};
const sourceOverlay = (raw.overlay ?? {}) as Record<string, unknown>;
const sourceAudio = raw.audio as Record<string, unknown> | undefined;
const sourceTypography = (raw.typography ?? {}) as Record<string, unknown>;
@@ -1868,6 +1890,7 @@ export const normalizeThemeV2 = (raw: Partial<ThemeV2Definition>): 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:
+1
View File
@@ -4,6 +4,7 @@ export const customCursor = writable<boolean>(false);
export const cursorSmoothening = writable<boolean>(false);
export const cursorSmoothness = writable<number>(14);
export const reduceAnimations = writable<boolean>(false);
export const gameOptimization = writable<boolean>(false);
export const patch = writable<boolean>(true);
export const loggingEnabled = writable<boolean>(true);