Physics Wrapper wrapper
Creates a physics world, giving internal elements physical effects ヾ(⌐■_■)ノ♪
Usage Examples
Basic Usage
Once running, elements wrapped by body will have physics effects.
View example source code
<template>
<div class="w-full flex flex-col gap-4 border border-gray-200 rounded-xl">
<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="t('start')"
@click="wrapperRef?.start()"
/>
</wrapper-physics-body>
<wrapper-physics-body>
<base-btn
:label="t('reset')"
@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.textList"
: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 { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import WrapperPhysics from '../wrapper-physics.vue'
import WrapperPhysicsBody from '../wrapper-physics-body.vue'
const { t } = useI18n()
const wrapperRef = ref<InstanceType<typeof WrapperPhysics>>()
const sections = computed(() => [
{
class: 'text-lg font-bold mt-2',
textList: t('title').split(''),
},
{
class: 'text-sm opacity-50 mb-2',
textList: t('subtitle').split('|'),
},
{
textList: t('quote1').split('|'),
},
{
textList: t('quote2').split('|'),
},
{
textList: t('quote3').split('|'),
},
{
textList: t('quote4').split('|'),
},
{
textList: t('quote5').split('|'),
},
{
textList: t('quote6').split('|'),
},
{
textList: t('quote7').split('|'),
},
{
textList: t('quote8').split('|'),
},
])
</script>Body Properties
Adjust physical properties to make elements go wild! ᕕ( ゚ ∀。)ᕗ
Codfish: "Why do I feel like this example is roasting me? ╭(°A ,°`)╮"
View example source code
<template>
<div class="w-full flex flex-col gap-4 border border-gray-200 rounded-xl p-6">
<div class="flex gap-2">
<base-btn
:label="t('start')"
@click="wrapperRef?.start()"
/>
<base-btn
:label="t('reset')"
@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 { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import WrapperPhysicsBody from '../wrapper-physics-body.vue'
import WrapperPhysics from '../wrapper-physics.vue'
type BodyProps = InstanceType<typeof WrapperPhysicsBody>['$props']
interface Item extends BodyProps {
text: string;
}
const { t } = useI18n()
const wrapperRef = ref<InstanceType<typeof WrapperPhysics>>()
/** 回彈力設為 1.5,表示回彈速度會是進入速度的 1.5 倍
*
* 畫面會超嗨喔!ᕕ( ゚ ∀。)ᕗ
*/
const restitution = 1.5
const list = computed<Item[]>(() => [
{ text: t('codfish'), isStatic: true },
{ text: t('really'), restitution },
{ text: t('not'), restitution },
{ text: t('is'), restitution },
{ text: t('a'), frictionAir: 1 },
{ text: t('kind'), restitution },
{ text: t('of'), restitution },
{ text: t('veryFat'), isStatic: true },
{ text: t('de'), restitution },
{ text: '🐟', restitution },
])
</script>Adjust Gravity
Adjust the gravity direction to make elements float! (ノ>ω<)ノ
View example source code
<template>
<div class="w-full flex flex-col gap-4 border border-gray-200 rounded-xl 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="">
{{ t('gravityDirection') }}
<analog-stick
size="14rem"
@trigger="handleTrigger"
/>
</div>
<div class="flex-col">
<base-input
v-model.number="bodyProp.friction"
type="range"
:label="`${t('friction')}: ${bodyProp.friction}`"
class="w-full"
:min="0"
:step="0.01"
:max="1"
/>
<base-input
v-model.number="bodyProp.frictionAir"
type="range"
:label="`${t('airFriction')}: ${bodyProp.frictionAir}`"
class="w-full"
:min="0"
:step="0.01"
:max="1"
/>
<base-input
v-model.number="bodyProp.restitution"
type="range"
:label="`${t('restitution')}: ${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 { useI18n } from 'vue-i18n'
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 { t } = useI18n()
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>Window Scroll
Combined with scroll effects, mapping scroll speed to gravitational acceleration to create a shaking effect.
Codfish: "Stop bullying the fish! Σ(っ °Д °;)っ"
View example source code
<template>
<div class="w-full flex flex-col gap-4 border border-gray-200 rounded-xl">
<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>Body Shape
Adjust the hit box shape of objects—for example, a circle better matches Codfish's real body shape. ╮(╯▽╰)╭
Codfish: "Are you pretending I can't hear you? ಠ_ಠ"
What is a hit box?
A hit box is the actual shape that participates in collisions in the physics world.
You're probably wondering why not use the object's own shape?
Because objects are usually complex, and using overly complex shapes for calculations would cause performance issues, so typically a simpler collision shape is used instead. (. ❛ ᴗ ❛.)
View example source code
<template>
<div class="w-full flex flex-col gap-4 border border-gray-200 rounded-xl">
<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="">
{{ t('gravityDirection') }}
<analog-stick
size="14rem"
@trigger="handleTrigger"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import AnalogStick from '../../analog-stick.vue'
import WrapperPhysics from '../wrapper-physics.vue'
import WrapperPhysicsBody from '../wrapper-physics-body.vue'
const { t } = useI18n()
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
Use Scope Prop to create even more possibilities.
View example source code
<template>
<div class="w-full flex flex-col gap-4 border border-gray-200 rounded-xl p-6">
<div class="flex gap-2">
<base-btn
:label="t('start')"
@click="wrapperRef?.start()"
/>
<base-btn
:label="t('reset')"
@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 { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { getVectorLength, mapNumber } from '../../../common/utils'
import BaseBtn from '../../base-btn.vue'
import WrapperPhysicsBody from '../wrapper-physics-body.vue'
import WrapperPhysics from '../wrapper-physics.vue'
type ScopeProps = Parameters<
NonNullable<InstanceType<typeof WrapperPhysicsBody>['$slots']['default']>
>[0]
const { t } = useI18n()
const wrapperRef = ref<InstanceType<typeof WrapperPhysics>>()
const env = ref({
gravity: {
scale: 0.0005,
x: -1,
y: -0.3,
},
})
const list = computed(() => [
t('poemLine1'),
t('poemLine2'),
t('poemLine3'),
t('poemLine4'),
])
function getStyle(scope: ScopeProps): CSSProperties {
/** 計算偏移距離 */
const distance = getVectorLength({
x: scope.offsetX,
y: scope.offsetY,
})
/** 偏越多越淡 */
const opacity = mapNumber(
distance,
0,
scope.height * 5,
1,
0,
)
return { opacity }
}
</script>Device Motion Sensor
Combine with the phone's accelerometer for even more fun interactions! (/≧▽≦)/
TIP
Thanks to the friends in the frontend community and Line groups for the inspiration!
(o゜▽ ゜)o☆
View example source code
<template>
<div class="relative w-full flex flex-col gap-4 border border-gray-200 rounded-xl">
<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">
{{ t('sensorNotAvailable') }}
</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 { useI18n } from 'vue-i18n'
import { getVectorLength, mapNumber } from '../../../common/utils'
import WrapperPhysics from '../wrapper-physics.vue'
import WrapperPhysicsBody from '../wrapper-physics-body.vue'
const { t } = useI18n()
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>Polite Quotation
When the budget is too low, disband on the spot! ◝( •ω• )◟
View example source code
<template>
<div class="w-full flex flex-col gap-4 border border-gray-200 rounded-xl p-6">
<!-- 預算 radio -->
<div class="flex flex-col items-start gap-4 border border-gray-200 rounded-xl rounded-xl p-6">
<div class="font-bold">
{{ t('selectBudget') }}
</div>
<div class="w-full flex flex-wrap justify-between gap-4 whitespace-nowrap">
<label
v-for="item, i in budgetList"
:key="i"
class="flex-1 cursor-pointer border rounded p-4 duration-300 hover:bg-gray-200/50"
:class="{ active: budget === i }"
>
<input
v-model="budget"
type="radio"
:value="i"
>
{{ item.text }}
</label>
</div>
</div>
<wrapper-physics
ref="wrapperRef"
class="h-[30vh] w-full flex flex-col items-center justify-center gap-2"
:class="{ 'select-none': budget === 0 }"
>
<div
v-for="row, i in rows"
:key="i"
class="min-h-[1rem] flex"
>
<wrapper-physics-body
v-for="item in row"
:key="item.text"
:restitution="0.98"
:friction="0.005"
:friction-air="0.005"
v-bind="item"
>
{{ item.text }}
</wrapper-physics-body>
</div>
</wrapper-physics>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import WrapperPhysicsBody from '../wrapper-physics-body.vue'
import WrapperPhysics from '../wrapper-physics.vue'
type BodyProps = InstanceType<typeof WrapperPhysicsBody>['$props']
interface Row extends BodyProps {
text: string;
}
const { t } = useI18n()
const wrapperRef = ref<InstanceType<typeof WrapperPhysics>>()
const rows = computed<Row[][]>(() => [
[
{ text: t('businessHours'), isStatic: true },
{ text: t('no'), restitution: 1.4 },
{ text: t('rest'), isStatic: true },
],
'E-mail:[email protected]'.split('').map((char) => ({ text: char })),
])
const budget = ref(-1)
const budgetList = computed(() => [
{ text: t('budgetLow') },
{ text: t('budgetMid') },
{ text: t('budgetHigh') },
])
watchEffect(() => {
if (budget.value === 0) {
wrapperRef.value?.start()
}
else {
wrapperRef.value?.reset()
}
})
</script>How It Works
The concept is to use Matter.js to simulate physics effects and synchronize the corresponding element states to DOM elements.
What is Matter.js?
Matter.js is a mature JavaScript 2D physics engine library with many interesting examples on its website.
The specific process is as follows:
- wrapper-physics collects internally registered wrapper-physics-body elements.
- Creates corresponding Matter.js bodies based on each body's position, size, and props.
- Simulates physics effects through Matter.js and stores the results.
- Each body retrieves the stored physics effects and synchronizes them to the DOM elements.
And just like that, we've successfully implemented physics simulation! It's like having a stand! (ノ>ω<)ノ
JOJO! I don't want to be a DOM anymore! (⊙益⊙)
For detailed explanation, see this article.
Source Code
API
Two components are needed:
- wrapper-physics: Creates the physics world.
- wrapper-physics-body: Defines objects placed in the physics world.
🧩 WrapperPhysics
Defines the physics world and its properties, creating interactions between the objects within it.
Props
interface Props {
/** 立即開始,物體會在元件建立完成後馬上會開始掉落 */
immediate?: boolean;
/** 重力加速度
*
* x, y 為加速度的方向,scale 為加速度的大小
*/
gravity?: Matter.Gravity;
}Methods
defineExpose({
/** 開始 */
start,
/** 重置所有元素,元素會回到初始位置 */
reset,
})🧩 WrapperPhysicsBody
Defines objects and their properties, placed in the physics world and affected by its rules.
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;
}>()