Skip to content

物理包裝器 wrapper

產生物理世界,讓內部元素具有物理效果 ヾ(⌐■_■)ノ♪

使用範例

基本用法

開始運行後,被 body 包裹的元素會產生物理效果

I
T
若有雷同,
那就雷同
• 
世界上
有 10 種
一種
懂二進位
一種不懂
• 
要改的太多了
,不如
改天
• 
程式
,一個能
就行
• 
在我的
機器
可以 work
• 
這 TMD 誰
.
.
.
啊是我
• 
這不是
bug
,這是
feature
• 
你有
試過
重啟
嗎?
• 
昨天
好好的
查看範例原始碼
vue
<template>
  <div class="w-full flex flex-col gap-4 border border-gray-300">
    <wrapper-physics
      ref="wrapperRef"
      class="h-[30rem] w-full flex flex-col items-start gap-1 p-4 md:w-[40rem]"
    >
      <div class="flex gap-20 pl-10">
        <wrapper-physics-body>
          <base-btn
            label="開始"
            @click="wrapperRef?.start()"
          />
        </wrapper-physics-body>
        <wrapper-physics-body>
          <base-btn
            label="重置"
            @click="wrapperRef?.reset()"
          />
        </wrapper-physics-body>
      </div>

      <div
        v-for="section, i in sections"
        :key="i"
        class="flex"
        :class="section.class"
      >
        <wrapper-physics-body
          v-for="text, j in section.texts"
          :key="j"
          class="whitespace-nowrap"
        >
          {{ text }}
        </wrapper-physics-body>
      </div>

      <div class="flex-1" />

      <wrapper-physics-body is-static>
        <base-btn label="🐟" />
      </wrapper-physics-body>
    </wrapper-physics>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import BaseBtn from '../../base-btn.vue'
import WrapperPhysics from '../wrapper-physics.vue'
import WrapperPhysicsBody from '../wrapper-physics-body.vue'

const wrapperRef = ref<InstanceType<typeof WrapperPhysics>>()

const sections = [
  {
    class: 'text-lg font-bold mt-2',
    texts: ['I', 'T', '語', '錄'],
  },
  {
    class: 'text-sm opacity-50 mb-2',
    texts: ['若有雷同,', '那就雷同'],
  },
  {
    texts: ['• ', '世界上', '有 10 種', '人', ',', '一種', '懂二進位', ',', '一種不懂'],
  },
  {
    texts: ['• ', '要改的太多了', ',不如', '改天', '吧'],
  },
  {
    texts: ['• ', '人', '和', '程式', ',一個能', '跑', '就行'],
  },
  {
    texts: ['• ', '在我的', '機器', '上', '可以 work'],
  },
  {
    texts: ['• ', '這 TMD 誰', '寫', '.', '.', '.', '啊是我'],
  },
  {
    texts: ['• ', '這不是', 'bug', ',這是', 'feature'],
  },
  {
    texts: ['• ', '你有', '試過', '重啟', '嗎?'],
  },
  {
    texts: ['• ', '昨天', '還', '好好的'],
  },
]
</script>

物理性質

調整物理性質,讓元素嗨起來!ᕕ( ゚ ∀。)ᕗ

鱈魚
很肥
🐟
🐟

鱈魚:「我怎麼覺得這個範例偷嘴我?╭(°A ,°`)╮」

查看範例原始碼
vue
<template>
  <div class="w-full flex flex-col gap-4 border border-gray-300 p-6">
    <div class="flex gap-2">
      <base-btn
        label="開始"
        @click="wrapperRef?.start()"
      />
      <base-btn
        label="重置"
        @click="wrapperRef?.reset()"
      />
    </div>

    <wrapper-physics
      ref="wrapperRef"
      class="h-[30rem] w-full flex flex-col items-center justify-center border border-dashed md:w-[40rem]"
    >
      <div class="flex">
        <wrapper-physics-body
          v-for="item, i in list"
          :key="i"
          v-bind="item"
        >
          {{ item.text }}
        </wrapper-physics-body>
      </div>

      <wrapper-physics-body>
        🐟
      </wrapper-physics-body>
    </wrapper-physics>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import BaseBtn from '../../base-btn.vue'
import WrapperPhysics from '../wrapper-physics.vue'
import WrapperPhysicsBody from '../wrapper-physics-body.vue'

type BodyProp = InstanceType<typeof WrapperPhysicsBody>['$props']

interface Item extends BodyProp {
  text: string;
}

const wrapperRef = ref<InstanceType<typeof WrapperPhysics>>()

/** 回彈力設為 1.5,表示回彈速度會是進入速度的 1.5 倍
 *
 * 畫面會超嗨喔!ᕕ( ゚ ∀。)ᕗ
 */
const restitution = 1.5

const list: Item[] = [
  { text: '鱈魚', isStatic: true },
  { text: '真', restitution },
  { text: '的', restitution },
  { text: '不', restitution },
  { text: '是', frictionAir: 1 },
  { text: '一', restitution },
  { text: '種', restitution },
  { text: '很肥', isStatic: true },
  { text: '的', restitution },
  { text: '🐟', restitution },
]
</script>

調整重力

調整重力方向,讓元素飄起來!(ノ>ω<)ノ

🐟
🐟
🐟
🐟
🐟
🐟
🐟
🐟
🐟
重力方向:
查看範例原始碼
vue
<template>
  <div class="w-full flex flex-col gap-4 border border-gray-300 p-6">
    <wrapper-physics
      v-bind="env"
      immediate
      class="h-[30rem] w-full flex flex-col items-center justify-center border border-dashed md:w-[40rem]"
    >
      <div class="flex">
        <wrapper-physics-body
          v-for="item in list"
          :key="item"
          v-bind="bodyProp"
          class="select-none"
          :class="item"
        >
          🐟
        </wrapper-physics-body>
      </div>
    </wrapper-physics>
  </div>

  <div class="flex flex-col gap-6 p-4 md:flex-row">
    <div class="">
      重力方向:
      <analog-stick
        size="14rem"
        @trigger="handleTrigger"
      />
    </div>

    <div class="flex-col">
      <base-input
        v-model.number="bodyProp.friction"
        type="range"
        :label="`摩擦力: ${bodyProp.friction}`"
        class="w-full"
        :min="0"
        :step="0.01"
        :max="1"
      />
      <base-input
        v-model.number="bodyProp.frictionAir"
        type="range"
        :label="`空氣阻力: ${bodyProp.frictionAir}`"
        class="w-full"
        :min="0"
        :step="0.01"
        :max="1"
      />
      <base-input
        v-model.number="bodyProp.restitution"
        type="range"
        :label="`彈力: ${bodyProp.restitution}`"
        class="w-full"
        :min="0"
        :step="0.01"
        :max="1.1"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import AnalogStick from '../../analog-stick.vue'
import BaseInput from '../../base-input.vue'
import WrapperPhysics from '../wrapper-physics.vue'
import WrapperPhysicsBody from '../wrapper-physics-body.vue'

type BodyProp = InstanceType<typeof WrapperPhysicsBody>['$props']

const env = ref({
  gravity: {
    scale: 0.001,
    x: 0,
    y: 1,
  },
})

const bodyProp = ref<BodyProp>({
  frictionAir: 0.01,
  friction: 0.01,
  restitution: 1,
})

const list = [
  'text-base',
  'text-lgF',
  'text-xl',
  'text-2xl',
  'text-3xl',
  'text-4xl',
  'text-5xl',
  'text-6xl',
  'text-7xl',
]

function handleTrigger(e: { x: number; y: number }) {
  env.value.gravity.x = e.x
  env.value.gravity.y = e.y
}
</script>

視窗滾動

結合滾動效果,將捲動之速度映射至重力加速度,製造上下搖晃效果

🐟
🐟
🐟
🐟
🐟
🐟

鱈魚:「夭壽虐魚喔!Σ(っ °Д °;)っ」

查看範例原始碼
vue
<template>
  <div class="w-full flex flex-col gap-4 border border-gray-300">
    <wrapper-physics
      v-bind="env"
      immediate
      class="h-[100vh] w-full flex items-start justify-center"
    >
      <div class="flex">
        <wrapper-physics-body
          v-for="item in list"
          :key="item"
          v-bind="bodyProp"
          class="select-none"
          :class="item"
        >
          🐟
        </wrapper-physics-body>
      </div>
    </wrapper-physics>
  </div>
</template>

<script setup lang="ts">
import {
  refAutoReset,
  useWindowScroll,
} from '@vueuse/core'
import { clamp } from 'remeda'
import { computed, watch } from 'vue'
import WrapperPhysics from '../wrapper-physics.vue'
import WrapperPhysicsBody from '../wrapper-physics-body.vue'

const { y } = useWindowScroll()

const gravityScale = refAutoReset(0.001, 200)
watch(y, (value, prevValue) => {
  const delta = clamp(
    value - prevValue,
    { max: 100, min: -100 },
  )

  gravityScale.value = 0.001 + delta * 0.00015
})

const env = computed(() => ({
  gravity: {
    scale: gravityScale.value,
    x: 0,
    y: 1,
  },
}))

const bodyProp = {
  frictionAir: 0,
  friction: 0,
  restitution: 0.5,
}

const list = [
  'text-base',
  'text-xl',
  'text-2xl',
  'text-4xl',
  'text-5xl',
  'text-7xl',
]
</script>

<style scoped lang="sass">
</style>

物體形狀

調整物體碰撞箱(hit box)外型,例如圓形才符合鱈魚的真實體型。╮(╯▽╰)╭

鱈魚:「現在是當我聽不到是不是?ಠ_ಠ」

甚麼是碰撞箱(hit box)

碰撞箱是指實際上在物理世界中參與碰撞的實際形狀。

你一定在想為甚麼不用物體本身的形狀?

因為物體通常很複雜,如果使用過度複雜的形狀進行運算會導致性能問題,所以通常都會適當簡化,使用形狀更單純的碰撞箱呦。(. ❛ ᴗ ❛.)

🐟
🐟
🐟
🐟
🐟
🐟
🐟
🐟
🐟
🐟
🐟
🐟
🐟
🐟
🐟
重力方向:
查看範例原始碼
vue
<template>
  <div class="w-full flex flex-col gap-4 border border-gray-300">
    <wrapper-physics
      v-bind="env"
      immediate
      class="h-[30rem] w-full flex items-center justify-center md:w-[40rem]"
    >
      <template v-for="item in list">
        <wrapper-physics-body
          v-for="index in item.quantity"
          :key="index"
          v-bind="bodyProp"
          class="aspect-square flex select-none items-center justify-center rounded-full bg-slate-300"
          :class="item.class"
        >
          🐟
        </wrapper-physics-body>
      </template>
    </wrapper-physics>
  </div>

  <div class="flex gap-6 p-4">
    <div class="">
      重力方向:
      <analog-stick
        size="14rem"
        @trigger="handleTrigger"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import AnalogStick from '../../analog-stick.vue'
import WrapperPhysics from '../wrapper-physics.vue'
import WrapperPhysicsBody from '../wrapper-physics-body.vue'

const env = ref({
  gravity: {
    scale: 0.001,
    x: 0,
    y: 1,
  },
})

const bodyProp: InstanceType<typeof WrapperPhysicsBody>['$props'] = {
  polygon: 'circle',
  frictionAir: 0,
  friction: 0,
  restitution: 0.7,
}

function handleTrigger(e: { x: number; y: number }) {
  env.value.gravity.x = e.x
  env.value.gravity.y = e.y
}

const list = [
  {
    class: 'w-14 h-14 text-3xl',
    quantity: 2,
  },
  {
    class: 'w-10 h-10',
    quantity: 3,
  },
  {
    class: 'w-6 h-6 text-xs',
    quantity: 10,
  },
]
</script>

Scope Prop

使用 Scope Prop 玩出更多花樣

查看範例原始碼
vue
<template>
  <div class="w-full flex flex-col gap-4 border border-gray-300 p-6">
    <div class="flex gap-2">
      <base-btn
        label="開始"
        @click="wrapperRef?.start()"
      />
      <base-btn
        label="重置"
        @click="wrapperRef?.reset()"
      />
    </div>

    <wrapper-physics
      v-bind="env"
      ref="wrapperRef"
      class="h-[30rem] w-full flex flex-row-reverse items-center justify-center gap-4 border border-dashed md:w-[40rem]"
    >
      <div
        v-for="text, i in list"
        :key="i"
        class="flex flex-col"
      >
        <wrapper-physics-body
          v-for="word, j in text.split('')"
          v-slot="scope"
          :key="j"
          polygon="circle"
          :friction-air="(j + i) * 0.005"
        >
          <span :style="getStyle(scope)">
            {{ word }}
          </span>
        </wrapper-physics-body>
      </div>
    </wrapper-physics>
  </div>
</template>

<script setup lang="ts">
import type { CSSProperties } from 'vue'
import { ref } from 'vue'
import { getVectorLength, mapNumber } from '../../../common/utils'
import BaseBtn from '../../base-btn.vue'
import WrapperPhysics from '../wrapper-physics.vue'
import WrapperPhysicsBody from '../wrapper-physics-body.vue'

type ScopeProp = Parameters<
  NonNullable<InstanceType<typeof WrapperPhysicsBody>['$slots']['default']>
>[0]

const wrapperRef = ref<InstanceType<typeof WrapperPhysics>>()

const env = ref({
  gravity: {
    scale: 0.0005,
    x: -1,
    y: -0.3,
  },
})

const list = [
  '改不盡的需求與鍵盤的呢喃交織',
  '隨時間凋零,不是任務',
  '而是我不再新鮮的',
  '肝',
]

function getStyle(scope: ScopeProp): CSSProperties {
  /** 計算偏移距離 */
  const distance = getVectorLength({
    x: scope.offsetX,
    y: scope.offsetY,
  })

  /** 偏越多越淡 */
  const opacity = mapNumber(
    distance,
    0,
    scope.height * 5,
    1,
    0,
  )

  return { opacity }
}
</script>

手機感測器

配合手機的加速度計,做出更有趣的互動!(/≧▽≦)/

TIP

感謝前端社團和 Line 社群的朋友們提供靈感!

(o゜▽ ゜)o☆

🐟
🐟
🐟
🐟
🐟
🐟
🐟
🐟
🐟
🐟
🐟
🐟
🐟
🐟
🐟
無法取得此設備的感測器。(>人<;)
查看範例原始碼
vue
<template>
  <div class="relative w-full flex flex-col gap-4 border border-gray-300">
    <wrapper-physics
      v-bind="env"
      immediate
      class="h-[30rem] w-full flex items-center justify-center md:w-[40rem]"
    >
      <template v-for="item in list">
        <wrapper-physics-body
          v-for="index in item.quantity"
          :key="index"
          v-bind="bodyProp"
          class="aspect-square flex select-none items-center justify-center rounded-full bg-slate-300"
          :class="item.class"
        >
          🐟
        </wrapper-physics-body>
      </template>
    </wrapper-physics>

    <div
      v-if="!isSupport"
      class="not-support-box"
    >
      <div class="rounded bg-red-500 p-5 text-white">
        無法取得此設備的感測器。(>人<;)
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { throttleFilter, useDeviceMotion } from '@vueuse/core'
import { isNullish, pipe } from 'remeda'
import { computed, ref, watch } from 'vue'
import { getVectorLength, mapNumber } from '../../../common/utils'
import WrapperPhysics from '../wrapper-physics.vue'
import WrapperPhysicsBody from '../wrapper-physics-body.vue'

const {
  accelerationIncludingGravity: acceleration,
} = useDeviceMotion({
  eventFilter: throttleFilter(35),
})

const isSupport = computed(() => {
  if (acceleration.value === null) {
    return false
  }

  return acceleration.value.x !== null
})

watch(acceleration, (value) => {
  if (!isSupport.value || !value)
    return

  updateEnv(value)
}, { deep: true })

const env = ref({
  gravity: {
    scale: 0.001,
    x: 0,
    y: 1,
  },
})

function updateEnv(acc: DeviceMotionEventAcceleration) {
  const { x, y } = acc
  if (isNullish(x) || isNullish(y))
    return

  const scale = pipe(
    getVectorLength({ x, y }),
    /** 重力最大值為 9.8 */
    (value) => mapNumber(
      value,
      0,
      9.8,
      0,
      0.0005,
    ),
  )

  /** x 方向與環境 x 軸相反 */
  env.value.gravity = {
    scale,
    x: -x,
    y,
  }
}

const bodyProp = {
  frictionAir: 0.01,
  friction: 0.01,
  restitution: 0.1,
}

const list = [
  {
    class: 'w-14 h-14 text-3xl',
    quantity: 2,
  },
  {
    class: 'w-10 h-10',
    quantity: 3,
  },
  {
    class: 'w-6 h-6 text-xs',
    quantity: 10,
  },
]
</script>

<style lang="sass">
.not-support-box
  position: absolute
  width: 100%
  height: 100%
  display: flex
  justify-content: center
  align-items: center
</style>

原理

概念為利用 Matter.js 模擬物理效果,並將對應元素之狀態同步至 DOM 元素上。

甚麼是 Matter.js

Matter.js 是一個很成熟的 JavaScript 2D 物理引擎套件,官網上有很多有趣的範例

📚 Matter.js

具體流程如下:

  1. wrapper-physics 收集內部註冊的 wrapper-physics-body。
  2. 根據 body 的位置、尺寸與 prop,建立對應的 Matter.js 物體。
  3. 透過 Matter.js 模擬物理效果並將物理效果儲存。
  4. body 取得儲存的物理效果,並同步至 DOM 元素上。

如此這般,我們成功實現物理效果模擬了!就像是替身一樣!(ノ>ω<)ノ

JOJO!我不想當 DOM 了!(⊙益⊙)

原始碼

API

需要兩個元件,分別為:

  • wrapper-physics:產生物理世界。
  • wrapper-physics-body:定義物體,放在物理世界中。

🧩 WrapperPhysics

定義物理世界與特性,對其中物體產生交互作用。

Props

interface Props {
  /** 立即開始,物體會在元件建立完成後馬上會開始掉落 */
  immediate?: boolean;

  /** 重力加速度
   *
   * x, y 為加速度的方向,scale 為加速度的大小
   */
  gravity?: Matter.Gravity;
}

Methods

defineExpose({
  /** 開始 */
  start,
  /** 重置所有元素,元素會回到初始位置 */
  reset,
})

🧩 WrapperPhysicsBody

定義物體與性質,放在物理世界中,會受其物理規則影響。

Props

interface Props {
  /** 物體形狀,預設為 rectangle
   *
   * - rectangle:尺寸同 DOM 之長寬
   * - circle:取 DOM 長寬最大值為直徑
   */
  polygon?: 'rectangle' | 'circle';

  /** 空氣阻力。物體在空氣中受到的阻力 */
  frictionAir?: number;
  /** 摩擦力。物體本身的摩擦力,必須為 0 ~ 1,0 表示持續滑動,1 表示受力後會立即停止 */
  friction?: number;
  /** 回彈力。碰撞的回彈係數,0 表示不反彈,1 表示完全反彈 */
  restitution?: number;
  /** 物體質量 */
  mass?: number;
  /** 靜止。會變成像地面那樣完全靜止的存在 */
  isStatic?: boolean;
}

Slots

defineSlots<{
  default?: (props: {
    width: number;
    height: number;
    /** X 偏移量 */
    offsetX: number;
    /** Y 偏移量 */
    offsetY: number;
    /** 旋轉量 */
    rotate: number;
  }) => unknown;
}>()

v0.23.1