// 点阵背景动画引擎 import { animate, cancelFrame, frame, type AnimationPlaybackControls, type FrameData } from "motion"; import type { RipplePreset, RippleTone } from "@/constants/motion"; export type DotFieldConfig = { /** 点间距与首个点的偏移,需与 CSS 点阵背景一致 */ spacing: number; offset: number; hoverRadius: number; decayMs: number; baseRadius: number; litRadius: number; colors: Record; }; export type DotField = { resize: (width: number, height: number, dpr: number) => void; /** 光标位置(画布坐标),连续调用会沿轨迹点亮 */ setPointer: (x: number, y: number) => void; clearPointer: () => void; /** 从 origin 扩散一圈(或多圈)涟漪,全部播完后 resolve */ ripple: ( origin: { x: number; y: number }, preset: RipplePreset ) => Promise; destroy: () => void; }; type Ring = { x: number; y: number; radius: number; maxRadius: number; preset: RipplePreset; }; const TONES: RippleTone[] = ["primary", "destructive"]; /** 透明度分档数:同档同色的点合并成一条路径绘制 */ const LEVELS = 6; const THRESHOLD = 0.03; /** * 画布点阵场:只绘制被点亮的点,底层静态点由 CSS 背景负责。 * 光标与涟漪向能量场注入能量,能量按指数衰减形成余辉;无能量时停止帧循环。 */ export const createDotField = ( canvas: HTMLCanvasElement, config: DotFieldConfig ): DotField => { const context = canvas.getContext("2d"); const { spacing, offset } = config; let width = 0; let height = 0; let cols = 0; let rows = 0; let energy = new Float32Array(0); let tone = new Uint8Array(0); let running = false; let last: { x: number; y: number } | null = null; const trail: { x: number; y: number }[] = []; const rings = new Set(); const animations = new Set(); /** 在 (cx, cy) 周围 reach 范围内按 falloff(距离) 注入能量 */ const inject = ( cx: number, cy: number, reach: number, toneIndex: number, falloff: (d: number) => number ) => { const c0 = Math.max(0, Math.floor((cx - reach - offset) / spacing)); const c1 = Math.min(cols - 1, Math.ceil((cx + reach - offset) / spacing)); const r0 = Math.max(0, Math.floor((cy - reach - offset) / spacing)); const r1 = Math.min(rows - 1, Math.ceil((cy + reach - offset) / spacing)); for (let row = r0; row <= r1; row += 1) { const dy = offset + row * spacing - cy; for (let col = c0; col <= c1; col += 1) { const value = falloff(Math.hypot(offset + col * spacing - cx, dy)); const index = row * cols + col; if (value > energy[index]) { energy[index] = value; tone[index] = toneIndex; } } } }; const hoverFalloff = (d: number) => d < config.hoverRadius ? (1 - d / config.hoverRadius) ** 2 : 0; /** 沿光标轨迹按半径一半的步长插值注入,快速划过也能连成一条 */ const injectTrail = () => { const step = config.hoverRadius / 2; for (const point of trail) { const from = last ?? point; const steps = Math.max( 1, Math.ceil(Math.hypot(point.x - from.x, point.y - from.y) / step) ); for (let s = 1; s <= steps; s += 1) { const t = s / steps; inject( from.x + (point.x - from.x) * t, from.y + (point.y - from.y) * t, config.hoverRadius, 0, hoverFalloff ); } last = point; } trail.length = 0; }; const injectRing = (ring: Ring) => { if (ring.radius < 1) return; const half = ring.preset.width / 2; const fade = 1 - ring.radius / ring.maxRadius; const toneIndex = TONES.indexOf(ring.preset.tone); inject(ring.x, ring.y, ring.radius + half, toneIndex, (d) => { const gap = Math.abs(d - ring.radius); return gap < half ? ring.preset.strength * (1 - gap / half) * fade : 0; }); }; const draw = () => { if (!context) return; context.clearRect(0, 0, width, height); const buckets: number[][] = Array.from( { length: TONES.length * LEVELS }, () => [] ); let active = false; for (let index = 0; index < energy.length; index += 1) { const value = energy[index]; if (value < THRESHOLD) continue; active = true; const level = Math.min(LEVELS, Math.ceil(value * LEVELS)); buckets[tone[index] * LEVELS + level - 1].push(index); } buckets.forEach((indices, bucket) => { if (indices.length === 0) return; const level = (bucket % LEVELS) + 1; const radius = config.baseRadius + ((config.litRadius - config.baseRadius) * level) / LEVELS; context.globalAlpha = level / LEVELS; context.fillStyle = config.colors[TONES[Math.floor(bucket / LEVELS)]]; context.beginPath(); for (const index of indices) { const x = offset + (index % cols) * spacing; const y = offset + Math.floor(index / cols) * spacing; context.moveTo(x + radius, y); context.arc(x, y, radius, 0, Math.PI * 2); } context.fill(); }); context.globalAlpha = 1; return active; }; const tick = ({ delta }: FrameData) => { const decay = Math.exp(-delta / config.decayMs); for (let index = 0; index < energy.length; index += 1) energy[index] *= decay; injectTrail(); rings.forEach(injectRing); const active = draw(); if (!active && rings.size === 0 && trail.length === 0) stop(); }; const start = () => { if (running || !context) return; running = true; frame.render(tick, true); }; const stop = () => { running = false; cancelFrame(tick); }; return { resize: (nextWidth, nextHeight, dpr) => { width = nextWidth; height = nextHeight; canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); context?.setTransform(dpr, 0, 0, dpr, 0, 0); cols = Math.max(0, Math.ceil((width - offset) / spacing)); rows = Math.max(0, Math.ceil((height - offset) / spacing)); energy = new Float32Array(cols * rows); tone = new Uint8Array(cols * rows); }, setPointer: (x, y) => { trail.push({ x, y }); start(); }, clearPointer: () => { last = null; }, ripple: (origin, preset) => { const maxRadius = preset.maxRadius ?? Math.hypot( Math.max(origin.x, width - origin.x), Math.max(origin.y, height - origin.y) ) + preset.width; const count = preset.rings?.count ?? 1; const gap = preset.rings?.gap ?? 0; start(); const runs = Array.from({ length: count }, (_, index) => { const ring: Ring = { ...origin, radius: 0, maxRadius, preset }; rings.add(ring); const controls = animate(0, maxRadius, { duration: preset.duration, delay: index * gap, ease: "easeOut", onUpdate: (radius) => { ring.radius = radius; } }); animations.add(controls); return new Promise((resolve) => { controls.then(() => { rings.delete(ring); animations.delete(controls); resolve(); }); }); }); return Promise.all(runs).then(() => undefined); }, destroy: () => { stop(); animations.forEach((controls) => controls.stop()); animations.clear(); rings.clear(); } }; };