250 lines
5.9 KiB
Vue
250 lines
5.9 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* GalaxyBackground – Canvas Starfield + Aurora Blobs
|
||
*
|
||
* Ported from assets/galaxy.js (design_handoff_nexus_v2).
|
||
* Auto-mounts onMounted; cleans up onUnmounted.
|
||
* Fixed overlay with z-index:0 and pointer-events:none.
|
||
*/
|
||
import { onMounted, onUnmounted, ref } from 'vue'
|
||
|
||
const rootRef = ref<HTMLElement | null>(null)
|
||
|
||
interface Star {
|
||
x: number
|
||
y: number
|
||
r: number
|
||
a: number
|
||
tw: number
|
||
ph: number
|
||
hue: number
|
||
dx: number
|
||
dy: number
|
||
}
|
||
|
||
interface Shoot {
|
||
x: number
|
||
y: number
|
||
len: number
|
||
sp: number
|
||
ang: number
|
||
life: number
|
||
}
|
||
|
||
let resizeObserver: ResizeObserver | null = null
|
||
let animFrameId = 0
|
||
let canvas: HTMLCanvasElement | null = null
|
||
let ctx: CanvasRenderingContext2D | null = null
|
||
|
||
function mount(root: HTMLElement) {
|
||
canvas = document.createElement('canvas')
|
||
root.appendChild(canvas)
|
||
ctx = canvas.getContext('2d')
|
||
if (!ctx) return
|
||
|
||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||
let stars: Star[] = []
|
||
let shoots: Shoot[] = []
|
||
let W = 0
|
||
let H = 0
|
||
let t = 0
|
||
|
||
function resize() {
|
||
const r = root.getBoundingClientRect()
|
||
W = r.width
|
||
H = r.height
|
||
canvas!.width = W * dpr
|
||
canvas!.height = H * dpr
|
||
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||
|
||
const count = Math.round((W * H) / 5200)
|
||
stars = []
|
||
for (let i = 0; i < count; i++) {
|
||
stars.push({
|
||
x: Math.random() * W,
|
||
y: Math.random() * H,
|
||
r: Math.random() * 1.3 + 0.25,
|
||
a: Math.random() * 0.6 + 0.2,
|
||
tw: Math.random() * 0.025 + 0.004,
|
||
ph: Math.random() * Math.PI * 2,
|
||
hue: Math.random() < 0.5 ? 230 : 270,
|
||
dx: (Math.random() - 0.5) * 0.04,
|
||
dy: (Math.random() - 0.5) * 0.04,
|
||
})
|
||
}
|
||
}
|
||
|
||
function spawnShoot() {
|
||
const fromLeft = Math.random() < 0.6
|
||
shoots.push({
|
||
x: fromLeft ? -40 : W * (0.4 + Math.random() * 0.5),
|
||
y: Math.random() * H * 0.5,
|
||
len: 90 + Math.random() * 120,
|
||
sp: 6 + Math.random() * 5,
|
||
ang: Math.random() * 0.3 + 0.15,
|
||
life: 1,
|
||
})
|
||
}
|
||
|
||
function frame() {
|
||
ctx!.clearRect(0, 0, W, H)
|
||
t += 1
|
||
|
||
// Draw stars
|
||
for (let i = 0; i < stars.length; i++) {
|
||
const s = stars[i]
|
||
s.ph += s.tw
|
||
const a = s.a * (0.55 + 0.45 * Math.sin(s.ph))
|
||
s.x += s.dx
|
||
s.y += s.dy
|
||
if (s.x < 0) s.x = W
|
||
if (s.x > W) s.x = 0
|
||
if (s.y < 0) s.y = H
|
||
if (s.y > H) s.y = 0
|
||
|
||
ctx!.beginPath()
|
||
ctx!.fillStyle = `hsla(${s.hue},90%,82%,${a})`
|
||
ctx!.arc(s.x, s.y, s.r, 0, Math.PI * 2)
|
||
ctx!.fill()
|
||
|
||
// Glow for larger stars
|
||
if (s.r > 1) {
|
||
ctx!.beginPath()
|
||
ctx!.fillStyle = `hsla(${s.hue},95%,80%,${a * 0.12})`
|
||
ctx!.arc(s.x, s.y, s.r * 3.5, 0, Math.PI * 2)
|
||
ctx!.fill()
|
||
}
|
||
}
|
||
|
||
// Shooting stars
|
||
if (Math.random() < 0.004 && shoots.length < 2) spawnShoot()
|
||
for (let j = shoots.length - 1; j >= 0; j--) {
|
||
const sh = shoots[j]
|
||
sh.x += Math.cos(sh.ang) * sh.sp
|
||
sh.y += Math.sin(sh.ang) * sh.sp
|
||
sh.life -= 0.006
|
||
|
||
const ex = sh.x - Math.cos(sh.ang) * sh.len
|
||
const ey = sh.y - Math.sin(sh.ang) * sh.len
|
||
const g = ctx!.createLinearGradient(sh.x, sh.y, ex, ey)
|
||
g.addColorStop(0, `rgba(200,210,255,${0.9 * sh.life})`)
|
||
g.addColorStop(1, 'rgba(160,120,255,0)')
|
||
|
||
ctx!.strokeStyle = g
|
||
ctx!.lineWidth = 2
|
||
ctx!.lineCap = 'round'
|
||
ctx!.beginPath()
|
||
ctx!.moveTo(sh.x, sh.y)
|
||
ctx!.lineTo(ex, ey)
|
||
ctx!.stroke()
|
||
|
||
if (sh.life <= 0 || sh.x > W + 60 || sh.y > H + 60) shoots.splice(j, 1)
|
||
}
|
||
|
||
animFrameId = requestAnimationFrame(frame)
|
||
}
|
||
|
||
resizeObserver = new ResizeObserver(() => resize())
|
||
resizeObserver.observe(root)
|
||
resize()
|
||
frame()
|
||
}
|
||
|
||
function cleanup() {
|
||
if (resizeObserver) {
|
||
resizeObserver.disconnect()
|
||
resizeObserver = null
|
||
}
|
||
if (animFrameId) {
|
||
cancelAnimationFrame(animFrameId)
|
||
animFrameId = 0
|
||
}
|
||
if (canvas && canvas.parentNode) {
|
||
canvas.parentNode.removeChild(canvas)
|
||
}
|
||
canvas = null
|
||
ctx = null
|
||
}
|
||
|
||
onMounted(() => {
|
||
if (rootRef.value) mount(rootRef.value)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
cleanup()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div ref="rootRef" class="galaxy-bg">
|
||
<div class="aurora a1"></div>
|
||
<div class="aurora a2"></div>
|
||
<div class="aurora a3"></div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.galaxy-bg {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 0;
|
||
overflow: hidden;
|
||
background:
|
||
radial-gradient(1200px 800px at 18% -8%, rgba(79,124,255,.20), transparent 60%),
|
||
radial-gradient(1000px 760px at 92% 8%, rgba(181,87,246,.18), transparent 60%),
|
||
radial-gradient(900px 700px at 60% 110%, rgba(70,60,180,.20), transparent 60%),
|
||
linear-gradient(180deg, #070512, #0a0818 60%, #060410);
|
||
pointer-events: none;
|
||
}
|
||
.galaxy-bg canvas {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
.aurora {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
filter: blur(70px);
|
||
opacity: 0.5;
|
||
mix-blend-mode: screen;
|
||
will-change: transform;
|
||
}
|
||
.a1 {
|
||
width: 46vw;
|
||
height: 46vw;
|
||
left: -8vw;
|
||
top: -14vw;
|
||
background: radial-gradient(circle, rgba(79,124,255,.55), transparent 65%);
|
||
animation: drift1 26s ease-in-out infinite;
|
||
}
|
||
.a2 {
|
||
width: 40vw;
|
||
height: 40vw;
|
||
right: -10vw;
|
||
top: 2vw;
|
||
background: radial-gradient(circle, rgba(181,87,246,.5), transparent 65%);
|
||
animation: drift2 32s ease-in-out infinite;
|
||
}
|
||
.a3 {
|
||
width: 42vw;
|
||
height: 42vw;
|
||
left: 34vw;
|
||
bottom: -18vw;
|
||
background: radial-gradient(circle, rgba(95,80,220,.45), transparent 65%);
|
||
animation: drift3 30s ease-in-out infinite;
|
||
}
|
||
@keyframes drift1 {
|
||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||
50% { transform: translate(8vw, 6vw) scale(1.12); }
|
||
}
|
||
@keyframes drift2 {
|
||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||
50% { transform: translate(-7vw, 5vw) scale(1.1); }
|
||
}
|
||
@keyframes drift3 {
|
||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||
50% { transform: translate(4vw, -6vw) scale(1.15); }
|
||
}
|
||
</style>
|