285 lines
6.1 KiB
Vue
285 lines
6.1 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* C 端氛围层:暗色星空 + 偶发流星;浅色金色光斑漂移。
|
||
* 粒子数约为 PC StarBackground 的 1/3,挂在 AppPageShell 底层。
|
||
* 使用旧版 canvas 上下文以保证各端兼容;不可点击(pointer-events: none)。
|
||
*/
|
||
import { getCurrentInstance, nextTick, onBeforeUnmount, onMounted, watch } from 'vue'
|
||
import { onHide, onShow } from '@dcloudio/uni-app'
|
||
import { useTheme } from '@/composables/useTheme'
|
||
|
||
const { isDark } = useTheme()
|
||
const canvasId = `ambient-${Math.random().toString(36).slice(2, 9)}`
|
||
|
||
interface Star {
|
||
x: number
|
||
y: number
|
||
r: number
|
||
a: number
|
||
out: boolean
|
||
sp: number
|
||
}
|
||
interface Meteor {
|
||
x: number
|
||
y: number
|
||
len: number
|
||
sp: number
|
||
a: number
|
||
}
|
||
interface Orb {
|
||
bx: number
|
||
by: number
|
||
r: number
|
||
ampX: number
|
||
ampY: number
|
||
phX: number
|
||
phY: number
|
||
sp: number
|
||
color: string
|
||
}
|
||
interface Dust {
|
||
x: number
|
||
y: number
|
||
r: number
|
||
a: number
|
||
out: boolean
|
||
sp: number
|
||
base: number
|
||
}
|
||
|
||
let ctx: UniApp.CanvasContext | null = null
|
||
let width = 0
|
||
let height = 0
|
||
let running = false
|
||
let timer: ReturnType<typeof setTimeout> | null = null
|
||
let time = 0
|
||
const stars: Star[] = []
|
||
const meteors: Meteor[] = []
|
||
const orbs: Orb[] = []
|
||
const dust: Dust[] = []
|
||
|
||
const clearPools = () => {
|
||
stars.length = 0
|
||
meteors.length = 0
|
||
orbs.length = 0
|
||
dust.length = 0
|
||
}
|
||
|
||
const initDark = () => {
|
||
clearPools()
|
||
for (let i = 0; i < 55; i++) {
|
||
stars.push({
|
||
x: Math.random() * width,
|
||
y: Math.random() * height,
|
||
r: Math.random() * 1.4 + 0.3,
|
||
a: Math.random(),
|
||
out: Math.random() > 0.5,
|
||
sp: Math.random() * 0.02 + 0.005,
|
||
})
|
||
}
|
||
}
|
||
|
||
const initLight = () => {
|
||
clearPools()
|
||
const palette = [
|
||
'rgba(212,179,131,0.18)',
|
||
'rgba(196,150,90,0.14)',
|
||
'rgba(232,200,150,0.16)',
|
||
]
|
||
const places = [
|
||
{ bx: 0.18, by: 0.22 },
|
||
{ bx: 0.8, by: 0.38 },
|
||
{ bx: 0.42, by: 0.78 },
|
||
]
|
||
places.forEach((p, i) => {
|
||
orbs.push({
|
||
bx: p.bx * width,
|
||
by: p.by * height,
|
||
r: Math.min(width, height) * (0.22 + Math.random() * 0.1),
|
||
ampX: 28 + Math.random() * 40,
|
||
ampY: 22 + Math.random() * 36,
|
||
phX: Math.random() * Math.PI * 2,
|
||
phY: Math.random() * Math.PI * 2,
|
||
sp: 0.0003 + Math.random() * 0.0002,
|
||
color: palette[i % palette.length],
|
||
})
|
||
})
|
||
for (let i = 0; i < 28; i++) {
|
||
const base = Math.random() * 0.3 + 0.12
|
||
dust.push({
|
||
x: Math.random() * width,
|
||
y: Math.random() * height,
|
||
r: Math.random() * 1.6 + 0.4,
|
||
a: base,
|
||
out: Math.random() > 0.5,
|
||
sp: Math.random() * 0.008 + 0.003,
|
||
base,
|
||
})
|
||
}
|
||
}
|
||
|
||
const initTheme = () => {
|
||
if (isDark.value) initDark()
|
||
else initLight()
|
||
}
|
||
|
||
const draw = () => {
|
||
if (!ctx || !running) return
|
||
time += 1
|
||
ctx.clearRect(0, 0, width, height)
|
||
|
||
if (isDark.value) {
|
||
stars.forEach((s) => {
|
||
if (s.out) {
|
||
s.a -= s.sp
|
||
if (s.a <= 0.1) s.out = false
|
||
}
|
||
else {
|
||
s.a += s.sp
|
||
if (s.a >= 1) s.out = true
|
||
}
|
||
ctx!.beginPath()
|
||
ctx!.arc(s.x, s.y, s.r, 0, Math.PI * 2)
|
||
ctx!.setFillStyle(`rgba(255,255,255,${Math.max(0.08, s.a)})`)
|
||
ctx!.fill()
|
||
})
|
||
if (Math.random() < 0.008) {
|
||
meteors.push({
|
||
x: Math.random() * width,
|
||
y: 0,
|
||
len: Math.random() * 60 + 24,
|
||
sp: Math.random() * 8 + 5,
|
||
a: 1,
|
||
})
|
||
}
|
||
for (let i = meteors.length - 1; i >= 0; i--) {
|
||
const m = meteors[i]
|
||
const ex = m.x - m.len * 0.7
|
||
const ey = m.y - m.len * 0.7
|
||
ctx.beginPath()
|
||
ctx.moveTo(m.x, m.y)
|
||
ctx.lineTo(ex, ey)
|
||
ctx.setStrokeStyle(`rgba(255,255,255,${m.a})`)
|
||
ctx.setLineWidth(1.2)
|
||
ctx.stroke()
|
||
m.x += m.sp * 0.7
|
||
m.y += m.sp * 0.7
|
||
m.a -= 0.012
|
||
if (m.a <= 0 || m.y > height) meteors.splice(i, 1)
|
||
}
|
||
}
|
||
else {
|
||
orbs.forEach((o) => {
|
||
const x = o.bx + Math.sin(time * o.sp + o.phX) * o.ampX
|
||
const y = o.by + Math.cos(time * o.sp + o.phY) * o.ampY
|
||
const g = ctx!.createCircularGradient(x, y, o.r)
|
||
g.addColorStop(0, o.color)
|
||
g.addColorStop(1, 'rgba(212,179,131,0)')
|
||
ctx!.beginPath()
|
||
ctx!.arc(x, y, o.r, 0, Math.PI * 2)
|
||
ctx!.setFillStyle(g)
|
||
ctx!.fill()
|
||
})
|
||
dust.forEach((d) => {
|
||
if (d.out) {
|
||
d.a -= d.sp
|
||
if (d.a <= 0.05) d.out = false
|
||
}
|
||
else {
|
||
d.a += d.sp
|
||
if (d.a >= d.base) d.out = true
|
||
}
|
||
ctx!.beginPath()
|
||
ctx!.arc(d.x, d.y, d.r, 0, Math.PI * 2)
|
||
ctx!.setFillStyle(`rgba(196,150,90,${d.a})`)
|
||
ctx!.fill()
|
||
})
|
||
}
|
||
|
||
ctx.draw()
|
||
timer = setTimeout(() => {
|
||
if (running) draw()
|
||
}, 48)
|
||
}
|
||
|
||
const stop = () => {
|
||
running = false
|
||
if (timer) {
|
||
clearTimeout(timer)
|
||
timer = null
|
||
}
|
||
}
|
||
|
||
const start = () => {
|
||
stop()
|
||
running = true
|
||
draw()
|
||
}
|
||
|
||
const setup = () => {
|
||
const inst = getCurrentInstance()
|
||
uni.getSystemInfo({
|
||
success: (info) => {
|
||
width = info.windowWidth
|
||
height = info.windowHeight
|
||
nextTick(() => {
|
||
ctx = uni.createCanvasContext(canvasId, inst?.proxy as never)
|
||
initTheme()
|
||
start()
|
||
})
|
||
},
|
||
})
|
||
}
|
||
|
||
onMounted(setup)
|
||
onShow(() => {
|
||
if (ctx) start()
|
||
})
|
||
onHide(stop)
|
||
onBeforeUnmount(stop)
|
||
|
||
watch(isDark, () => {
|
||
initTheme()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<view class="ambient" aria-hidden="true">
|
||
<canvas
|
||
:canvas-id="canvasId"
|
||
class="ambient-canvas"
|
||
:style="{ width: '100%', height: '100%' }"
|
||
disable-scroll
|
||
/>
|
||
<view class="noise" :class="isDark ? 'noise--dark' : 'noise--light'" />
|
||
</view>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
.ambient {
|
||
position: fixed;
|
||
left: 0;
|
||
top: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 0;
|
||
pointer-events: none;
|
||
overflow: hidden;
|
||
}
|
||
.ambient-canvas {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
}
|
||
.noise {
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
/* 轻量噪点:用半透明叠层近似胶片感,避免额外图片体积 */
|
||
background-image: radial-gradient(rgba(127, 127, 127, 0.35) 0.5px, transparent 0.5px);
|
||
background-size: 4px 4px;
|
||
}
|
||
.noise--light { opacity: 0.045; mix-blend-mode: multiply; }
|
||
.noise--dark { opacity: 0.06; mix-blend-mode: soft-light; }
|
||
</style>
|