mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-08 02:32:27 +08:00
404 lines
12 KiB
Vue
404 lines
12 KiB
Vue
|
|
<template>
|
||
|
|
<div
|
||
|
|
v-show="!isFullyHidden"
|
||
|
|
class="gemini-star-cluster absolute inset-0 overflow-hidden pointer-events-none"
|
||
|
|
:class="{ 'scattering': isScattering, 'fading-out': isFadingOut }"
|
||
|
|
>
|
||
|
|
<!-- SVG Defs for the Gemini multi-color gradient -->
|
||
|
|
<svg class="absolute w-0 h-0 overflow-hidden" aria-hidden="true">
|
||
|
|
<defs>
|
||
|
|
<!-- Main Gemini gradient (blue base with color overlays) -->
|
||
|
|
<linearGradient id="gemini-base" x1="0%" y1="0%" x2="100%" y2="100%">
|
||
|
|
<stop offset="0%" stop-color="#1A73E8" />
|
||
|
|
<stop offset="50%" stop-color="#4285F4" />
|
||
|
|
<stop offset="100%" stop-color="#669DF6" />
|
||
|
|
</linearGradient>
|
||
|
|
<!-- Red accent overlay - from top -->
|
||
|
|
<linearGradient id="gemini-red-overlay" x1="50%" y1="0%" x2="50%" y2="50%">
|
||
|
|
<stop offset="0%" stop-color="#EA4335" />
|
||
|
|
<stop offset="100%" stop-color="#EA4335" stop-opacity="0" />
|
||
|
|
</linearGradient>
|
||
|
|
<!-- Yellow accent overlay - from left -->
|
||
|
|
<linearGradient id="gemini-yellow-overlay" x1="0%" y1="50%" x2="50%" y2="50%">
|
||
|
|
<stop offset="0%" stop-color="#FBBC04" />
|
||
|
|
<stop offset="100%" stop-color="#FBBC04" stop-opacity="0" />
|
||
|
|
</linearGradient>
|
||
|
|
<!-- Green accent overlay - from bottom -->
|
||
|
|
<linearGradient id="gemini-green-overlay" x1="50%" y1="100%" x2="50%" y2="50%">
|
||
|
|
<stop offset="0%" stop-color="#34A853" />
|
||
|
|
<stop offset="100%" stop-color="#34A853" stop-opacity="0" />
|
||
|
|
</linearGradient>
|
||
|
|
<!-- Glow filter -->
|
||
|
|
<filter id="star-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||
|
|
<feGaussianBlur stdDeviation="2" result="blur" />
|
||
|
|
<feFlood flood-color="#4285F4" flood-opacity="0.3" />
|
||
|
|
<feComposite in2="blur" operator="in" />
|
||
|
|
<feMerge>
|
||
|
|
<feMergeNode />
|
||
|
|
<feMergeNode in="SourceGraphic" />
|
||
|
|
</feMerge>
|
||
|
|
</filter>
|
||
|
|
</defs>
|
||
|
|
</svg>
|
||
|
|
|
||
|
|
<!-- Layer 1: Far background stars (smallest, lowest z-index) -->
|
||
|
|
<div class="stars-layer far-layer">
|
||
|
|
<div
|
||
|
|
v-for="star in farStars"
|
||
|
|
:key="`far-${star.id}`"
|
||
|
|
class="star-wrapper"
|
||
|
|
:class="{ 'star-visible': star.visible && hasScattered }"
|
||
|
|
:style="getStarStyle(star)"
|
||
|
|
>
|
||
|
|
<svg viewBox="0 0 24 24" class="star-svg">
|
||
|
|
<path :d="starPath" fill="url(#gemini-base)" />
|
||
|
|
<path :d="starPath" fill="url(#gemini-red-overlay)" />
|
||
|
|
<path :d="starPath" fill="url(#gemini-yellow-overlay)" />
|
||
|
|
<path :d="starPath" fill="url(#gemini-green-overlay)" />
|
||
|
|
</svg>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Layer 2: Mid-distance stars -->
|
||
|
|
<div class="stars-layer mid-layer">
|
||
|
|
<div
|
||
|
|
v-for="star in midStars"
|
||
|
|
:key="`mid-${star.id}`"
|
||
|
|
class="star-wrapper"
|
||
|
|
:class="{ 'star-visible': star.visible && hasScattered }"
|
||
|
|
:style="getStarStyle(star)"
|
||
|
|
>
|
||
|
|
<svg viewBox="0 0 24 24" class="star-svg" style="filter: url(#star-glow)">
|
||
|
|
<path :d="starPath" fill="url(#gemini-base)" />
|
||
|
|
<path :d="starPath" fill="url(#gemini-red-overlay)" />
|
||
|
|
<path :d="starPath" fill="url(#gemini-yellow-overlay)" />
|
||
|
|
<path :d="starPath" fill="url(#gemini-green-overlay)" />
|
||
|
|
</svg>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Layer 3: Near stars (largest, highest z-index - in front, not occluded by small stars) -->
|
||
|
|
<div class="stars-layer near-layer">
|
||
|
|
<div
|
||
|
|
v-for="star in nearStars"
|
||
|
|
:key="`near-${star.id}`"
|
||
|
|
class="star-wrapper"
|
||
|
|
:class="{ 'star-visible': star.visible && hasScattered }"
|
||
|
|
:style="getStarStyle(star)"
|
||
|
|
>
|
||
|
|
<svg viewBox="0 0 24 24" class="star-svg" style="filter: url(#star-glow)">
|
||
|
|
<path :d="starPath" fill="url(#gemini-base)" />
|
||
|
|
<path :d="starPath" fill="url(#gemini-red-overlay)" />
|
||
|
|
<path :d="starPath" fill="url(#gemini-yellow-overlay)" />
|
||
|
|
<path :d="starPath" fill="url(#gemini-green-overlay)" />
|
||
|
|
</svg>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<script setup lang="ts">
|
||
|
|
import { ref, onMounted, onUnmounted, watch, type CSSProperties } from 'vue'
|
||
|
|
|
||
|
|
// Props for transition control
|
||
|
|
const props = withDefaults(defineProps<{
|
||
|
|
isVisible?: boolean
|
||
|
|
}>(), {
|
||
|
|
isVisible: true
|
||
|
|
})
|
||
|
|
|
||
|
|
// Gemini star SVG path (4-point star) - viewBox 0 0 24 24
|
||
|
|
const starPath = 'M12 1.5c.2 3.4 1.4 6.4 3.8 8.8 2.4 2.4 5.4 3.6 8.8 3.8-3.4.2-6.4 1.4-8.8 3.8-2.4 2.4-3.6 5.4-3.8 8.8-.2-3.4-1.4-6.4-3.8-8.8-2.4-2.4-5.4-3.6-8.8-3.8 3.4-.2 6.4-1.4 8.8-3.8 2.4-2.4 3.6-5.4 3.8-8.8z'
|
||
|
|
|
||
|
|
interface Star {
|
||
|
|
id: number
|
||
|
|
x: number
|
||
|
|
y: number
|
||
|
|
targetX: number
|
||
|
|
targetY: number
|
||
|
|
size: number
|
||
|
|
baseOpacity: number
|
||
|
|
visible: boolean
|
||
|
|
zIndex: number
|
||
|
|
// Animation props
|
||
|
|
twinkleDuration: number
|
||
|
|
twinkleDelay: number
|
||
|
|
}
|
||
|
|
|
||
|
|
const farStars = ref<Star[]>([])
|
||
|
|
const midStars = ref<Star[]>([])
|
||
|
|
const nearStars = ref<Star[]>([])
|
||
|
|
const hasScattered = ref(false)
|
||
|
|
const isScattering = ref(false)
|
||
|
|
const isFadingOut = ref(false)
|
||
|
|
const isFullyHidden = ref(false)
|
||
|
|
|
||
|
|
const CENTER_X = 50
|
||
|
|
const CENTER_Y = 50
|
||
|
|
|
||
|
|
// Generate constrained position to keep stars within bounds
|
||
|
|
const getConstrainedPosition = (size: number): { x: number; y: number } => {
|
||
|
|
// Calculate padding based on star size (in percentage)
|
||
|
|
// Assuming container is roughly 400-600px wide, use a safe estimate
|
||
|
|
const paddingPercent = Math.max(3, (size / 5))
|
||
|
|
const minPos = paddingPercent
|
||
|
|
const maxPos = 100 - paddingPercent
|
||
|
|
|
||
|
|
return {
|
||
|
|
x: minPos + Math.random() * (maxPos - minPos),
|
||
|
|
y: minPos + Math.random() * (maxPos - minPos)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const createStar = (id: number, sizeRange: [number, number, number, number, number, number], opacityBase: number, opacityRange: number, visibleThreshold: number, zIndexBase: number): Star => {
|
||
|
|
const sizeVariant = Math.random()
|
||
|
|
let size: number
|
||
|
|
if (sizeVariant < 0.4) {
|
||
|
|
size = sizeRange[0] + Math.random() * sizeRange[1]
|
||
|
|
} else if (sizeVariant < 0.7) {
|
||
|
|
size = sizeRange[2] + Math.random() * sizeRange[3]
|
||
|
|
} else {
|
||
|
|
size = sizeRange[4] + Math.random() * sizeRange[5]
|
||
|
|
}
|
||
|
|
|
||
|
|
const { x: targetX, y: targetY } = getConstrainedPosition(size)
|
||
|
|
|
||
|
|
return {
|
||
|
|
id,
|
||
|
|
x: CENTER_X,
|
||
|
|
y: CENTER_Y,
|
||
|
|
targetX,
|
||
|
|
targetY,
|
||
|
|
size,
|
||
|
|
baseOpacity: opacityBase + Math.random() * opacityRange,
|
||
|
|
visible: true,
|
||
|
|
zIndex: zIndexBase + Math.round(size),
|
||
|
|
twinkleDuration: 3 + Math.random() * 4, // 3-7s duration
|
||
|
|
twinkleDelay: Math.random() * 5 // 0-5s delay
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const createStarLayers = () => {
|
||
|
|
const far: Star[] = []
|
||
|
|
for (let i = 0; i < 30; i++) {
|
||
|
|
far.push(createStar(i, [6, 4, 10, 6, 14, 6], 0.2, 0.25, 0.35, 0))
|
||
|
|
}
|
||
|
|
farStars.value = far
|
||
|
|
|
||
|
|
const mid: Star[] = []
|
||
|
|
for (let i = 0; i < 18; i++) {
|
||
|
|
mid.push(createStar(i, [18, 8, 24, 10, 32, 10], 0.35, 0.35, 0.45, 30))
|
||
|
|
}
|
||
|
|
midStars.value = mid
|
||
|
|
|
||
|
|
const near: Star[] = []
|
||
|
|
for (let i = 0; i < 10; i++) {
|
||
|
|
near.push(createStar(i, [40, 15, 55, 20, 70, 25], 0.5, 0.5, 0.55, 100))
|
||
|
|
}
|
||
|
|
nearStars.value = near
|
||
|
|
}
|
||
|
|
|
||
|
|
// Removed: handleAnimationIteration was causing stars to "teleport" visibly
|
||
|
|
// Stars now stay in place and only twinkle without changing position
|
||
|
|
|
||
|
|
// Scatter stars from center to their target positions
|
||
|
|
const scatterStars = () => {
|
||
|
|
isScattering.value = true
|
||
|
|
hasScattered.value = true
|
||
|
|
|
||
|
|
const allStars = [...farStars.value, ...midStars.value, ...nearStars.value]
|
||
|
|
allStars.forEach(star => {
|
||
|
|
star.x = star.targetX
|
||
|
|
star.y = star.targetY
|
||
|
|
})
|
||
|
|
|
||
|
|
setTimeout(() => {
|
||
|
|
isScattering.value = false
|
||
|
|
}, 2000)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Reset stars to center
|
||
|
|
const resetStarsToCenter = () => {
|
||
|
|
hasScattered.value = false
|
||
|
|
isScattering.value = false
|
||
|
|
|
||
|
|
const allStars = [...farStars.value, ...midStars.value, ...nearStars.value]
|
||
|
|
allStars.forEach(star => {
|
||
|
|
star.x = CENTER_X
|
||
|
|
star.y = CENTER_Y
|
||
|
|
const { x, y } = getConstrainedPosition(star.size)
|
||
|
|
star.targetX = x
|
||
|
|
star.targetY = y
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
const getStarStyle = (star: Star): CSSProperties => {
|
||
|
|
return {
|
||
|
|
left: `${star.x}%`,
|
||
|
|
top: `${star.y}%`,
|
||
|
|
width: `${star.size}px`,
|
||
|
|
height: `${star.size}px`,
|
||
|
|
zIndex: star.zIndex,
|
||
|
|
'--base-opacity': star.baseOpacity,
|
||
|
|
'--twinkle-duration': `${star.twinkleDuration}s`,
|
||
|
|
'--twinkle-delay': `${star.twinkleDelay}s`
|
||
|
|
} as CSSProperties
|
||
|
|
}
|
||
|
|
|
||
|
|
// Watch for visibility changes
|
||
|
|
watch(() => props.isVisible, (newVal, oldVal) => {
|
||
|
|
if (newVal && !oldVal) {
|
||
|
|
// Entering: recreate stars if needed, reset to center then scatter
|
||
|
|
isFadingOut.value = false
|
||
|
|
isFullyHidden.value = false
|
||
|
|
// Recreate stars if they were cleared
|
||
|
|
if (farStars.value.length === 0) {
|
||
|
|
createStarLayers()
|
||
|
|
} else {
|
||
|
|
resetStarsToCenter()
|
||
|
|
}
|
||
|
|
setTimeout(() => {
|
||
|
|
scatterStars()
|
||
|
|
}, 50)
|
||
|
|
} else if (!newVal && oldVal) {
|
||
|
|
// Leaving: immediately stop animation and hide to release GPU resources
|
||
|
|
hasScattered.value = false
|
||
|
|
isScattering.value = false // Force stop scattering transition immediately
|
||
|
|
isFadingOut.value = true
|
||
|
|
|
||
|
|
// Wait for fade-out transition (150ms) to complete before cleanup
|
||
|
|
// Use a single timeout matching the CSS transition duration
|
||
|
|
setTimeout(() => {
|
||
|
|
if (!props.isVisible) {
|
||
|
|
isFullyHidden.value = true
|
||
|
|
isFadingOut.value = false
|
||
|
|
// Clear star arrays to fully release memory
|
||
|
|
farStars.value = []
|
||
|
|
midStars.value = []
|
||
|
|
nearStars.value = []
|
||
|
|
}
|
||
|
|
}, 180) // Slightly longer than CSS transition (150ms) to ensure smooth fade
|
||
|
|
}
|
||
|
|
}, { flush: 'post' }) // Change to post flush to ensure DOM is updated before our cleanup logic runs
|
||
|
|
|
||
|
|
onMounted(() => {
|
||
|
|
createStarLayers()
|
||
|
|
if (props.isVisible) {
|
||
|
|
isFullyHidden.value = false
|
||
|
|
setTimeout(() => {
|
||
|
|
scatterStars()
|
||
|
|
}, 100)
|
||
|
|
} else {
|
||
|
|
// Start hidden if not visible
|
||
|
|
isFullyHidden.value = true
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
onUnmounted(() => {
|
||
|
|
// No explicit cleanup needed for CSS animations
|
||
|
|
})
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<style scoped>
|
||
|
|
.gemini-star-cluster {
|
||
|
|
perspective: 800px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.stars-layer {
|
||
|
|
position: absolute;
|
||
|
|
inset: 0;
|
||
|
|
pointer-events: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.far-layer {
|
||
|
|
z-index: 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
.mid-layer {
|
||
|
|
z-index: 2;
|
||
|
|
}
|
||
|
|
|
||
|
|
.near-layer {
|
||
|
|
z-index: 3;
|
||
|
|
}
|
||
|
|
|
||
|
|
.star-svg {
|
||
|
|
width: 100%;
|
||
|
|
height: 100%;
|
||
|
|
overflow: visible;
|
||
|
|
}
|
||
|
|
|
||
|
|
.star-wrapper {
|
||
|
|
position: absolute;
|
||
|
|
opacity: 0;
|
||
|
|
transform: scale(0.3) translate(-50%, -50%);
|
||
|
|
transition:
|
||
|
|
opacity 0.8s ease-out,
|
||
|
|
transform 0.8s ease-out;
|
||
|
|
/* Avoid persistent will-change to reduce GPU memory usage */
|
||
|
|
}
|
||
|
|
|
||
|
|
.star-wrapper.star-visible {
|
||
|
|
opacity: 0; /* Default to invisible, animation handles opacity */
|
||
|
|
transform: scale(1) translate(-50%, -50%);
|
||
|
|
animation: twinkle var(--twinkle-duration) ease-in-out infinite;
|
||
|
|
animation-delay: var(--twinkle-delay);
|
||
|
|
}
|
||
|
|
|
||
|
|
@keyframes twinkle {
|
||
|
|
0% {
|
||
|
|
opacity: 0;
|
||
|
|
transform: scale(0.5) translate(-50%, -50%);
|
||
|
|
}
|
||
|
|
50% {
|
||
|
|
opacity: var(--base-opacity);
|
||
|
|
transform: scale(1) translate(-50%, -50%);
|
||
|
|
filter: brightness(1.2);
|
||
|
|
}
|
||
|
|
100% {
|
||
|
|
opacity: 0;
|
||
|
|
transform: scale(0.5) translate(-50%, -50%);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Scatter animation - stars fly outward from center (2s duration) */
|
||
|
|
.scattering .star-wrapper {
|
||
|
|
transition:
|
||
|
|
opacity 0.8s ease-out,
|
||
|
|
transform 0.8s ease-out,
|
||
|
|
left 2s cubic-bezier(0.16, 1, 0.3, 1),
|
||
|
|
top 2s cubic-bezier(0.16, 1, 0.3, 1);
|
||
|
|
/* Only use will-change during active scatter animation */
|
||
|
|
will-change: opacity, transform, left, top;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Fade out animation - quick fade and disable child transitions */
|
||
|
|
.fading-out {
|
||
|
|
opacity: 0;
|
||
|
|
transition: opacity 0.15s ease-out;
|
||
|
|
pointer-events: none;
|
||
|
|
/* Force GPU layer removal */
|
||
|
|
will-change: auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
.fading-out .star-wrapper {
|
||
|
|
transition: none !important;
|
||
|
|
will-change: auto !important;
|
||
|
|
animation: none !important;
|
||
|
|
opacity: 0 !important;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Depth blur effect */
|
||
|
|
.far-layer .star-wrapper {
|
||
|
|
filter: blur(0.5px);
|
||
|
|
}
|
||
|
|
|
||
|
|
.mid-layer .star-wrapper {
|
||
|
|
filter: blur(0.2px);
|
||
|
|
}
|
||
|
|
|
||
|
|
.near-layer .star-wrapper {
|
||
|
|
filter: none;
|
||
|
|
}
|
||
|
|
</style>
|