Files
BYW-VoiceHub/app/components/UI/WarpCanvas.vue
T

308 lines
9.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="absolute inset-0 z-0 bg-bg-primary overflow-hidden">
<canvas
ref="canvasRef"
class="w-full h-full block touch-none pointer-events-auto"
/>
<div
class="absolute inset-0 pointer-events-none"
:style="{ background: `radial-gradient(circle_at_center,transparent_45%,${glowColors.overlayEnd} 100%)` }"
/>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useTheme } from '~/composables/useTheme'
const props = defineProps({
settings: { type: Object, required: true },
isAccelerating: { type: Boolean, required: true },
currentProgress: { type: Number, required: true }
})
const { isDark } = useTheme()
const canvasRef = ref(null)
const settingsRef = ref(props.settings)
const isAcceleratingRef = ref(props.isAccelerating)
watch(() => props.settings, (val) => { settingsRef.value = val })
watch(() => props.isAccelerating, (val) => { isAcceleratingRef.value = val })
watch(isDark, () => {
loadBrandColors()
// 主题切换后重绘光晕颜色
glowColors.load()
// 主题切换后重新生成星星颜色
stars.length = 0
for (let i = 0; i < 250; i++) {
stars.push(initStar({ z: Math.random() * 1000 }))
}
})
const pointer = { x: 0, y: 0, targetX: 0, targetY: 0 }
// Canvas 动画所需的状态,提升为模块级以便主题切换时重绘
const stars = []
// Canvas 2D API 不识别 CSS 变量,此处从 CSS 变量动态读取实际色值
const BRAND_COLORS = {}
function loadBrandColors(root) {
root = root || document.documentElement
const cs = getComputedStyle(root)
const keys = [
'warp-canvas-indigo', 'warp-canvas-primary', 'warp-canvas-primary-light', 'brand-blue-light',
'brand-green', 'brand-green-light', 'brand-red', 'brand-pink',
'brand-teal', 'brand-orange', 'brand-yellow-light',
'text-primary', 'text-primary-lighter', 'brand-cyan',
]
for (const key of keys) {
const val = cs.getPropertyValue(`--${key}`).trim()
if (val) BRAND_COLORS[key] = toHexValue(val)
}
}
if (typeof document !== 'undefined') {
loadBrandColors()
}
/** 将任意 CSS 颜色值统一转为 hex (#RRGGBB) */
function toHexValue(color) {
if (color.startsWith('#')) {
// #RGB 展开为 #RRGGBB
if (color.length === 4) {
return `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}`
}
return color
}
// rgb(r, g, b) / rgba(r, g, b, a)
const m = color.match(/rgba?\(\s*([\d.]+)\s*[,%]\s*([\d.]+)\s*[,%]\s*([\d.]+)/)
if (m) {
const r = parseInt(m[1], 10)
const g = parseInt(m[2], 10)
const b = parseInt(m[3], 10)
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`
}
// 兜底:原样返回
return color
}
/** 将 hex 颜色转为 rgba 数组 [r, g, b, a] */
function hexToRgba(hex) {
let h = hex.replace('#', '')
if (h.length === 3) h = `${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}`
const r = parseInt(h.substring(0, 2), 16)
const g = parseInt(h.substring(2, 4), 16)
const b = parseInt(h.substring(4, 6), 16)
return [r, g, b, 1]
}
/** 将 hex 颜色 + alpha 转为 rgba() 字符串 */
function hexWithAlpha(hex, alpha) {
const [r, g, b] = hexToRgba(hex)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
// 光晕颜色 — 统一从 CSS 变量 --app-loading-brand-primary 读取
const glowColors = {
color: '', // hex
overlayEnd: '', // 解析后的 rgba 字符串
load() {
const cs = getComputedStyle(document.documentElement)
const raw = cs.getPropertyValue('--app-loading-brand-primary').trim()
// 回退值与 SSR 默认主题(经典深色)的主色对齐
this.color = raw ? toHexValue(raw) : '#6366f1'
// 解析 --canvas-glow-overlay-end 的实际 rgba 值
const rawOverlay = cs.getPropertyValue('--canvas-glow-overlay-end').trim()
this.overlayEnd = rawOverlay || 'rgba(245, 245, 247, 0.7)'
}
}
if (typeof document !== 'undefined') {
glowColors.load()
}
function getRandomColor(base) {
const cold = ['warp-canvas-indigo', 'warp-canvas-primary', 'brand-blue-light', 'text-primary', 'warp-canvas-primary-light']
if (base === 'emerald') {
const greens = ['brand-green', 'brand-green-light', 'brand-cyan']
return toHex(greens[Math.floor(Math.random() * greens.length)])
}
if (base === 'cyberpunk') {
const pinks = ['brand-red', 'brand-pink', 'warp-canvas-primary', 'brand-teal']
return toHex(pinks[Math.floor(Math.random() * pinks.length)])
}
if (base === 'sunset') {
const warm = ['brand-orange', 'brand-red', 'text-primary-lighter', 'brand-yellow-light', 'brand-pink']
return toHex(warm[Math.floor(Math.random() * warm.length)])
}
return toHex(cold[Math.floor(Math.random() * cold.length)])
}
function toHex(color) {
// 已经是 hex 直接返回
if (color.startsWith('#')) return color
// 从映射表取(已统一为 hex)
return BRAND_COLORS[color] ?? glowColors.color
}
function initStar(partial) {
partial = partial || {}
return {
x: partial.x ?? (Math.random() - 0.5) * 1000,
y: partial.y ?? (Math.random() - 0.5) * 1000,
z: partial.z ?? Math.random() * 1000 + 10,
prevZ: partial.z ?? 1000,
color: getRandomColor('indigo'),
size: Math.random() * 1.5 + 1.2,
angle: Math.random() * Math.PI * 2,
}
}
onMounted(() => {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
let animationId
let width = 0
let height = 0
const handleResize = () => {
const parent = canvas.parentElement
if (parent) {
width = parent.clientWidth
height = parent.clientHeight
canvas.width = width
canvas.height = height
}
}
const resizeObserver = new ResizeObserver(() => handleResize())
if (canvas.parentElement) {
resizeObserver.observe(canvas.parentElement)
}
handleResize()
for (let i = 0; i < 250; i++) {
stars.push(initStar({ z: Math.random() * 1000 }))
}
let speedFactor = 1.0
let thicknessFactor = 1.2
let glowFactor = 0.12
let radialGlowOpacity = 0.2
const handleMouseMove = (e) => {
const halfW = window.innerWidth / 2
const halfH = window.innerHeight / 2
pointer.targetX = (e.clientX - halfW) / halfW
pointer.targetY = (e.clientY - halfH) / halfH
}
const handleTouchMove = (e) => {
if (e.touches.length > 0) {
const touch = e.touches[0]
const halfW = window.innerWidth / 2
const halfH = window.innerHeight / 2
pointer.targetX = (touch.clientX - halfW) / halfW
pointer.targetY = (touch.clientY - halfH) / halfH
}
}
const handleMouseLeave = () => {
pointer.targetX = 0
pointer.targetY = 0
}
if (props.settings.interactive) {
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('touchmove', handleTouchMove, { passive: true })
window.addEventListener('mouseleave', handleMouseLeave)
}
const tick = () => {
const currentSettings = settingsRef.value
const accelerating = isAcceleratingRef.value
const targetSpeedFactor = accelerating ? 3.2 : 1.0
const targetThicknessFactor = accelerating ? 2.2 : 1.2
const targetGlowFactor = accelerating ? 0.26 : 0.12
const targetRadialGlowOpacity = accelerating ? 0.42 : 0.2
speedFactor += (targetSpeedFactor - speedFactor) * 0.07
thicknessFactor += (targetThicknessFactor - thicknessFactor) * 0.07
glowFactor += (targetGlowFactor - glowFactor) * 0.07
radialGlowOpacity += (targetRadialGlowOpacity - radialGlowOpacity) * 0.07
pointer.x += (pointer.targetX - pointer.x) * 0.08
pointer.y += (pointer.targetY - pointer.y) * 0.08
ctx.save()
// 从 CSS 变量读取背景叠加色
const bgOverlay = getComputedStyle(document.documentElement)
.getPropertyValue('--canvas-bg-overlay').trim()
ctx.fillStyle = bgOverlay || 'rgba(7, 7, 9, 0.18)'
ctx.fillRect(0, 0, width, height)
const centerX = width / 2 + pointer.x * (width * 0.12)
const centerY = height / 2 + pointer.y * (height * 0.12)
const currentSpeed = 7 * currentSettings.speedMultiplier * speedFactor
stars.forEach((star) => {
star.prevZ = star.z
star.z -= currentSpeed
if (star.z <= 0) {
Object.assign(star, initStar({ z: 1000 }))
star.prevZ = star.z
}
const scaleFactor = 300
const screenX = (star.x / star.z) * scaleFactor + centerX
const screenY = (star.y / star.z) * scaleFactor + centerY
const prevScreenX = (star.x / star.prevZ) * scaleFactor + centerX
const prevScreenY = (star.y / star.prevZ) * scaleFactor + centerY
if (screenX < -200 || screenX > width + 200 || screenY < -200 || screenY > height + 200) return
const depthAlpha = Math.min(1, (1000 - star.z) / 800) * 0.85
ctx.strokeStyle = star.color
ctx.beginPath()
ctx.lineWidth = star.size * thicknessFactor * (1 - star.z / 1000)
ctx.globalAlpha = depthAlpha
ctx.moveTo(prevScreenX, prevScreenY)
ctx.lineTo(screenX, screenY)
ctx.stroke()
})
if (currentSettings.glowEffect) {
ctx.globalAlpha = glowFactor
const grad = ctx.createRadialGradient(centerX, centerY, 5, centerX, centerY, Math.max(width, height) * 0.75)
grad.addColorStop(0, hexWithAlpha(glowColors.color, radialGlowOpacity))
grad.addColorStop(0.5, hexWithAlpha(glowColors.color, 0.04))
grad.addColorStop(1, 'transparent')
ctx.fillStyle = grad
ctx.fillRect(0, 0, width, height)
}
ctx.restore()
animationId = requestAnimationFrame(tick)
}
animationId = requestAnimationFrame(tick)
onUnmounted(() => {
cancelAnimationFrame(animationId)
resizeObserver.disconnect()
if (props.settings.interactive) {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('touchmove', handleTouchMove)
window.removeEventListener('mouseleave', handleMouseLeave)
}
})
})
</script>