| New file |
| | |
| | | import Taro from '@tarojs/taro'; |
| | | import { Canvas } from '@tarojs/components'; |
| | | import { |
| | | forwardRef, |
| | | useEffect, |
| | | useImperativeHandle, |
| | | useRef, |
| | | } from 'react'; |
| | | import ErrorBoundary from './error-boundary'; |
| | | import './index.less'; |
| | | |
| | | // 粒子礼花动效:小程序原生 Canvas 2D 自绘,复刻 react-canvas-confetti 的 fireworks 预设。 |
| | | // 参数命名与 canvas-confetti 对齐,方便与 H5 侧对照调参。 |
| | | |
| | | export type FireworkShape = 'square' | 'circle'; |
| | | |
| | | export type FireworksOptions = { |
| | | /** 单簇粒子数量(连发时随剩余时间递减) */ |
| | | particleCount?: number; |
| | | /** 散布角度(度),360 即向四周炸开 */ |
| | | spread?: number; |
| | | /** 发射方向角度(度),90 表示向上(spread=360 时无影响) */ |
| | | angle?: number; |
| | | /** 初始速度 */ |
| | | startVelocity?: number; |
| | | /** 重力(每帧对 vy 的增量) */ |
| | | gravity?: number; |
| | | /** 横向漂移 */ |
| | | drift?: number; |
| | | /** 每帧速度衰减(空气阻力) */ |
| | | decay?: number; |
| | | /** 粒子大小缩放(方片边长 = 6 * scalar) */ |
| | | scalar?: number; |
| | | /** 粒子存活帧数 */ |
| | | ticks?: number; |
| | | /** 颜色数组 */ |
| | | colors?: string[]; |
| | | /** 形状(单个),与 shapes 二选一 */ |
| | | shape?: FireworkShape; |
| | | /** 形状数组,随机取一,优先级高于 shape */ |
| | | shapes?: FireworkShape[]; |
| | | /** 发射中心点,取值 0~1 的相对坐标(x 向右、y 向下) */ |
| | | origin?: { x?: number; y?: number }; |
| | | /** 连发总时长(ms),仅 play 连发时生效 */ |
| | | duration?: number; |
| | | /** 两次发射间隔(ms),仅 play 连发时生效 */ |
| | | interval?: number; |
| | | }; |
| | | |
| | | export type FireworksRef = { |
| | | /** 连发礼花(在 duration 内每隔 interval 在随机位置发射一簇) */ |
| | | play: (options?: FireworksOptions) => void; |
| | | /** 单次发射一簇礼花(等价 canvas-confetti 的 confetti(options)) */ |
| | | shoot: (options?: FireworksOptions) => void; |
| | | /** 立即停止并清空 */ |
| | | stop: () => void; |
| | | }; |
| | | |
| | | type Props = { |
| | | /** canvas 层级 */ |
| | | zIndex?: number; |
| | | }; |
| | | |
| | | const DEFAULTS = { |
| | | particleCount: 80, |
| | | spread: 360, |
| | | angle: 90, |
| | | startVelocity: 28, |
| | | gravity: 0.85, |
| | | drift: 0, |
| | | decay: 0.9, |
| | | scalar: 1, |
| | | ticks: 150, |
| | | colors: ['#7FE6F2', '#F9A76A', '#95CF29'], |
| | | shapes: ['square'] as FireworkShape[], |
| | | origin: { x: 0.5, y: 0.5 }, |
| | | duration: 2500, |
| | | interval: 250, |
| | | }; |
| | | |
| | | type NormalizedOptions = { |
| | | particleCount: number; |
| | | spread: number; |
| | | angle: number; |
| | | startVelocity: number; |
| | | gravity: number; |
| | | drift: number; |
| | | decay: number; |
| | | scalar: number; |
| | | ticks: number; |
| | | colors: string[]; |
| | | shapes: FireworkShape[]; |
| | | origin: { x: number; y: number }; |
| | | duration: number; |
| | | interval: number; |
| | | }; |
| | | |
| | | // 合并默认值 + 兼容 shape 单值写法,产出归一化配置 |
| | | function normalize(options: FireworksOptions = {}): NormalizedOptions { |
| | | const shapes = options.shapes && options.shapes.length |
| | | ? options.shapes |
| | | : options.shape |
| | | ? [options.shape] |
| | | : DEFAULTS.shapes; |
| | | return { |
| | | particleCount: options.particleCount ?? DEFAULTS.particleCount, |
| | | spread: options.spread ?? DEFAULTS.spread, |
| | | angle: options.angle ?? DEFAULTS.angle, |
| | | startVelocity: options.startVelocity ?? DEFAULTS.startVelocity, |
| | | gravity: options.gravity ?? DEFAULTS.gravity, |
| | | drift: options.drift ?? DEFAULTS.drift, |
| | | decay: options.decay ?? DEFAULTS.decay, |
| | | scalar: options.scalar ?? DEFAULTS.scalar, |
| | | ticks: options.ticks ?? DEFAULTS.ticks, |
| | | colors: options.colors ?? DEFAULTS.colors, |
| | | shapes, |
| | | origin: { |
| | | x: options.origin?.x ?? DEFAULTS.origin.x, |
| | | y: options.origin?.y ?? DEFAULTS.origin.y, |
| | | }, |
| | | duration: options.duration ?? DEFAULTS.duration, |
| | | interval: options.interval ?? DEFAULTS.interval, |
| | | }; |
| | | } |
| | | |
| | | function rand(min: number, max: number) { |
| | | return Math.random() * (max - min) + min; |
| | | } |
| | | |
| | | // 粒子模型对齐 canvas-confetti:每片纸屑带 wobble(飘动)+ tiltAngle(翻转), |
| | | // 因此绘制成会抖动、翻面的四边形,而非静止方块,视觉上更接近真实纸屑。 |
| | | type Particle = { |
| | | x: number; |
| | | y: number; |
| | | velocity: number; // 爆发初速(逐帧按 decay 衰减) |
| | | angle2D: number; // 发射方向(弧度) |
| | | wobble: number; // 飘动相位 |
| | | wobbleSpeed: number; |
| | | wobbleX: number; |
| | | wobbleY: number; |
| | | tiltAngle: number; // 翻转角 |
| | | gravity: number; // 每帧恒定下落量(= opt.gravity * 3) |
| | | decay: number; |
| | | drift: number; |
| | | scalar: number; |
| | | color: string; |
| | | shape: FireworkShape; |
| | | tick: number; |
| | | totalTicks: number; |
| | | }; |
| | | |
| | | // 生成一簇粒子。angle 为发射主方向(度,90 向上),spread 为散布角度。 |
| | | // angle2D = -angle ± spread/2,屏幕 y 向下,故 angle=90 时 sin(angle2D)=-1 表现为向上。 |
| | | // 初速取 sqrt(random)×startVelocity:用平方根让粒子按「面积」均匀分布, |
| | | // 避免大量粒子挤在中心(若用线性 random,低速粒子过多会在中心结团); |
| | | // 同时最小值仍接近 0,保留少量粒子填充中心,整体从中心向四周均匀铺满屏幕。 |
| | | function createBurst(cx: number, cy: number, particleCount: number, opt: NormalizedOptions) { |
| | | const list: Particle[] = []; |
| | | const radAngle = (opt.angle * Math.PI) / 180; |
| | | const radSpread = (opt.spread * Math.PI) / 180; |
| | | for (let i = 0; i < particleCount; i++) { |
| | | list.push({ |
| | | x: cx, |
| | | y: cy, |
| | | velocity: Math.sqrt(Math.random()) * opt.startVelocity, |
| | | angle2D: -radAngle + (0.5 * radSpread - Math.random() * radSpread), |
| | | wobble: Math.random() * 10, |
| | | wobbleSpeed: Math.min(0.11, Math.random() * 0.1 + 0.05), |
| | | wobbleX: 0, |
| | | wobbleY: 0, |
| | | tiltAngle: (Math.random() * 0.5 + 0.25) * Math.PI, |
| | | gravity: opt.gravity * 3, |
| | | decay: opt.decay, |
| | | drift: opt.drift, |
| | | // 每片纸屑大小在 scalar 基础上随机(0.5×~1.8×),大的一端更大,呈现有大有小的层次感 |
| | | scalar: opt.scalar * rand(0.5, 1.8), |
| | | color: opt.colors[(Math.random() * opt.colors.length) | 0], |
| | | shape: opt.shapes[(Math.random() * opt.shapes.length) | 0], |
| | | tick: 0, |
| | | totalTicks: opt.ticks, |
| | | }); |
| | | } |
| | | return list; |
| | | } |
| | | |
| | | // canvas 节点 id 前缀。真正的 id 会在每个实例内拼接自增序号 + 随机串,保证同页多个 |
| | | // <Fireworks> 实例不会因固定 id 冲突而 createSelectorQuery 选错节点(class 仍固定用于样式)。 |
| | | const CANVAS_ID_PREFIX = 'xp-fireworks-canvas'; |
| | | // 实例自增序号:模块级,配合随机串生成唯一 canvas id |
| | | let fireworksInstanceSeq = 0; |
| | | |
| | | const FireworksInner = forwardRef<FireworksRef, Props>((props, ref) => { |
| | | const canvasRef = useRef<any>(null); |
| | | const ctxRef = useRef<any>(null); |
| | | const sizeRef = useRef<{ w: number; h: number }>({ w: 0, h: 0 }); |
| | | const particlesRef = useRef<Particle[]>([]); |
| | | const runningRef = useRef(false); |
| | | // 世代号:stop() 递增,loop 每帧比对——若世代变化则自终止,解决 stop() 停不掉已排队的 rAF |
| | | const generationRef = useRef(0); |
| | | // 当前发射器:play 设 fireRef,shoot 设 null;运行中更换 fireRef 即可热替换发射逻辑,解决吞新 play() |
| | | const fireRef = useRef<((now: number) => boolean) | null>(null); |
| | | // 本实例唯一的 canvas id(惰性生成一次,跨渲染稳定) |
| | | const canvasIdRef = useRef<string>(''); |
| | | if (!canvasIdRef.current) { |
| | | fireworksInstanceSeq += 1; |
| | | canvasIdRef.current = `${CANVAS_ID_PREFIX}-${fireworksInstanceSeq}-${Math.random().toString(36).slice(2, 8)}`; |
| | | } |
| | | |
| | | // 统一的错误吞噬:礼花只是锦上添花的动效,任何异常都不应外溢影响页面,出错即静默不显示 |
| | | const swallow = (err: unknown) => { |
| | | runningRef.current = false; |
| | | // eslint-disable-next-line no-console |
| | | console.warn('[Fireworks] 已忽略动效内部错误:', err); |
| | | }; |
| | | |
| | | // 包裹异步入口方法:错误边界捕获不到 rAF/Promise 等异步栈里的错误,这里统一兜底吞掉 |
| | | const safe = <A extends any[]>(fn: (...args: A) => void | Promise<void>) => (...args: A) => { |
| | | try { |
| | | const ret = fn(...args); |
| | | if (ret && typeof (ret as Promise<void>).catch === 'function') { |
| | | (ret as Promise<void>).catch(swallow); |
| | | } |
| | | } catch (err) { |
| | | swallow(err); |
| | | } |
| | | }; |
| | | |
| | | // 初始化 canvas 节点(Canvas 2D)。同步异常会 reject 由 safe 兜底; |
| | | // exec 回调是异步栈,safe 捕获不到,故回调内单独 try/catch。 |
| | | const initCanvas = () => |
| | | new Promise<void>((resolve) => { |
| | | if (canvasRef.current) { |
| | | resolve(); |
| | | return; |
| | | } |
| | | const query = Taro.createSelectorQuery(); |
| | | query |
| | | .select(`#${canvasIdRef.current}`) |
| | | .fields({ node: true, size: true }) |
| | | .exec((res) => { |
| | | try { |
| | | if (!res || !res[0] || !res[0].node) { |
| | | resolve(); |
| | | return; |
| | | } |
| | | const canvas = res[0].node; |
| | | const ctx = canvas.getContext('2d'); |
| | | const dpr = (Taro.getSystemInfoSync() || {}).pixelRatio || 1; |
| | | canvas.width = res[0].width * dpr; |
| | | canvas.height = res[0].height * dpr; |
| | | ctx.scale(dpr, dpr); |
| | | canvasRef.current = canvas; |
| | | ctxRef.current = ctx; |
| | | sizeRef.current = { w: res[0].width, h: res[0].height }; |
| | | resolve(); |
| | | } catch (err) { |
| | | swallow(err); |
| | | resolve(); |
| | | } |
| | | }); |
| | | }); |
| | | |
| | | // 逐帧更新并绘制单个粒子,返回 false 表示已消亡。逻辑对齐 canvas-confetti 的 updateFizz。 |
| | | const updateAndDraw = (ctx: any, p: Particle) => { |
| | | // 位置:沿 angle2D 前进(速度按 decay 衰减)+ 恒定重力下落 + 横向漂移 |
| | | p.x += Math.cos(p.angle2D) * p.velocity + p.drift; |
| | | p.y += Math.sin(p.angle2D) * p.velocity + p.gravity; |
| | | p.velocity *= p.decay; |
| | | |
| | | // wobble:让粒子飘动,绘制成随时间抖动的四边形 |
| | | p.wobble += p.wobbleSpeed; |
| | | p.wobbleX = p.x + 10 * p.scalar * Math.cos(p.wobble); |
| | | p.wobbleY = p.y + 10 * p.scalar * Math.sin(p.wobble); |
| | | |
| | | // tiltAngle:让纸屑翻面(宽度周期性收缩,模拟 3D 翻转) |
| | | p.tiltAngle += 0.1; |
| | | const tiltSin = Math.sin(p.tiltAngle); |
| | | const tiltCos = Math.cos(p.tiltAngle); |
| | | const random = Math.random() + 2; // 对齐 canvas-confetti,决定纸片另一维的尺寸 |
| | | const x1 = p.x + random * tiltCos; |
| | | const y1 = p.y + random * tiltSin; |
| | | const x2 = p.wobbleX + random * tiltCos; |
| | | const y2 = p.wobbleY + random * tiltSin; |
| | | |
| | | const progress = p.tick / p.totalTicks; |
| | | p.tick++; |
| | | if (p.tick >= p.totalTicks) return false; |
| | | |
| | | ctx.globalAlpha = 1 - progress; |
| | | ctx.fillStyle = p.color; |
| | | ctx.beginPath(); |
| | | if (p.shape === 'circle') { |
| | | // 椭圆:随 wobble/tilt 改变长短轴,呈现翻转的圆片(ovalScalar=0.6) |
| | | ctx.ellipse( |
| | | p.x, |
| | | p.y, |
| | | Math.abs(x2 - x1) * 0.6, |
| | | Math.abs(y2 - y1) * 0.6, |
| | | (Math.PI / 10) * p.wobble, |
| | | 0, |
| | | 2 * Math.PI, |
| | | ); |
| | | } else { |
| | | // 方片:由 4 个随 wobble/tilt 抖动的顶点构成的四边形,会飘动翻面 |
| | | ctx.moveTo(Math.floor(p.x), Math.floor(p.y)); |
| | | ctx.lineTo(Math.floor(p.wobbleX), Math.floor(y1)); |
| | | ctx.lineTo(Math.floor(x2), Math.floor(y2)); |
| | | ctx.lineTo(Math.floor(x1), Math.floor(p.wobbleY)); |
| | | } |
| | | ctx.closePath(); |
| | | ctx.fill(); |
| | | return true; |
| | | }; |
| | | |
| | | // 逐帧渲染循环:读取 fireRef 驱动发射、比对 generationRef 自终止,无粒子且无发射时自动停止 rAF |
| | | const startLoop = () => { |
| | | const canvas = canvasRef.current; |
| | | const ctx = ctxRef.current; |
| | | if (!canvas || !ctx) return; |
| | | if (runningRef.current) return; // 循环唯一,发射器靠 fireRef 热替换,新 play 无需重启循环 |
| | | runningRef.current = true; |
| | | const { w, h } = sizeRef.current; |
| | | const myGen = generationRef.current; |
| | | |
| | | const loop = () => { |
| | | // 单帧出错时吞掉并终止循环,清空粒子,避免错误反复外溢 |
| | | try { |
| | | // 世代号比对:stop() 递增后,已排队的下一帧检测到变化即自终止 |
| | | if (myGen !== generationRef.current) { |
| | | runningRef.current = false; |
| | | return; |
| | | } |
| | | const now = Date.now(); |
| | | const keepFiring = fireRef.current ? fireRef.current(now) : false; |
| | | |
| | | ctx.clearRect(0, 0, w, h); |
| | | particlesRef.current = particlesRef.current.filter((p) => updateAndDraw(ctx, p)); |
| | | ctx.globalAlpha = 1; |
| | | |
| | | if (particlesRef.current.length || keepFiring) { |
| | | canvas.requestAnimationFrame(loop); |
| | | } else { |
| | | runningRef.current = false; |
| | | fireRef.current = null; |
| | | } |
| | | } catch (err) { |
| | | particlesRef.current = []; |
| | | try { |
| | | ctx.clearRect(0, 0, w, h); |
| | | } catch (_) { |
| | | // 清屏也失败则忽略 |
| | | } |
| | | swallow(err); |
| | | } |
| | | }; |
| | | |
| | | canvas.requestAnimationFrame(loop); |
| | | }; |
| | | |
| | | // 单次发射一簇礼花(safe 包裹,异步异常统一吞掉) |
| | | const shoot = safe(async (options: FireworksOptions = {}) => { |
| | | await initCanvas(); |
| | | if (!canvasRef.current || !ctxRef.current) return; |
| | | const opt = normalize(options); |
| | | const { w, h } = sizeRef.current; |
| | | const cx = opt.origin.x * w; |
| | | const cy = opt.origin.y * h; |
| | | fireRef.current = null; |
| | | particlesRef.current.push(...createBurst(cx, cy, opt.particleCount, opt)); |
| | | startLoop(); |
| | | }); |
| | | |
| | | // 连发礼花:在 duration 内每隔 interval 在随机位置发射一簇。 |
| | | // fireRef 先设后调 startLoop——即使循环已在运行也能热替换发射逻辑,不再吞新 play()。 |
| | | const play = safe(async (options: FireworksOptions = {}) => { |
| | | await initCanvas(); |
| | | if (!canvasRef.current || !ctxRef.current) return; |
| | | const opt = normalize(options); |
| | | const { w, h } = sizeRef.current; |
| | | const end = Date.now() + opt.duration; |
| | | let lastFire = 0; |
| | | |
| | | fireRef.current = (now) => { |
| | | if (now <= end && now - lastFire >= opt.interval) { |
| | | lastFire = now; |
| | | const remain = (end - now) / opt.duration; |
| | | const count = Math.max(1, Math.ceil(opt.particleCount * remain)); |
| | | const cx = rand(0.2, 0.8) * w; |
| | | const cy = rand(0.35, 0.5) * h; |
| | | particlesRef.current.push(...createBurst(cx, cy, count, opt)); |
| | | } |
| | | return now <= end; |
| | | }; |
| | | startLoop(); |
| | | }); |
| | | |
| | | const stop = safe(() => { |
| | | generationRef.current += 1; |
| | | runningRef.current = false; |
| | | fireRef.current = null; |
| | | particlesRef.current = []; |
| | | const ctx = ctxRef.current; |
| | | const { w, h } = sizeRef.current; |
| | | if (ctx) ctx.clearRect(0, 0, w, h); |
| | | }); |
| | | |
| | | useImperativeHandle(ref, () => ({ play, shoot, stop })); |
| | | |
| | | useEffect(() => { |
| | | return () => { |
| | | generationRef.current += 1; |
| | | runningRef.current = false; |
| | | fireRef.current = null; |
| | | particlesRef.current = []; |
| | | }; |
| | | }, []); |
| | | |
| | | return ( |
| | | <Canvas |
| | | type='2d' |
| | | id={canvasIdRef.current} |
| | | className='xp-fireworks-canvas' |
| | | style={props.zIndex != null ? { zIndex: props.zIndex } : undefined} |
| | | /> |
| | | ); |
| | | }); |
| | | |
| | | // 用错误边界包裹:捕获渲染/生命周期阶段的错误(异步错误由内部 safe 兜底),出错静默渲染为空。 |
| | | // ref 透传给内部组件,外部照常拿到 play / shoot / stop。 |
| | | const Fireworks = forwardRef<FireworksRef, Props>((props, ref) => ( |
| | | <ErrorBoundary> |
| | | <FireworksInner {...props} ref={ref} /> |
| | | </ErrorBoundary> |
| | | )); |
| | | |
| | | export default Fireworks; |