Skip to content

拉炮

隨時隨地都可以慶祝!✧。٩(ˊᗜˋ*)و✧*。

使用範例

基本用法

呼叫 emit 即可發射粒子。

🎉
🎉
🎉
🎉
🎉
🎇
0
查看範例原始碼
vue
<template>
  <div class="w-full flex flex-col gap-4 border border-gray-300">
    <util-party-popper
      ref="popperRef"
      v-slot="{ fps }"
      class="h-[60vh] flex"
      :max-concurrency="40"
    >
      <div class="h-full w-full flex flex-col items-center justify-center gap-6">
        <div class="flex gap-6">
          <div
            class="cursor-pointer select-none rounded p-4 text-4xl -rotate-90"
            @click="(event) => emit(event, 'lt')"
          >
            🎉
          </div>

          <div
            class="cursor-pointer select-none rounded p-4 text-4xl -rotate-45"
            @click="(event) => emit(event, 't')"
          >
            🎉
          </div>

          <div
            class="cursor-pointer select-none rounded p-4 text-4xl"
            @click="(event) => emit(event, 'rt')"
          >
            🎉
          </div>
        </div>

        <div class="flex gap-10">
          <div
            class="cursor-pointer select-none rounded p-4 text-4xl -rotate-[135deg]"
            @click="(event) => emit(event, 'l')"
          >
            🎉
          </div>

          <div
            class="rotate-45 cursor-pointer select-none rounded p-4 text-4xl"
            @click="(event) => emit(event, 'r')"
          >
            🎉
          </div>
        </div>

        <div
          class="cursor-pointer select-none rounded p-4 text-4xl"
          @click="emit"
        >
          🎇
        </div>
      </div>

      <div class="absolute left-0 top-0 p-4">
        {{ fps }}
      </div>
    </util-party-popper>
  </div>
</template>

<script setup lang="ts">
import { Scalar } from '@babylonjs/core'
import { useElementBounding } from '@vueuse/core'
import { conditional, constant, isDeepEqual } from 'remeda'
import { ref } from 'vue'
import UtilPartyPopper from '../util-party-popper.vue'

const popperRef = ref<InstanceType<typeof UtilPartyPopper>>()
const popperBounding = useElementBounding(popperRef)

type Direction = 'rt' | 'lt' | 't' | 'l' | 'r'

function emit(
  payload: MouseEvent,
  direction?: Direction,
) {
  const target = payload.target
  if (!(target instanceof Element))
    return

  const bounding = target.getBoundingClientRect()

  const position = {
    x: bounding.left + bounding.width / 2 - popperBounding.left.value,
    y: bounding.top + bounding.height / 2 - popperBounding.top.value,
  }

  const velocityRange = { min: 2, max: 8 }

  if (!direction) {
    popperRef.value?.emit(position)
    return
  }

  const param = conditional(direction, [
    isDeepEqual('rt'),
    constant(() => ({
      ...position,
      velocity: {
        x: -Scalar.RandomRange(velocityRange.min, velocityRange.max),
        y: Scalar.RandomRange(velocityRange.min, velocityRange.max),
      },
    })),
  ], [
    isDeepEqual('lt'),
    constant(() => ({
      ...position,
      velocity: {
        x: Scalar.RandomRange(velocityRange.min, velocityRange.max),
        y: Scalar.RandomRange(velocityRange.min, velocityRange.max),
      },
    })),
  ], [
    isDeepEqual('t'),
    constant(() => ({
      ...position,
      velocity: {
        x: Scalar.RandomRange(-2, 2),
        y: Scalar.RandomRange(velocityRange.min, velocityRange.max),
      },
    })),
  ], [
    isDeepEqual('l'),
    constant(() => ({
      ...position,
      velocity: {
        x: Scalar.RandomRange(velocityRange.min, velocityRange.max),
        y: Scalar.RandomRange(-2, 2),
      },
    })),
  ], [
    isDeepEqual('r'),
    constant(() => ({
      ...position,
      velocity: {
        x: -Scalar.RandomRange(velocityRange.min, velocityRange.max),
        y: Scalar.RandomRange(-2, 2),
      },
    })),
  ])

  popperRef.value?.emit(param)
}
</script>

廣域發射

不只可自訂發射位置,粒子發射範圍也可以調整。

廣域發射更有派對的感覺!✧⁑。٩(ˊᗜˋ*)و✧⁕。

👈
👆
👇
🎆
👉
查看範例原始碼
vue
<template>
  <div class="w-full flex flex-col gap-4">
    <div class="h-full w-full flex items-center justify-center gap-10 p-10">
      <div
        class="cursor-pointer select-none rounded bg-white px-4 py-2 text-2xl"
        @click="emit('left')"
      >
        👈
      </div>

      <div class="flex flex-col gap-10">
        <div
          class="cursor-pointer select-none rounded bg-white px-4 py-2 text-2xl"
          @click="emit('top')"
        >
          👆
        </div>

        <div
          class="cursor-pointer select-none rounded bg-white px-4 py-2 text-2xl"
          @click="emit('bottom')"
        >
          👇
        </div>

        <div
          class="cursor-pointer select-none rounded bg-white px-4 py-2 text-2xl"
          @click="emit('bottom-center')"
        >
          🎆
        </div>
      </div>

      <div
        class="cursor-pointer select-none rounded bg-white px-4 py-2 text-2xl"
        @click="emit('right')"
      >
        👉
      </div>
    </div>

    <util-party-popper
      ref="popperRef"
      class="pointer-events-none left-0 top-0 z-50 h-full w-full !fixed"
      :quantity-of-per-emit="100"
      :max-concurrency="50"
    />
  </div>
</template>

<script setup lang="ts">
import { Scalar } from '@babylonjs/core'
import { useElementBounding } from '@vueuse/core'
import { conditional, constant, isDeepEqual } from 'remeda'

import { ref } from 'vue'

import UtilPartyPopper from '../util-party-popper.vue'

const popperRef = ref<InstanceType<typeof UtilPartyPopper>>()
const popperBounding = useElementBounding(popperRef)

function emit(position: 'top' | 'bottom' | 'left' | 'right' | 'bottom-center') {
  const offset = 50

  const param = conditional(position, [
    isDeepEqual('top'),
    constant(() => ({
      x: Scalar.RandomRange(0, popperBounding.width.value),
      y: -offset,
      velocity: {
        x: Scalar.RandomRange(1, -1),
        y: Scalar.RandomRange(0, -10),
      },
    })),
  ], [
    isDeepEqual('bottom'),
    constant(() => ({
      x: Scalar.RandomRange(0, popperBounding.width.value),
      y: popperBounding.height.value + offset,
      velocity: {
        x: Scalar.RandomRange(1, -1),
        y: Scalar.RandomRange(10, 15),
      },
    })),
  ], [
    isDeepEqual('bottom-center'),
    () => ({
      x: Scalar.RandomRange(0, popperBounding.width.value),
      y: popperBounding.height.value + offset,
      velocity: {
        x: 0,
        y: Scalar.RandomRange(8, 20),
      },
    }),
  ], [
    isDeepEqual('left'),
    constant(() => ({
      x: -offset,
      y: Scalar.RandomRange(0, popperBounding.height.value),
      velocity: {
        x: Scalar.RandomRange(-5, -10),
        y: Scalar.RandomRange(-1, 1),
      },
    })),
  ], [
    isDeepEqual('right'),
    constant(() => ({
      x: popperBounding.width.value + offset,
      y: Scalar.RandomRange(0, popperBounding.height.value),
      velocity: {
        x: Scalar.RandomRange(5, 10),
        y: Scalar.RandomRange(-1, 1),
      },
    })),
  ])

  popperRef.value?.emit(param)
}
</script>

各種形狀

不只是方形,還有各種形狀可以選擇。

🎉
查看範例原始碼
vue
<template>
  <div class="w-full flex flex-col gap-4">
    <div class="h-full w-full flex items-center justify-center gap-10 p-10">
      <div
        class="cursor-pointer select-none rounded bg-white px-4 py-2 text-2xl"
        @click="emit()"
      >
        🎉
      </div>
    </div>

    <util-party-popper
      ref="popperRef"
      :confetti="confettiList"
      class="pointer-events-none left-0 top-0 z-50 h-full w-full !fixed"
      :quantity-of-per-emit="50"
      :max-concurrency="50"
    />
  </div>
</template>

<script setup lang="ts">
import type { ExtractArrayType } from '../../../types/main.type'
import { Scalar } from '@babylonjs/core'
import { useElementBounding } from '@vueuse/core'
import { ref } from 'vue'
import UtilPartyPopper from '../util-party-popper.vue'

const popperRef = ref<InstanceType<typeof UtilPartyPopper>>()
const popperBounding = useElementBounding(popperRef)

type Confetti = ExtractArrayType<
  InstanceType<typeof UtilPartyPopper>['confetti']
>
const confettiList: Confetti[] = [
  {
    shape: 'plane',
    width: 10,
    height: 10,
  },
  {
    shape: 'cylinder',
    diameter: 10,
    height: 1,
  },
  {
    shape: 'disc',
    radius: 10,
    tessellation: 3,
    arc: 1,
  },
  {
    shape: 'disc',
    radius: 8,
    tessellation: 8,
    arc: 1,
  },
  {
    shape: 'torus',
    diameter: 12,
    thickness: 2,
  },
]

function emit() {
  popperRef.value?.emit(() => ({
    x: 0,
    y: popperBounding.height.value,
    velocity: {
      x: -Scalar.RandomRange(5, 10),
      y: Scalar.RandomRange(10, 20),
    },
  }))

  popperRef.value?.emit(() => ({
    x: popperBounding.width.value,
    y: popperBounding.height.value,
    velocity: {
      x: Scalar.RandomRange(5, 10),
      y: Scalar.RandomRange(10, 20),
    },
  }))
}
</script>

使用文字

不只形狀,還可以使用文字,有更多理由可以慶祝了。

例如鱈魚又胖了 2 公斤!(/≧▽≦)/

🎉

鱈魚:「這種事別拿出來慶祝啊!╭(°A ,°`)╮」

查看範例原始碼
vue
<template>
  <div class="w-full flex flex-col gap-4">
    <div class="h-full w-full flex items-center justify-center gap-10 p-10">
      <div
        class="cursor-pointer select-none rounded bg-white px-4 py-2 text-2xl"
        @click="emit()"
      >
        🎉
      </div>
    </div>

    <util-party-popper
      ref="popperRef"
      :confetti="confettiList"
      class="pointer-events-none left-0 top-0 z-50 h-full w-full !fixed"
      :quantity-of-per-emit="20"
      :max-concurrency="50"
      :max-angular-velocity="Math.PI / 100"
      :color="{ r: 1, g: 1, b: 1 }"
    />
  </div>
</template>

<script setup lang="ts">
import type { ExtractArrayType } from '../../../types/main.type'
import { Scalar } from '@babylonjs/core'
import { useElementBounding } from '@vueuse/core'
import { ref } from 'vue'
import UtilPartyPopper from '../util-party-popper.vue'

const popperRef = ref<InstanceType<typeof UtilPartyPopper>>()
const popperBounding = useElementBounding(popperRef)

type Confetti = ExtractArrayType<
  InstanceType<typeof UtilPartyPopper>['confetti']
>
const confettiList: Confetti[] = [
  {
    shape: 'text',
    width: 40,
    height: 40,
    char: '🐟',
  },
  {
    shape: 'text',
    width: 40,
    height: 20,
    char: '肥魚',
  },
  {
    shape: 'text',
    width: 80,
    height: 40,
    char: '2 KG!',
  },
  {
    shape: 'text',
    width: 30,
    height: 30,
    char: '✨',
  },
]

function emit() {
  popperRef.value?.emit(() => ({
    x: 0,
    y: Scalar.RandomRange(0, popperBounding.height.value),
    velocity: {
      x: -Scalar.RandomRange(5, 10),
      y: Scalar.RandomRange(-5, 5),
    },
  }))

  popperRef.value?.emit(() => ({
    x: popperBounding.width.value,
    y: Scalar.RandomRange(0, popperBounding.height.value),
    velocity: {
      x: Scalar.RandomRange(5, 10),
      y: Scalar.RandomRange(-5, 5),
    },
  }))
}
</script>

勞贖嘉年華

勞贖!滿滿的勞贖!Σ(ˊДˋ;)

查看範例原始碼
vue
<template>
  <div class="w-full flex flex-col gap-4">
    <base-checkbox
      v-model="enable"
      class="border p-4"
      label="啟用"
    />

    <util-party-popper
      ref="popperRef"
      class="pointer-events-none left-0 top-0 z-50 h-full w-full !fixed"
      :confetti="confettiList"
      :quantity-of-per-emit="2"
      :max-concurrency="500"
      :max-angular-velocity="Math.PI / 100"
      :color="{ r: 1, g: 1, b: 1 }"
    />
  </div>
</template>

<script setup lang="ts">
import type { ExtractArrayType } from '../../../types/main.type'
import { Scalar } from '@babylonjs/core'
import { throttleFilter, useMouseInElement, useMousePressed, whenever } from '@vueuse/core'
import { ref, watch } from 'vue'

import BaseCheckbox from '../../base-checkbox.vue'
import UtilPartyPopper from '../util-party-popper.vue'

const enable = ref(false)

type Confetti = ExtractArrayType<
  InstanceType<typeof UtilPartyPopper>['confetti']
>
const confettiList: Confetti[] = [
  {
    shape: 'text',
    width: 30,
    height: 30,
    char: '🐁',
  },
  {
    shape: 'text',
    width: 40,
    height: 40,
    char: '🐀',
  },
]

const popperRef = ref<InstanceType<typeof UtilPartyPopper>>()
const {
  elementX,
  elementY,
} = useMouseInElement(popperRef, {
  eventFilter: throttleFilter(10),
  scroll: false,
})
const { pressed } = useMousePressed()

whenever(pressed, () => {
  if (!enable.value)
    return

  popperRef.value?.emit(() => ({
    x: elementX.value,
    y: elementY.value,
    velocity: {
      x: Scalar.RandomRange(-10, 10),
      y: Scalar.RandomRange(-10, 10),
    },
  }))
})

watch(() => [elementX, elementY], () => {
  if (!enable.value)
    return

  popperRef.value?.emit(() => ({
    x: elementX.value,
    y: elementY.value,
    velocity: {
      x: Scalar.RandomRange(-2, 2),
      y: Scalar.RandomRange(-2, 2),
    },
  }))
}, { deep: true })
</script>

原理

利用 babylon.js 製作粒子效果。

📚 babylon.js

預設使用 WebGPU,效能好棒棒!。✧。٩(ˊᗜˋ*)و✧*。

原始碼

API

Props

type Confetti = {
  shape: 'plane';
  width: number;
  height: number;
} | {
  shape: 'cylinder';
  height: number;
  diameter: number;
} | {
  shape: 'disc';
  radius: number;
  /** the number of disc/polygon sides */
  tessellation: number;
  /** ratio of the circumference between 0 and 1 */
  arc: number;
} | {
  shape: 'torus';
  diameter: number;
  /** number of segments along the circle */
  thickness: number;
} | {
  shape: 'polyhedron';
  /** polyhedron type in the range。0-14
   *
   * https://doc.babylonjs.com/features/featuresDeepDive/mesh/creation/polyhedra/polyhedra_by_numbers
   */
  type: number;
  size: number;
  sizeX?: number;
  sizeY?: number;
  sizeZ?: number;
} | {
  shape: 'text';
  width: number;
  height: number;
  char: string;
}

interface Props {
  /** 紙屑參數。初始化後即固定,不支援動態變更 */
  confetti?: Confetti | Confetti[];

  /** 每次發射數量
   *
   * @default 20
   */
  quantityOfPerEmit?: number;

  /** 最大同時觸發次數。
   *
   * @default 10
   */
  maxConcurrency?: number;

  /** 最大角速度
   *
   * @default 1.5
   */
  maxAngularVelocity?: number;

  /** 重力
   *
   * @default -0.01
   */
  gravity?: number;

  /** 空氣阻力。速度衰減比率
   *
   * @default 0.985
   */
  airResistance?: number;

  /** 預設發射速度 */
  velocity?: Vector | ((index: number) => Vector);

  /** 粒子顏色 */
  color?: Color | ((index: number) => Color);
}

Methods

defineExpose({
  /** 發射粒子,如果提供 function,則可以分別設定粒子參數 */
  emit,
  /** 目前畫面 FPS */
  fps,
})

Slots

defineSlots<{
  default?: (data: { fps: number }) => unknown;
}>()

v0.23.1