251 lines
7.4 KiB
TypeScript
251 lines
7.4 KiB
TypeScript
// 点阵背景动画引擎
|
|
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<RippleTone, string>;
|
|
};
|
|
|
|
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<void>;
|
|
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<Ring>();
|
|
const animations = new Set<AnimationPlaybackControls>();
|
|
|
|
/** 在 (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<void>((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();
|
|
}
|
|
};
|
|
};
|