import confetti from "canvas-confetti" import { getCurrentScope, onScopeDispose } from "vue" // 表情图标取自 @iconify 的 streamline-emojis 图标集,与项目内 用法保持一致 const EMOJI_SVGS = [ // 星星眼 '', // 点赞 '', // 火箭 '', ] // 将 emoji SVG 转成 confetti 可用的彩色位图 Shape,缓存后供重复调用复用 let emojiShapesPromise: Promise | null = null function svgToBitmapShape(svg: string): Promise { return new Promise((resolve, reject) => { const blob = new Blob([svg], { type: "image/svg+xml" }) const url = URL.createObjectURL(blob) const img = new Image() img.onload = async () => { const renderSize = 64 const baseUnit = 10 const canvas = document.createElement("canvas") canvas.width = renderSize canvas.height = renderSize const ctx = canvas.getContext("2d")! ctx.drawImage(img, 0, 0, renderSize, renderSize) URL.revokeObjectURL(url) try { const bitmap = await createImageBitmap(canvas) const scale = baseUnit / renderSize resolve({ type: "bitmap", bitmap, matrix: [scale, 0, 0, scale, (-renderSize * scale) / 2, (-renderSize * scale) / 2], } as unknown as confetti.Shape) } catch (e) { reject(e) } } img.onerror = reject img.src = url }) } function loadEmojiShapes(): Promise { if (!emojiShapesPromise) { emojiShapesPromise = Promise.all(EMOJI_SVGS.map(svgToBitmapShape)) } return emojiShapesPromise } const COLORS = { green: "#18a058", mint: "#2cc27a", gold: "#f0a020", lightGold: "#ffd36b", orange: "#f06a1a", pink: "#e8457f", lightPink: "#ff7aa8", blue: "#2080f0", purple: "#7d50e8", silver: "#8f99aa", } const PALETTES = [ [COLORS.green, COLORS.gold, COLORS.blue, COLORS.pink], [COLORS.gold, COLORS.orange, COLORS.pink], [COLORS.blue, COLORS.silver, COLORS.purple], [COLORS.pink, COLORS.gold, COLORS.mint], ] const RAINBOW = [COLORS.pink, COLORS.orange, COLORS.gold, COLORS.green, COLORS.blue, COLORS.purple] /** * 盖在弹层上面放。点评弹窗平时等烟花放完才出来(见 celebrate 的返回值),但学生在放的时候 * 点「下一题」,点评会被提前叫出来 —— 默认的 zIndex 100 在 Naive 弹层(2000 起)底下, * 剩下的半场就被遮罩挡住了。画布本身 pointer-events: none,不挡点评的点击。 */ const Z_INDEX = 3000 function pick(list: T[]): T { return list[Math.floor(Math.random() * list.length)] } // 对勾、爱心当粒子形状。matrix 直接给:不给的话 shapeFromPath 要扫 1000×1000 个点去量边界。 // 运行时它要的是数组(拿去 new DOMMatrix),类型声明写成了 DOMMatrix,只好转一下 let pathShapes: { check: confetti.Shape; heart: confetti.Shape } | null = null function loadPathShapes() { if (!pathShapes) { const matrix = [0.5, 0, 0, 0.5, -5, -5] as unknown as DOMMatrix pathShapes = { check: confetti.shapeFromPath({ path: "M1 11 L4 8 L8 12 L16 3 L19 6 L8 17 Z", matrix }), heart: confetti.shapeFromPath({ path: "M10 18 C2 12 0 7 3 4 C6 1 9 3 10 5 C11 3 14 1 17 4 C20 7 18 12 10 18 Z", matrix, }), } } return pathShapes } /** * 随机烟花效果 Composable * 十种彩纸风格的烟花,每次随机放一种(设计稿「做对了的庆祝」右边那块画板) */ export function useFireworks() { // 提前预加载表情包位图,避免首次触发「表情包烟花」时出现解码延迟 loadEmojiShapes() // 动画期间创建的定时器,组件卸载时统一清理,避免离开页面后动画继续 const timers = new Set>() let disposed = false // 这一场最后一次发射的 promise:canvas-confetti 同一块画布上的粒子是一场动画, // 后发的接进去,promise 在所有粒子都落完的时候才 resolve let lastShot: Promise | null = null // 还没发出去的异步准备(表情包位图在解码),算「还在放」 let preparing = 0 function runLater(fn: () => void, ms: number) { const id = setTimeout(() => { timers.delete(id) if (!disposed) fn() }, ms) timers.add(id) } /** 每 16ms 调一次 fn(进度 0..1),放满 ms 之后调 end */ function every(ms: number, fn: (progress: number) => void, end?: () => void) { const start = performance.now() const id = setInterval(() => { const progress = Math.min(1, (performance.now() - start) / ms) fn(progress) if (progress >= 1) { clearInterval(id) timers.delete(id) end?.() } }, 16) timers.add(id) } if (getCurrentScope()) { onScopeDispose(() => { disposed = true timers.forEach((id) => clearInterval(id)) timers.clear() }) } function fire(options: confetti.Options) { lastShot = confetti({ zIndex: Z_INDEX, ...options }) ?? lastShot } /** 定时器都走完、最后一把粒子也落完。离开页面(定时器被清掉)就当放完了 */ function finished(): Promise { return new Promise((resolve) => { const check = () => { if (disposed) return resolve() if (timers.size > 0 || preparing > 0) { setTimeout(check, 50) return } Promise.resolve(lastShot).then(() => resolve()) } check() }) } /** 没有真火箭:沿上升路线一路撒往下掉的小火星,当作尾焰。x0 → x、从屏幕底到 y */ function rocket(x0: number, x: number, y: number, then: () => void) { every( 380, (progress) => { const eased = 1 - Math.pow(1 - progress, 2.2) fire({ particleCount: 2, angle: 270, spread: 40, startVelocity: 3, gravity: 0.5, decay: 0.9, ticks: 22, scalar: 0.45, flat: true, shapes: ["circle"], colors: [COLORS.gold, COLORS.lightGold], origin: { x: x0 + (x - x0) * eased, y: 1.02 - (1.02 - y) * eased }, }) }, then, ) } /** 炸开:一大把,60ms 后再补一小把,看起来厚一点 */ function burst(x: number, y: number, colors: string[], size = 1) { const base: confetti.Options = { spread: 360, decay: 0.92, gravity: 0.55, flat: true, shapes: ["circle"], colors, origin: { x, y }, } fire({ ...base, particleCount: Math.round(70 * size), startVelocity: 26 * size, ticks: 80, scalar: 0.75, }) runLater( () => fire({ ...base, particleCount: Math.round(40 * size), startVelocity: 18 * size, ticks: 70, scalar: 0.55, }), 60, ) } /** 升一发再炸开。x0 不给就从正下方附近升 */ function shell(x: number, y: number, colors: string[], size = 1, x0?: number) { rocket(x0 ?? x + (Math.random() - 0.5) * 0.04, x, y, () => burst(x, y, colors, size)) } const fireworkTypes = [ // 效果1: 经典烟花秀 —— 左右两边轮流升起 7 发 () => { for (let i = 0; i < 7; i++) { runLater(() => { const left = i % 2 === 0 shell( left ? 0.08 + Math.random() * 0.3 : 0.62 + Math.random() * 0.3, 0.14 + Math.random() * 0.26, pick(PALETTES), 0.9 + Math.random() * 0.3, ) }, i * 240) } }, // 效果2: 星星雨 —— 从顶上落下一片星星 () => { every(1000, () => { fire({ particleCount: 2, angle: 270, spread: 20, startVelocity: 10, gravity: 0.7, decay: 0.97, ticks: 120, scalar: 1.1, shapes: ["star"], colors: [COLORS.gold, COLORS.gold, COLORS.pink, COLORS.blue, COLORS.silver], origin: { x: Math.random(), y: -0.05 }, }) }) }, // 效果3: 齐射 —— 从底部正中扇形射出 5 发,排成一道彩虹弧 () => { for (let i = 0; i < 5; i++) { runLater(() => { shell( 0.14 + 0.18 * i, 0.38 - 0.2 * Math.sin((Math.PI * i) / 4), [RAINBOW[i], COLORS.silver], 1, 0.5, ) }, i * 170) } }, // 效果4: 彩虹喷泉 —— 两个下角往里喷,颜色按彩虹轮换 () => { const start = performance.now() every(1300, () => { const color = RAINBOW[Math.floor((performance.now() - start) / 100) % RAINBOW.length] const base: confetti.Options = { particleCount: 3, spread: 22, startVelocity: 55, gravity: 1, decay: 0.92, ticks: 95, scalar: 0.8, flat: true, shapes: ["circle"], colors: [color], } fire({ ...base, angle: 62, origin: { x: 0, y: 1 } }) fire({ ...base, angle: 118, origin: { x: 1, y: 1 } }) }) }, // 效果5: 炮竹齐鸣 —— 正中一发大的,接着四周噼里啪啦碎响 () => { rocket(0.5, 0.5, 0.34, () => { burst(0.5, 0.34, [COLORS.gold, COLORS.orange, COLORS.pink, COLORS.green], 1.8) for (let i = 0; i < 14; i++) { runLater( () => { fire({ particleCount: 6, spread: 360, startVelocity: 6, decay: 0.9, gravity: 0.4, ticks: 25, scalar: 0.4, flat: true, shapes: ["circle"], colors: [COLORS.silver, COLORS.gold], origin: { x: 0.5 + (Math.random() - 0.5) * 0.3, y: 0.34 + (Math.random() - 0.5) * 0.4, }, }) }, 180 + i * 30, ) } }) }, // 效果6: 螺旋上升 —— 两股火星绕成螺旋往上爬,到顶炸开 () => { every( 950, (progress) => { const y = 1.02 - (1.02 - 0.3) * (1 - Math.pow(1 - progress, 1.6)) for (let arm = 0; arm < 2; arm++) { const phase = progress * Math.PI * 6 + arm * Math.PI fire({ particleCount: 2, spread: 360, startVelocity: 2, decay: 0.92, gravity: 0.3, ticks: 40, scalar: 0.6, flat: true, shapes: ["circle"], colors: [arm ? COLORS.blue : COLORS.pink], origin: { x: 0.5 + Math.sin(phase) * 0.05 * (1 - progress * 0.6), y }, }) } }, () => burst(0.5, 0.3, [COLORS.gold, COLORS.pink, COLORS.blue, COLORS.green], 1.4), ) }, // 效果7: 图案烟花 —— 三发分别炸出对勾、爱心、星星 () => { const shapes = loadPathShapes() const list = [ { x: 0.22, y: 0.36, shape: shapes.check, colors: [COLORS.green, COLORS.mint] }, { x: 0.5, y: 0.3, shape: shapes.heart, colors: [COLORS.pink, COLORS.lightPink] }, { x: 0.78, y: 0.36, shape: "star" as const, colors: [COLORS.gold, COLORS.orange] }, ] list.forEach((item, i) => { runLater(() => { rocket(item.x, item.x, item.y, () => { fire({ particleCount: 26, spread: 360, startVelocity: 24, decay: 0.92, gravity: 0.5, ticks: 110, scalar: 2.2, flat: true, shapes: [item.shape], colors: item.colors, origin: { x: item.x, y: item.y }, }) }) }, i * 280) }) }, // 效果8: 风火轮 —— 屏幕中间四条火星臂越转越快,最后炸一下 () => { const colors = [COLORS.pink, COLORS.gold, COLORS.green, COLORS.blue] every( 1300, (progress) => { const base = progress * progress * 7 * 180 for (let arm = 0; arm < 4; arm++) { fire({ particleCount: 2, angle: base + arm * 90, spread: 8, startVelocity: 30, decay: 0.9, gravity: 0.4, ticks: 50, scalar: 0.7, flat: true, shapes: ["circle"], colors: [colors[arm]], origin: { x: 0.5, y: 0.42 }, }) } }, () => burst(0.5, 0.42, [COLORS.gold, COLORS.silver]), ) }, // 效果9: 礼花瀑布 —— 顶上挂下一道金色火花帘 () => { every(1400, () => { for (let i = 0; i < 5; i++) { fire({ particleCount: 1, angle: 270, spread: 30, startVelocity: 3, decay: 0.95, gravity: 0.9, ticks: 90, scalar: 0.6, flat: true, shapes: ["circle"], colors: [COLORS.gold, COLORS.gold, COLORS.silver, COLORS.orange], origin: { x: 0.06 + Math.random() * 0.88, y: 0.06 }, }) } }) }, // 效果10: 表情包烟花 —— 一左一右炸出两团表情 () => { preparing++ loadEmojiShapes() .then((emojiShapes) => { if (disposed) return const options: confetti.Options = { shapes: emojiShapes, flat: true, scalar: 5, particleCount: 16, spread: 360, startVelocity: 40, gravity: 0.7, ticks: 150, } fire({ ...options, origin: { x: 0.35, y: 0.5 } }) runLater(() => fire({ ...options, origin: { x: 0.68, y: 0.42 } }), 300) }) .catch(() => {}) .finally(() => preparing--) }, ] /** 最多等这么久:粒子落完的回调万一没来,别让点评弹窗一直不出来 */ const MAX_WAIT_MS = 4000 /** * 触发随机烟花效果,返回的 promise 在这一场放完时 resolve(点评弹窗等它) */ function celebrate(): Promise { pick(fireworkTypes)() return Promise.race([ finished(), new Promise((resolve) => setTimeout(resolve, MAX_WAIT_MS)), ]) } return { celebrate, } }