Files
OJ2/apps/web/src/oj/problem/composables/useFireworks.ts
T

483 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import confetti from "canvas-confetti"
import { getCurrentScope, onScopeDispose } from "vue"
// 表情图标取自 @iconify 的 streamline-emojis 图标集,与项目内 <Icon icon="streamline-emojis:..."> 用法保持一致
const EMOJI_SVGS = [
// 星星眼
'<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 48 48"><path fill="#45413c" d="M8 45.5a16 1.5 0 1 0 32 0a16 1.5 0 1 0-32 0" opacity=".15"/><path fill="#ffe500" d="M4 21.5a20 20 0 1 0 40 0a20 20 0 1 0-40 0"/><path fill="#ebcb00" d="M24 1.5a20 20 0 1 0 20 20a20 20 0 0 0-20-20m0 37a18.25 18.25 0 1 1 18.25-18.25A18.25 18.25 0 0 1 24 38.5"/><path fill="#fff48c" d="M18 5.5a6 1.5 0 1 0 12 0a6 1.5 0 1 0-12 0"/><path fill="none" stroke="#45413c" stroke-linecap="round" stroke-linejoin="round" d="M4 21.5a20 20 0 1 0 40 0a20 20 0 1 0-40 0"/><path fill="#ffaa54" d="M38.5 26.5c0 .83-1.12 1.5-2.5 1.5s-2.5-.67-2.5-1.5S34.62 25 36 25s2.5.67 2.5 1.5m-29 0c0 .83 1.12 1.5 2.5 1.5s2.5-.67 2.5-1.5S13.38 25 12 25s-2.5.67-2.5 1.5"/><path fill="#ebcb00" d="m32.13 21.79l-3 1.59a1 1 0 0 1-1.45-1.05l.55-3.33a1 1 0 0 0-.29-.89l-2.45-2.38a1 1 0 0 1 .56-1.73l3.38-.49a1 1 0 0 0 .75-.55l1.52-3.09a1 1 0 0 1 1.79 0L35 12.94a1 1 0 0 0 .75.55l3.38.49a1 1 0 0 1 .56 1.71l-2.45 2.38A1 1 0 0 0 37 19l.58 3.37a1 1 0 0 1-1.45 1.05l-3-1.59a1 1 0 0 0-1-.04"/><path fill="#fffacf" d="m33.69 19.35l-3 1.59a1 1 0 0 1-1.45-1.05l.57-3.37a1 1 0 0 0-.28-.89l-2.45-2.38a1 1 0 0 1 .55-1.71l3.37-.49a1 1 0 0 0 .75-.55l1.51-3.07a1 1 0 0 1 1.8 0l1.51 3.07a1 1 0 0 0 .75.55l3.39.49a1 1 0 0 1 .55 1.71l-2.45 2.38a1 1 0 0 0-.29.89l.58 3.37a1 1 0 0 1-1.45 1.05l-3-1.59a1 1 0 0 0-.96 0"/><path fill="#fff48c" d="m38.81 18.2l-3.06-1.61a1.38 1.38 0 0 0-1.31 0l-4.25 2.23a1.35 1.35 0 0 1-.82.16l-.16.91a1 1 0 0 0 1.46 1.05l3-1.59a1 1 0 0 1 .93 0l3 1.59a1 1 0 0 0 1.45-1.05Z"/><path fill="none" stroke="#45413c" stroke-linecap="round" stroke-linejoin="round" d="m33.69 19.35l-3 1.59a1 1 0 0 1-1.45-1.05l.57-3.37a1 1 0 0 0-.28-.89l-2.45-2.38a1 1 0 0 1 .55-1.71l3.37-.49a1 1 0 0 0 .75-.55l1.51-3.07a1 1 0 0 1 1.8 0l1.51 3.07a1 1 0 0 0 .75.55l3.39.49a1 1 0 0 1 .55 1.71l-2.45 2.38a1 1 0 0 0-.29.89l.58 3.37a1 1 0 0 1-1.45 1.05l-3-1.59a1 1 0 0 0-.96 0"/><path fill="#ebcb00" d="m15.87 21.79l3 1.59a1 1 0 0 0 1.45-1.05L19.77 19a1 1 0 0 1 .29-.89l2.45-2.38A1 1 0 0 0 22 14l-3.38-.49a1 1 0 0 1-.75-.55L16.3 9.87a1 1 0 0 0-1.79 0L13 12.94a1 1 0 0 1-.75.55L8.86 14a1 1 0 0 0-.56 1.71l2.45 2.38A1 1 0 0 1 11 19l-.58 3.37a1 1 0 0 0 1.45 1.05l3-1.59a1 1 0 0 1 1-.04"/><path fill="#fffacf" d="m14.31 19.35l3 1.59a1 1 0 0 0 1.45-1.05l-.57-3.37a1 1 0 0 1 .28-.89l2.45-2.38a1 1 0 0 0-.55-1.71L17 11.05a1 1 0 0 1-.75-.55l-1.51-3.07a1 1 0 0 0-1.8 0l-1.51 3.07a1 1 0 0 1-.75.55l-3.39.49a1 1 0 0 0-.55 1.71l2.45 2.38a1 1 0 0 1 .29.89l-.58 3.37a1 1 0 0 0 1.45 1.05l3-1.59a1 1 0 0 1 .96 0"/><path fill="#fff48c" d="m9.19 18.2l3.06-1.61a1.38 1.38 0 0 1 1.31 0l4.25 2.23a1.35 1.35 0 0 0 .82.16l.16.91a1 1 0 0 1-1.46 1.05l-3-1.59a1 1 0 0 0-.93 0l-3 1.59a1 1 0 0 1-1.5-1.05Z"/><path fill="none" stroke="#45413c" stroke-linecap="round" stroke-linejoin="round" d="m14.31 19.35l3 1.59a1 1 0 0 0 1.45-1.05l-.57-3.37a1 1 0 0 1 .28-.89l2.45-2.38a1 1 0 0 0-.55-1.71L17 11.05a1 1 0 0 1-.75-.55l-1.51-3.07a1 1 0 0 0-1.8 0l-1.51 3.07a1 1 0 0 1-.75.55l-3.39.49a1 1 0 0 0-.55 1.71l2.45 2.38a1 1 0 0 1 .29.89l-.58 3.37a1 1 0 0 0 1.45 1.05l3-1.59a1 1 0 0 1 .96 0"/><path fill="#fff" stroke="#45413c" stroke-linecap="round" stroke-linejoin="round" d="M24 38.5a10.9 10.9 0 0 0 11-9.17a1 1 0 0 0-.31-.84a1.12 1.12 0 0 0-.91-.27a61.6 61.6 0 0 1-19.54 0a1.12 1.12 0 0 0-.91.27a1 1 0 0 0-.31.84A10.9 10.9 0 0 0 24 38.5"/><path fill="#fff" stroke="#45413c" stroke-linecap="round" stroke-linejoin="round" d="M34 32.5A30.2 30.2 0 0 1 24 34a30.2 30.2 0 0 1-10-1.5"/><path fill="none" stroke="#45413c" stroke-linecap="round" stroke-linejoin="round" d="M24 29v9.5m-6.5-9.84c0 .6-.05 5.75 1.06 8.49m11.94-8.49c0 .6.05 5.75-1.06 8.49"/></svg>',
// 点赞
'<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 48 48"><path fill="#45413c" d="M13 45.5a11 1.5 0 1 0 22 0a11 1.5 0 1 0-22 0" opacity=".15"/><path fill="#ffe500" d="M37.17 30.42h.18a2.54 2.54 0 1 0 0-5.08h-2.54a2.55 2.55 0 0 0 0-5.09h-6.12c.58-1.44 1.32-3.71 2-5.87a4.18 4.18 0 0 0-2.77-5.2a1.08 1.08 0 0 0-1.36.72c-.86 2.94-1.5 6.65-6 10a18.2 18.2 0 0 0-4 4.34a4 4 0 0 1-3.2 1.7H9.79a1 1 0 0 0-1 1v11.12a1 1 0 0 0 1 1h2.49c3.28.08 4.7 1.31 8.79 1.31h13.47a2.25 2.25 0 0 0 0-4.5h2.63a2.73 2.73 0 0 0 0-5.45"/><path fill="#fff48c" d="M14.34 28.63a3.93 3.93 0 0 0 3.19-1.7a18 18 0 0 1 4-4.34c4.5-3.32 5.15-7 6-10a1.08 1.08 0 0 1 1.35-.72a4.1 4.1 0 0 1 1.91 1.23a4.19 4.19 0 0 0-2.95-4a1.08 1.08 0 0 0-1.36.72c-.86 2.94-1.5 6.65-6 10a18.2 18.2 0 0 0-4 4.34a4 4 0 0 1-3.2 1.7H9.79a1 1 0 0 0-1 1v1.72Z"/><path fill="none" stroke="#45413c" stroke-linecap="round" stroke-linejoin="round" d="m28.37 26.07l-.06.12a11.41 11.41 0 0 1-8.68 6.22h0"/><path fill="#ffe500" d="M25.39 35.87h11.4v4.5h-11.4Z"/><path fill="#ffe500" d="M24.71 30.42h15.18v5.45H24.71Z"/><path fill="#ffe500" d="M24.71 25.34h15.18v5.09H24.71Zm.68-5.09h11.96v5.09H25.39Z"/><path fill="#fff48c" d="M27.93 22.34h6.88a2.56 2.56 0 0 1 2.32 1.5a2.6 2.6 0 0 0 .22-1.05a2.54 2.54 0 0 0-2.54-2.54h-6.88a2.54 2.54 0 0 0-2.54 2.54a2.6 2.6 0 0 0 .22 1.05a2.56 2.56 0 0 1 2.32-1.5"/><path fill="none" stroke="#45413c" stroke-linecap="round" stroke-linejoin="round" d="M25.39 35.87h11.4v4.5h-11.4Z"/><path fill="none" stroke="#45413c" stroke-linecap="round" stroke-linejoin="round" d="M24.71 30.42h15.18v5.45H24.71Z"/><path fill="none" stroke="#45413c" stroke-linecap="round" stroke-linejoin="round" d="M24.71 25.34h15.18v5.09H24.71Zm.68-5.09h11.96v5.09H25.39Z"/><path fill="none" stroke="#45413c" stroke-linecap="round" stroke-linejoin="round" d="M37.17 30.42h.18a2.54 2.54 0 1 0 0-5.08h-2.54a2.55 2.55 0 0 0 0-5.09h-6.12c.58-1.44 1.32-3.71 2-5.87a4.18 4.18 0 0 0-2.77-5.2a1.08 1.08 0 0 0-1.36.72c-.86 2.94-1.5 6.65-6 10a18.2 18.2 0 0 0-4 4.34a4 4 0 0 1-3.2 1.7H9.79a1 1 0 0 0-1 1v11.12a1 1 0 0 0 1 1h2.49c3.28.08 4.7 1.31 8.79 1.31h13.47a2.25 2.25 0 0 0 0-4.5h2.63a2.73 2.73 0 0 0 0-5.45"/></svg>',
// 火箭
'<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 48 48"><path fill="#ff6242" d="m14.05 26.58l5.67-7a19.55 19.55 0 0 0-8.5-.24c-3.12.77-3.61 6.66-3.66 9.45a.87.87 0 0 0 1.31.77Z"/><path fill="#ff866e" d="M11.24 22a18.2 18.2 0 0 1 6.62-.13l1.86-2.28a19.55 19.55 0 0 0-8.5-.24c-3.12.76-3.61 6.65-3.66 9.44a.85.85 0 0 0 .14.48c.3-2.94 1.09-6.66 3.54-7.27"/><path fill="none" stroke="#45413c" stroke-linecap="round" stroke-linejoin="round" d="m14.05 26.58l5.67-7a19.55 19.55 0 0 0-8.5-.24c-3.12.77-3.61 6.66-3.66 9.45a.87.87 0 0 0 1.31.77Z"/><path fill="#ff6242" d="m20.86 33.4l7-5.67a19.5 19.5 0 0 1 .24 8.49c-.73 3.12-6.58 3.61-9.41 3.66a.87.87 0 0 1-.77-1.31Z"/><path fill="#ff866e" d="M25.41 36.21a18 18 0 0 0 .13-6.63l2.28-1.85a19.5 19.5 0 0 1 .24 8.49c-.73 3.12-6.58 3.61-9.41 3.66a.87.87 0 0 1-.48-.14c2.94-.26 6.66-1.09 7.24-3.53"/><path fill="none" stroke="#45413c" stroke-linecap="round" stroke-linejoin="round" d="m20.86 33.4l7-5.67a19.5 19.5 0 0 1 .24 8.49c-.73 3.12-6.58 3.61-9.41 3.66a.87.87 0 0 1-.77-1.31Z"/><path fill="#e8f4fa" d="M39.48 21.56c5.42-5.7 3.71-12.73 2.86-15.2a2 2 0 0 0-.48-.78a2.1 2.1 0 0 0-.77-.48c-2.47-.85-9.51-2.56-15.21 2.9C18.84 14.66 14 26.5 14 26.5l7 7s11.78-4.9 18.48-11.94"/><path fill="#fff" d="M28.05 11.87c5.53-5.27 12.31-3.8 15-2.94a17 17 0 0 0-.66-2.57a2 2 0 0 0-1.3-1.26c-2.47-.85-9.51-2.56-15.21 2.9C18.84 14.66 14 26.5 14 26.5l2.69 2.69c1.43-3.19 5.68-11.92 11.36-17.32"/><path fill="none" stroke="#45413c" stroke-linecap="round" stroke-linejoin="round" d="M39.48 21.56c5.42-5.7 3.71-12.73 2.86-15.2a2 2 0 0 0-.48-.78a2.1 2.1 0 0 0-.77-.48c-2.47-.85-9.51-2.56-15.21 2.9C18.84 14.66 14 26.5 14 26.5l7 7s11.78-4.9 18.48-11.94"/><path fill="#45413c" d="M18.22 44.21a10 1.5 0 1 0 20 0a10 1.5 0 1 0-20 0" opacity=".15"/><path fill="#ff6242" stroke="#45413c" stroke-linecap="round" stroke-linejoin="round" d="M22.7 25.65a.68.68 0 0 0-.92-.93a22.1 22.1 0 0 0-5.31 3.8a17.8 17.8 0 0 0-3.87 5.86a.34.34 0 0 0 .44.45A17.8 17.8 0 0 0 18.9 31a21.9 21.9 0 0 0 3.8-5.35"/><path fill="#c0dceb" stroke="#45413c" stroke-linecap="round" stroke-linejoin="round" d="M28.24 13.72a5.49 5.49 0 1 0 10.98 0a5.49 5.49 0 1 0-10.98 0"/><path fill="#00b8f0" d="M29.96 13.69a3.8 3.8 0 1 0 7.6 0a3.8 3.8 0 1 0-7.6 0"/><path fill="#4acfff" d="M31.07 11a3.8 3.8 0 0 0 0 5.38a4 4 0 0 0 .85.62l3.55-6.69a3.77 3.77 0 0 0-4.4.69"/><path fill="none" stroke="#45413c" stroke-linecap="round" stroke-linejoin="round" d="M29.96 13.69a3.8 3.8 0 1 0 7.6 0a3.8 3.8 0 1 0-7.6 0"/><path fill="#ff8a14" stroke="#45413c" stroke-linecap="round" stroke-linejoin="round" d="M11.34 41.47c-.7.7-5.16 2.21-7.42 2.94c-.42.14-1-.47-.89-.89c.73-2.25 2.24-6.71 3-7.42c.95-1 3.43-1 4.91.46s1.35 3.96.4 4.91"/><path fill="#ffe500" stroke="#45413c" stroke-linecap="round" stroke-linejoin="round" d="M10.13 39.79c-.5.49-2.57 1-3 .56s.07-2.54.57-3a1.57 1.57 0 0 1 2.13.34a1.56 1.56 0 0 1 .3 2.1"/><path fill="#ffe500" d="m43.32 23.69l.8 1.51a.42.42 0 0 0 .32.22l1.69.2a.42.42 0 0 1 .25.71l-1.19 1.22a.39.39 0 0 0-.11.37l.33 1.67a.42.42 0 0 1-.59.46l-1.53-.76a.44.44 0 0 0-.39 0l-1.49.84a.43.43 0 0 1-.62-.43L41 28a.41.41 0 0 0-.13-.36l-1.21-1.14a.42.42 0 0 1 .21-.72l1.68-.29a.41.41 0 0 0 .31-.23l.72-1.55a.41.41 0 0 1 .74-.02"/><path fill="#fff48c" d="m40.53 27.31l1.06-.19a.4.4 0 0 0 .31-.23l.72-1.55a.42.42 0 0 1 .75 0l.79 1.51a.42.42 0 0 0 .32.22l1.07.13l.83-.85a.42.42 0 0 0-.25-.71l-1.69-.2a.42.42 0 0 1-.32-.22l-.8-1.51a.41.41 0 0 0-.74 0l-.72 1.55a.41.41 0 0 1-.31.23l-1.68.29a.42.42 0 0 0-.21.72Z"/><path fill="none" stroke="#45413c" stroke-linecap="round" stroke-linejoin="round" d="m43.32 23.69l.8 1.51a.42.42 0 0 0 .32.22l1.69.2a.42.42 0 0 1 .25.71l-1.19 1.22a.39.39 0 0 0-.11.37l.33 1.67a.42.42 0 0 1-.59.46l-1.53-.76a.44.44 0 0 0-.39 0l-1.49.84a.43.43 0 0 1-.62-.43L41 28a.41.41 0 0 0-.13-.36l-1.21-1.14a.42.42 0 0 1 .21-.72l1.68-.29a.41.41 0 0 0 .31-.23l.72-1.55a.41.41 0 0 1 .74-.02"/><path fill="#ffe500" d="m31.74 40.59l.26 1.35a.35.35 0 0 0 .2.24l1.27.54a.34.34 0 0 1 0 .6l-1.2.68a.32.32 0 0 0-.17.26l-.1 1.37a.34.34 0 0 1-.57.22l-1-.93a.3.3 0 0 0-.3-.08l-1.34.3a.34.34 0 0 1-.38-.47l.59-1.25a.31.31 0 0 0 0-.31l-.7-1.18a.34.34 0 0 1 .33-.51l1.36.16a.38.38 0 0 0 .3-.11l.9-1a.34.34 0 0 1 .55.12"/><path fill="#fff48c" d="m28.76 42.75l.86.1a.35.35 0 0 0 .3-.11l.9-1a.34.34 0 0 1 .59.15l.27 1.35a.31.31 0 0 0 .19.24l.8.34l.84-.47a.34.34 0 0 0 0-.6l-1.27-.54a.35.35 0 0 1-.2-.24l-.27-1.35a.34.34 0 0 0-.58-.16l-.9 1a.38.38 0 0 1-.3.11l-1.36-.16a.34.34 0 0 0-.33.51Z"/><path fill="none" stroke="#45413c" stroke-linecap="round" stroke-linejoin="round" d="m31.74 40.59l.26 1.35a.35.35 0 0 0 .2.24l1.27.54a.34.34 0 0 1 0 .6l-1.2.68a.32.32 0 0 0-.17.26l-.1 1.37a.34.34 0 0 1-.57.22l-1-.93a.3.3 0 0 0-.3-.08l-1.34.3a.34.34 0 0 1-.38-.47l.59-1.25a.31.31 0 0 0 0-.31l-.7-1.18a.34.34 0 0 1 .33-.51l1.36.16a.38.38 0 0 0 .3-.11l.9-1a.34.34 0 0 1 .55.12"/><path fill="#9ceb60" d="M11.86 7a3.06 3.06 0 1 1-2.59-3.44A3.06 3.06 0 0 1 11.86 7"/><path fill="#c8ffa1" d="M9.27 5.74a3.06 3.06 0 0 1 2.41 2a3 3 0 0 0 .18-.74a3.06 3.06 0 0 0-6.06-.85A3.2 3.2 0 0 0 6 7.67a3.08 3.08 0 0 1 3.27-1.93"/><path fill="none" stroke="#45413c" stroke-linecap="round" stroke-linejoin="round" d="M11.86 7a3.06 3.06 0 1 1-2.59-3.44A3.06 3.06 0 0 1 11.86 7"/><path fill="none" stroke="#45413c" stroke-linecap="round" stroke-linejoin="round" d="M5.86 7.3C5.1 7.82 3.57 9 4 9.86s10.49-5.15 10.14-6s-2.5.12-3.26.48"/></svg>',
]
// 将 emoji SVG 转成 confetti 可用的彩色位图 Shape,缓存后供重复调用复用
let emojiShapesPromise: Promise<confetti.Shape[]> | null = null
function svgToBitmapShape(svg: string): Promise<confetti.Shape> {
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<confetti.Shape[]> {
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<T>(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<ReturnType<typeof setTimeout>>()
let disposed = false
// 这一场最后一次发射的 promise:canvas-confetti 同一块画布上的粒子是一场动画,
// 后发的接进去,promise 在所有粒子都落完的时候才 resolve
let lastShot: Promise<unknown> | 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<void> {
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<void> {
pick(fireworkTypes)()
return Promise.race([
finished(),
new Promise<void>((resolve) => setTimeout(resolve, MAX_WAIT_MS)),
])
}
return {
celebrate,
}
}