哥登堡表單 form
按鈕不會直接給你按,要等球繞完一大圈。(´-ω-`)
靈感來自哥登堡機械(Rube Goldberg machine),動用一整套機關,大費周章完成一件按一下就能做完的事。
技術關鍵字
| 名稱 | 描述 |
|---|---|
| GSAP | 專業級 JavaScript 動畫函式庫,以時間軸編排一連串補間動畫 |
| SVG | 可在 DOM 中使用的圖形格式,適合複雜圖形和動畫,配合 Vue v-bind 可以實現基於資料的動態效果 |
| JS 動畫 | 基於 JavaScript 實現的動畫,達成更複雜、精準的動畫控制,常見套件有 GSAP、anime.js 等 |
| Vue Provide/Inject | Vue 的依賴注入機制,允許父元件向其所有後代元件提供資料或方法 |
| Element getBoundingClientRect() | 取得元素相對視窗的座標與尺寸 |
使用範例
基本用法
輸入框、單選、下拉選單、勾選框、按鈕本身就是機關,放進 form-rube-goldberg 就會自動串起來。
球從漏斗出發,沿著每個元件頂端的導軌左右來回,一路滾到按鈕。
查看範例原始碼
vue
<template>
<div class="relative w-full flex justify-center">
<form-rube-goldberg
ref="machineRef"
class="max-w-[26rem] w-full flex flex-col gap-2"
@submit.prevent="showSubmitted.start()"
>
<form-rube-goldberg-input
v-model="form.nickname"
:label="t('暱稱')"
:placeholder="t('至少 3 個字')"
:min-length="3"
/>
<form-rube-goldberg-radio
v-model="form.frequency"
:label="t('寄送頻率')"
:options="frequencyOptionList"
/>
<form-rube-goldberg-select
v-model="form.topic"
:label="t('感興趣的主題')"
:placeholder="t('選一個主題')"
:options="topicOptionList"
/>
<form-rube-goldberg-checkbox
v-model="form.agreed"
:label="t('我同意收到電子報')"
/>
<form-rube-goldberg-button>
{{ t('訂閱') }}
</form-rube-goldberg-button>
</form-rube-goldberg>
<transition name="opacity">
<div
v-if="isSubmitted"
class="absolute inset-0 z-[40] flex flex-col cursor-pointer items-center justify-center gap-6 rounded-xl bg-slate-600 bg-opacity-90 text-white"
@click="reset"
>
<span class="text-xl tracking-wide">
{{ t('訂閱成功!(*´∀`)~♥') }}
</span>
<span class="text-xs">
{{ t('點一下再來一次') }}
</span>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormRubeGoldbergButton from '../form-rube-goldberg-button.vue'
import FormRubeGoldbergCheckbox from '../form-rube-goldberg-checkbox.vue'
import FormRubeGoldbergInput from '../form-rube-goldberg-input.vue'
import FormRubeGoldbergRadio from '../form-rube-goldberg-radio.vue'
import FormRubeGoldbergSelect from '../form-rube-goldberg-select.vue'
import FormRubeGoldberg from '../form-rube-goldberg.vue'
const { t } = useI18n()
const machineRef = useTemplateRef('machineRef')
const frequencyOptionList = computed(() => [
{ label: t('每天'), value: 'daily' },
{ label: t('每週'), value: 'weekly' },
{ label: t('每月'), value: 'monthly' },
])
const topicOptionList = computed(() => [
{ label: t('程式'), value: 'code' },
{ label: t('設計'), value: 'design' },
{ label: t('遊戲'), value: 'game' },
{ label: t('音樂'), value: 'music' },
{ label: t('電影'), value: 'movie' },
{ label: t('攝影'), value: 'photo' },
{ label: t('美食'), value: 'food' },
])
const form = ref({
nickname: '',
frequency: undefined as string | undefined,
topic: undefined as string | undefined,
agreed: false,
})
const isSubmitted = ref(false)
// 送出時機器會跑一段慶祝,等它跑完再蓋上遮罩
const showSubmitted = useTimeoutFn(() => {
isSubmitted.value = true
}, 900, { immediate: false })
function reset() {
isSubmitted.value = false
form.value = {
nickname: '',
frequency: undefined,
topic: undefined,
agreed: false,
}
machineRef.value?.reset()
}
</script>
<style lang="sass" scoped>
.opacity-enter-active, .opacity-leave-active
transition-duration: 0.4s
.opacity-enter-from, .opacity-leave-to
opacity: 0 !important
</style>原理
外框依 DOM 順序替元件排出方向,第一個由左往右、第二個由右往左,元件之間用管子接起來。球只有一顆,由外框負責畫,滾進元件後交給元件處理。元件決定球怎麼滾、在哪裡等、播什麼動畫,最後再從出口交回外框。
球帶著速度滾動,下坡受重力加速,平路的摩擦把它拉回基本速度,速度在各段之間延續,經過已經打開的機關也不會減速。小珠與勾勾射出的球則依拋體運動飛行。
原始碼
API
外框搭配五個欄位元件使用:
- form-rube-goldberg:外框,負責接管子、滾球。本身就是
<form>,@submit、action、novalidate等屬性直接寫在它身上,不要再包一層 form。 - form-rube-goldberg-input:輸入框。
- form-rube-goldberg-radio:單選。
- form-rube-goldberg-select:下拉選單。右側有一座星形輪,第 0 格是擋片,其餘最多 7 個凹槽,選項依序輪流分到各個凹槽,數量不限。選了就把那個凹槽轉到頂端,球落進去後轉盤轉半圈,把球送進出口管;選回提示時擋片轉回頂端,球已通過就彈飛重跑。
- form-rube-goldberg-checkbox:勾選框。
- form-rube-goldberg-button:按鈕,固定排在最後。柵欄完全拉開前會自動加上
disabled,在輸入框按 Enter 也送不出表單。
name、required、autocomplete 等屬性會交給元件裡的原生控制項,class 與 style 留在元件外層,可以直接用在原生表單送出。
主色用 color 調整,球、正在等待的欄位、漣漪與按鈕會一起換色。要分亮暗色,傳 light-dark(亮色, 暗色) 即可。
vue
<form-rube-goldberg color="light-dark(#0ea5e9, #38bdf8)">
...
</form-rube-goldberg>🧩 FormRubeGoldberg
Props
interface Props {
/** 時間倍率,2 表示快一倍,最小 0.1
*
* @default 1
*/
speed?: number;
/** 表單左右留給連接管的寬度,單位 px,建議至少 28
*
* @default 28
*/
gutter?: number;
/** 主色,用在球、正在等待的欄位、漣漪與按鈕。任何 CSS 顏色都行,
* 要分亮暗色可傳 `light-dark(亮色, 暗色)`
*
* @default 'light-dark(#f43f5e, #fb7185)'
*/
color?: string;
}Emits
const emit = defineEmits<{
/** 球抵達按鈕,柵欄拉起 */
unlock: [];
/** 重新上鎖:已解鎖後有欄位變回無效,或呼叫 reset */
lock: [];
/** 球抵達第 index 個元件 */
progress: [index: number];
}>()Slots
defineSlots<{
default?: (params: {
/** 是否已解鎖 */
unlocked: boolean;
}) => unknown;
}>()Methods
defineExpose({
/** 機關全部復原,球回到漏斗重新開始。只重置機關,不會清空欄位的值 */
reset() {
const token = ++runToken
isResetting = true
stopBall()
arrivedGoal.value = false
// 舊球縮小淡出,零件由下往上依序復位,最後重新冒出新球
const reversedList = [...sortedPartList.value].reverse()
const timeline = gsap.timeline({
onComplete: () => {
if (token === runToken) {
clearProgress()
spawn()
}
},
})
.to(ballStyle, { scale: 0, opacity: 0, duration: 0.25, ease: 'power2.in' })
reversedList.forEach((part, index) => {
timeline.call(() => {
part.reset()
passedIdSet.delete(part.id)
}, [], 0.15 + index * 0.12)
})
timeline.to({}, { duration: 0.2 }).timeScale(speed.value)
},
})🧩 FormRubeGoldbergInput
使用 v-model 綁定字串。
Props
interface Props {
label?: string;
type?: 'text' | 'email' | 'password' | 'tel' | 'url' | 'search';
placeholder?: string;
/** 至少要幾個字才算有效
*
* @default 1
*/
minLength?: number;
/** 要符合的正規表達式 */
pattern?: string;
/** 欄位是否有效。有傳就以它為準,min-length 與 pattern 不再作用 */
ready?: boolean;
}
defineOptions({ inheritAttrs: false })🧩 FormRubeGoldbergRadio
使用 v-model 綁定選中選項的 value。
Props
interface Props {
/** 群組名稱,顯示在選項左邊 */
label?: string;
/** 選項清單,元件建立後請勿增減數量 */
options: Option[];
/** 欄位是否有效。有傳就以它為準,預設是選了任何一個選項就有效 */
ready?: boolean;
}
defineOptions({ inheritAttrs: false })🧩 FormRubeGoldbergSelect
使用 v-model 綁定選中選項的 value,選回提示時為 undefined。
Props
interface Props {
label?: string;
/** 選項清單,數量不限。轉盤最多 7 個凹槽,選項依序輪流分到各個凹槽 */
options: Option[];
/** 還沒選時顯示的提示,也是一個可以選回去的無效選項 */
placeholder?: string;
/** 欄位是否有效。有傳就以它為準,預設是選了任何一個選項就有效 */
ready?: boolean;
}
defineOptions({ inheritAttrs: false })🧩 FormRubeGoldbergCheckbox
使用 v-model 綁定布林值。
Props
interface Props {
label?: string;
/** 欄位是否有效。有傳就以它為準,預設是打勾才有效 */
ready?: boolean;
}
defineOptions({ inheritAttrs: false })Slots
<template>
<div
ref="rootRef"
v-bind="rootAttrs"
class="rg-checkbox"
:class="{ 'is-waiting': isWaitingHint, 'is-rtl': direction === 'rtl' }"
:style="{
'--rg-pulse': `${waitingPulse * 100}%`,
'--rg-select': `color-mix(in srgb, var(--rg-accent) ${effect.checkFlash * 100}%, var(--rg-ink))`,
}"
>
<label class="switch">
<input
v-bind="controlAttrs"
v-model="modelValue"
type="checkbox"
class="native"
>
<span
ref="boxRef"
class="box"
/>
<span class="text">
<slot>{{ props.label }}</slot>
</span>
</label>
<svg
class="mechanism"
:width="width"
:height="height"
aria-hidden="true"
>
<!-- 投石器:以「由左往右」繪製,由右往左時整組鏡像 -->
<g :transform="direction === 'rtl' ? `translate(${width} 0) scale(-1 1)` : undefined">
<!-- 機關:球不在這一格時退到後面 -->
<g
class="parts"
:style="{ opacity: spotOpacity }"
>
<!-- 出口的接球漏斗 -->
<path
:d="catchPath"
class="mech"
/>
<!-- 投擲臂與湯匙 -->
<line
:x1="armStart.x"
:y1="armStart.y"
:x2="spoon.x"
:y2="spoon.y"
class="arm"
/>
<path
d="M -7 -4 Q -7 4 0 4 Q 7 4 7 -4"
class="spoon"
:transform="`translate(${spoon.x} ${spoon.y}) rotate(${mechanism.arm - 180})`"
/>
<!-- 電源符號造型的轉軸:缺口朝下的圓環跟著手臂轉,插銷從缺口插進中心鎖住旋轉 -->
<path
:d="hubPath"
class="hub"
:transform="`rotate(${mechanism.arm - COCKED_ANGLE} ${pivot.x} ${pivot.y})`"
/>
<!-- 打勾時強調色從方框沿繩子流到插銷 -->
<path
:d="stringPath"
class="string"
:style="{ stroke: energyColor }"
/>
<line
:x1="pivot.x"
:y1="pinTopY"
:x2="pivot.x"
:y2="pinTopY + PIN_LENGTH"
class="pin"
:style="{ stroke: energyColor }"
/>
</g>
</g>
<!-- 方框裡的勾號,不跟著鏡像 -->
<path
:d="checkPath"
pathLength="1"
class="check"
:stroke-dasharray="1"
:stroke-dashoffset="1 - mechanism.tick"
/>
</svg>
</div>
</template>
<script setup lang="ts">
import type { BallControl } from '.'
import type { Point } from './motion'
import gsap from 'gsap'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { BALL_RADIUS, OUTSIDE, SPRING } from '.'
import { createProjectile } from './motion'
import { useMachinePart, useSplitAttrs, useWaitingPulse } from './use-machine-part'
// #region Props
interface Props {
label?: string;
/** 欄位是否有效。有傳就以它為準,預設是打勾才有效 */
ready?: boolean;
}
// #endregion Slots
defineOptions({ inheritAttrs: false })
// #endregion Props
const props = withDefaults(defineProps<Props>(), {
label: '',
ready: undefined,
})
// #region Slots
defineSlots<{
/** 標籤內容,預設顯示 label */
default?: () => unknown;
}>()
const modelValue = defineModel<boolean>({ default: false })
const { rootAttrs, controlAttrs } = useSplitAttrs()
/** 投擲臂角度:上膛時指向後方略低,發射後甩到前上方。0 度朝右、順時針為正 */
const COCKED_ANGLE = 172
/** 轉到這個角度時球離開湯匙,此時湯匙正往前上方甩 */
const RELEASE_ANGLE = 235
const FIRED_ANGLE = 290
const ARM_LENGTH = 20
const HUB_RADIUS = 5
const PIN_LENGTH = 12
/** 插銷拔出時往下滑多遠,要大於圓環半徑才會完全離開轉軸 */
const PIN_TRAVEL = 9
/** 缺口還沒轉過來時,插銷只能頂到圓環外緣(含線寬)為止 */
const PIN_BLOCKED = (HUB_RADIUS + 1.2) / PIN_TRAVEL
/** 缺口與插銷的夾角在這個範圍內才插得進去,單位度;缺口單邊有 35 度,插進去後轉軸再轉這麼多也不會卡到 */
const GAP_TOLERANCE = 20
/** 射出時往上衝的高度與重力,單位 px 與 px/s² */
const LAUNCH_HEIGHT = 48
const GRAVITY = 2000
const rootRef = ref<HTMLDivElement>()
const boxRef = ref<HTMLSpanElement>()
const isReady = computed(() => props.ready ?? modelValue.value)
const isBallWaiting = ref(false)
const isWaitingHint = computed(() => isBallWaiting.value && !isReady.value)
/**
* - arm:投擲臂角度
* - pin:插銷拔出程度,0 插著、1 拔出
* - tick:方框裡勾號畫出的程度
*/
const mechanism = reactive({
arm: COCKED_ANGLE,
pin: isReady.value ? 1 : 0,
tick: modelValue.value ? 1 : 0,
})
/**
* - energy:繩子與插銷通上強調色的程度
* - catchSpread:接球漏斗張開的幅度
* - checkFlash:打勾那一刻閃一下強調色,接著退回墨色,強調色只留給「現在」
*/
const effect = reactive({ energy: 0, catchSpread: 0, checkFlash: 0 })
const part = useMachinePart({
elementRef: rootRef,
ready: () => isReady.value,
entry: () => getEntryPoint(),
exit: (size) => ({ x: size.width + OUTSIDE, y: size.height }),
carry,
reset,
})
const { direction, width, height, flip, spotOpacity } = part
/** 球坐在湯匙裡:這段期間投石器不抖動 */
const isBallLoaded = ref(false)
const boxRect = part.useLocalRect(boxRef)
const { pulse: waitingPulse, knock } = useWaitingPulse(rootRef, () => isWaitingHint.value, part.speed)
// --- 幾何,以「由左往右」描述 ---
/** 方框位置換成由左往右的座標;由右往左時方框在右邊,鏡像後剛好回到左邊 */
const boxFlowRect = computed(() => {
const rect = boxRect.value
const isRtl = direction.value === 'rtl'
return {
top: rect.top,
bottom: rect.bottom,
left: isRtl ? width.value - rect.right : rect.left,
right: isRtl ? width.value - rect.left : rect.right,
}
})
/** 球坐在湯匙裡時,球心相對轉軸的位置 */
function getSpoonBallOffset(degree: number): Point {
const radian = degree * Math.PI / 180
const offset = rotateOffset({ x: 0, y: 4 - BALL_RADIUS - 0.75 }, degree - 180)
return { x: ARM_LENGTH * Math.cos(radian) + offset.x, y: ARM_LENGTH * Math.sin(radian) + offset.y }
}
/** 方框對齊欄位左緣,投石器放在左側的管道區:轉軸的位置讓上膛的湯匙剛好在管子正下方,
* 球從管子直接落進湯匙。轉軸對齊方框的垂直中心
*/
const pivot = computed<Point>(() => ({
x: -OUTSIDE - getSpoonBallOffset(COCKED_ANGLE).x,
y: (boxFlowRect.value.top + boxFlowRect.value.bottom) / 2,
}))
/** 插銷上端:插著時頂在轉軸中心,拔出時往下滑出圓環 */
const pinTopY = computed(() => pivot.value.y + PIN_TRAVEL * mechanism.pin)
/** 缺口朝下的圓環,跟插銷合起來就是電源符號 */
const hubPath = computed(() => {
const { x, y } = pivot.value
const gap = 35 * Math.PI / 180
const from = { x: x + HUB_RADIUS * Math.cos(Math.PI / 2 + gap), y: y + HUB_RADIUS * Math.sin(Math.PI / 2 + gap) }
const to = { x: x + HUB_RADIUS * Math.cos(Math.PI / 2 - gap), y: y + HUB_RADIUS * Math.sin(Math.PI / 2 - gap) }
return `M ${from.x} ${from.y} A ${HUB_RADIUS} ${HUB_RADIUS} 0 1 1 ${to.x} ${to.y}`
})
function rotateOffset(offset: Point, degree: number): Point {
const radian = degree * Math.PI / 180
const cos = Math.cos(radian)
const sin = Math.sin(radian)
return { x: offset.x * cos - offset.y * sin, y: offset.x * sin + offset.y * cos }
}
function getArmPoint(degree: number, length: number): Point {
const radian = degree * Math.PI / 180
return {
x: pivot.value.x + length * Math.cos(radian),
y: pivot.value.y + length * Math.sin(radian),
}
}
function getSpoon(degree: number): Point {
return getArmPoint(degree, ARM_LENGTH)
}
/** 球在湯匙裡的位置,湯匙怎麼轉,球就跟著怎麼轉 */
function getSpoonBall(degree: number): Point {
const offset = getSpoonBallOffset(degree)
return { x: pivot.value.x + offset.x, y: pivot.value.y + offset.y }
}
const spoon = computed(() => getSpoon(mechanism.arm))
/** 手臂從圓環外緣伸出去 */
const armStart = computed(() => getArmPoint(mechanism.arm, HUB_RADIUS + 1))
/** 管子的出口,就在上膛的湯匙正上方 */
function getEntryPoint(): Point {
const ball = getSpoonBall(COCKED_ANGLE)
return { x: ball.x, y: ball.y - BALL_RADIUS * 2 - 4 }
}
/** 繩子從插銷底端連到方框左下角,插銷插著時鬆垂,往下拉出時拉直 */
const stringPath = computed(() => {
const from = { x: boxFlowRect.value.left, y: boxFlowRect.value.bottom - 3 }
const to = { x: pivot.value.x, y: pinTopY.value + PIN_LENGTH }
const sag = 6 * (1 - mechanism.pin)
return `M ${from.x} ${from.y} Q ${(from.x + to.x) / 2} ${Math.max(from.y, to.y) + sag} ${to.x} ${to.y}`
})
const checkPath = computed(() => {
const { left, top } = boxRect.value
return `M ${left + 4.5} ${top + 10.5} L ${left + 8.5} ${top + 14.5} L ${left + 15.5} ${top + 5.5}`
})
const exitPoint = computed<Point>(() => ({ x: width.value + OUTSIDE, y: height.value }))
/** 接球漏斗,球落進來時張開一下 */
const catchPath = computed(() => {
const { x, y } = exitPoint.value
const spread = effect.catchSpread
return `M ${x - 11 - spread} ${y - 12} L ${x - 8} ${y} M ${x + 11 + spread} ${y - 12} L ${x + 8} ${y}`
})
/** 繩子與插銷的顏色:平常是欄位的深色,打勾那一刻沿繩子通上強調色 */
const energyColor = computed(() =>
`color-mix(in srgb, var(--rg-accent) ${effect.energy * 100}%, var(--rg-control))`,
)
// --- 動作 ---
watch(modelValue, (checked) => {
gsap.to(mechanism, { tick: checked ? 1 : 0, duration: 0.15, ease: 'power1.out' }).timeScale(part.speed())
if (checked) {
gsap.fromTo(effect, { checkFlash: 1 }, { checkFlash: 0, duration: 0.4, delay: 0.25 / part.speed(), ease: 'power1.in' })
.timeScale(part.speed())
}
})
let pinTween: gsap.core.Timeline | undefined
let fireTween: gsap.core.Timeline | undefined
/** 手臂正在甩,圓環跟著轉,插銷這時插不進去 */
const isFiring = ref(false)
/** 圓環的缺口跟著手臂轉:手臂停在上膛位置附近、沒在甩,缺口才對著插銷 */
const canSeatPin = computed(() => !isFiring.value && Math.abs(mechanism.arm - COCKED_ANGLE) < GAP_TOLERANCE)
/** 取消勾選時插銷要去的位置:缺口對著就插到底,還沒對上就頂在圓環外緣 */
const pinRestTarget = computed(() => canSeatPin.value ? 0 : PIN_BLOCKED)
/** 插回插銷。缺口沒對上時頂在圓環外緣,繩子從鬆垂拉直;缺口一轉過來就喀一聲滑進去 */
function seatPin(isSnap: boolean) {
pinTween?.kill()
const isSeating = pinRestTarget.value === 0
pinTween = gsap.timeline()
.to(mechanism, {
pin: pinRestTarget.value,
duration: isSnap && isSeating ? 0.08 : 0.18,
ease: isSnap && isSeating ? 'power3.in' : 'power1.out',
})
.to(effect, { energy: 0, duration: 0.15 }, 0)
.timeScale(part.speed())
}
// 取消勾選期間缺口轉開或轉回來,插銷跟著頂住或插進去。每次都重新判斷,快速切換也不會讓插銷穿過圓環
watch(pinRestTarget, () => {
if (!isReady.value) {
seatPin(true)
}
})
// 勾號畫完,強調色沿繩子流過去才拔出插銷;拔出瞬間手臂抖一下,像彈簧被釋放。取消勾選時插銷插回去
watch(isReady, (ready) => {
pinTween?.kill()
if (!ready) {
seatPin(false)
return
}
pinTween = gsap.timeline({ delay: 0.1 / part.speed() })
.to(effect, { energy: 1, duration: 0.1, ease: 'power1.out' })
.to(mechanism, { pin: 1, duration: 0.12, ease: 'power2.in' }, 0.04)
.to(effect, { energy: 0, duration: 0.35, ease: 'power1.in' })
.call(() => {
// 湯匙裡沒有球、手臂在上膛位置時才抖,抖動幅度小於判定上膛的容許值
if (!isBallLoaded.value && Math.abs(mechanism.arm - COCKED_ANGLE) < 1 && !fireTween?.isActive()) {
gsap.timeline()
.to(mechanism, { arm: COCKED_ANGLE - 0.9, duration: 0.04, ease: 'sine.inOut' })
.to(mechanism, { arm: COCKED_ANGLE + 0.9, duration: 0.05, ease: 'sine.inOut' })
.to(mechanism, { arm: COCKED_ANGLE, duration: 0.05, ease: 'sine.inOut' })
.timeScale(part.speed())
}
}, [], 0.16)
.timeScale(part.speed())
})
/** 插銷拔掉、手臂在上膛位置,球一進湯匙就會發射 */
const isArmed = () => isReady.value && mechanism.pin > 0.95 && Math.abs(mechanism.arm - COCKED_ANGLE) < 1
/** 手臂甩起;轉到放開角度時球離開湯匙,改成拋體飛向出口 */
function fire(ball: BallControl) {
const releasePoint = getSpoonBall(RELEASE_ANGLE)
const projectile = createProjectile({
from: releasePoint,
to: exitPoint.value,
height: LAUNCH_HEIGHT,
gravity: GRAVITY,
})
const flight = reactive({ time: 0 })
return new Promise<void>((resolve) => {
isFiring.value = true
fireTween = gsap.timeline({
onComplete: () => {
isFiring.value = false
},
})
// 預備動作:球的重量先把手臂壓低、頓一下,接著猛力甩起
.to(mechanism, { arm: COCKED_ANGLE + 6, duration: 0.12, ease: 'power2.out' })
.to({}, { duration: 0.04 })
.to(mechanism, { arm: RELEASE_ANGLE, duration: 0.08, ease: 'power2.in' })
.call(() => {
ball.place(() => flip(projectile.at(flight.time, exitPoint.value)))
})
.addLabel('release')
.to(flight, { time: projectile.duration, duration: projectile.duration, ease: 'none' }, 'release')
// 放開球之後手臂照樣甩到底,撞到底再回彈幾下停住,跟球的飛行同時進行
.to(mechanism, { arm: FIRED_ANGLE, duration: 0.07, ease: 'power1.out' }, 'release')
.to(mechanism, { arm: FIRED_ANGLE - 10, ...SPRING.heavy })
// 落進出口漏斗:球壓扁,漏斗張開一下。手臂可能還在回彈,球先交出去
.call(() => {
ball.impact({ x: 0, y: -1 }, 0.55)
gsap.timeline()
.to(effect, { catchSpread: 2.5, duration: 0.06, ease: 'power2.out' })
.to(effect, { catchSpread: 0, ...SPRING.medium })
.timeScale(part.speed())
resolve()
}, [], `release+=${projectile.duration}`)
.timeScale(part.speed())
})
}
async function carry(ball: BallControl) {
// 從管子出口直接落進湯匙
await ball.roll(() => [getEntryPoint(), getSpoonBall(COCKED_ANGLE)].map(flip))
ball.place(() => flip(getSpoonBall(mechanism.arm)))
ball.impact({ x: 0, y: -1 }, 0.4)
isBallLoaded.value = true
if (!isArmed()) {
isBallWaiting.value = true
// 敲門:球坐在湯匙上,投石器整組不動,只有方框閃一下
await ball.waitUntil(isArmed, { onKnock: knock })
isBallWaiting.value = false
}
await fire(ball)
isBallLoaded.value = false
}
function reset() {
fireTween?.kill()
isFiring.value = false
isBallWaiting.value = false
isBallLoaded.value = false
// 重新上膛,準備下一顆球
gsap.to(mechanism, { arm: COCKED_ANGLE, duration: 0.5, ease: 'power2.inOut' }).timeScale(part.speed())
}
onBeforeUnmount(() => {
fireTween?.kill()
pinTween?.kill()
gsap.killTweensOf([mechanism, effect])
})
</script>
<style scoped lang="sass">
.rg-checkbox
position: relative
padding: 22px 18px 8px
/* 整列都能點,滑過時整列浮出底色。方框對齊其他欄位的左緣,投石器在左側的管道區 */
.switch
display: flex
align-items: center
width: fit-content
margin: -4px -8px
padding: 4px 8px
border-radius: 0.375rem
cursor: pointer
user-select: none
transition: background-color 0.2s
&:hover
background: var(--rg-hover-bg)
/* 由右往左時方框放右邊,投石器跟著鏡像,發射方向才會朝向出口 */
.is-rtl .switch
flex-direction: row-reverse
margin: -4px -8px -4px auto
.native
position: absolute
width: 1px
height: 1px
opacity: 0
pointer-events: none
.box
flex-shrink: 0
width: 20px
height: 20px
border: 2px solid var(--rg-control)
border-radius: 5px
box-shadow: 0 1px 2px var(--rg-control-shadow)
background: var(--rg-surface)
transition: border-color 0.2s, background-color 0.2s, box-shadow 0.2s
.switch:hover .box
border-color: var(--rg-control-hover)
.native:checked + .box
border-color: var(--rg-select)
.native:focus-visible + .box
box-shadow: 0 0 0 3px color-mix(in srgb, var(--rg-ink) 12%, transparent)
.text
margin-left: 12px
font-size: 0.9375rem
.is-rtl .text
margin-right: 12px
margin-left: 0
.is-waiting .box
border-color: color-mix(in srgb, var(--rg-accent) var(--rg-pulse), var(--rg-control))
.mechanism
position: absolute
left: 0
top: 0
z-index: 1
overflow: visible
pointer-events: none
.parts
transition: opacity 0.4s
.mech, .arm, .spoon, .hub
fill: none
stroke: var(--rg-mech)
stroke-width: var(--rg-w-mech)
stroke-linecap: round
stroke-linejoin: round
.spoon
fill: var(--rg-surface)
/* 繩子與插銷用欄位的深色,看得出跟勾選框連在一起 */
.string
fill: none
stroke: var(--rg-control)
stroke-width: var(--rg-w-structure)
.pin
stroke: var(--rg-control)
stroke-width: var(--rg-w-mech)
stroke-linecap: round
.check
fill: none
stroke: var(--rg-select)
stroke-width: 2.25
stroke-linecap: round
stroke-linejoin: round
</style>🧩 FormRubeGoldbergButton
Props
interface Props {
/** @default 'submit' */
type?: 'submit' | 'button';
}
defineOptions({ inheritAttrs: false })Slots
<template>
<div
ref="rootRef"
v-bind="rootAttrs"
class="rg-button"
>
<!-- 柵欄完全離開按鈕上緣才解除停用,按鈕不會比柵欄先亮 -->
<button
v-bind="controlAttrs"
ref="buttonRef"
class="plate"
:type="props.type"
:disabled="!isRevealed"
:style="plateStyle"
>
<slot />
</button>
<svg
class="mechanism"
:width="width"
:height="height"
aria-hidden="true"
>
<defs>
<clipPath :id="clipId">
<rect
:x="buttonFlowRect.left - 3"
:y="buttonFlowRect.top - 3"
:width="buttonFlowRect.width + 6"
:height="buttonFlowRect.height + 6"
rx="10"
/>
</clipPath>
</defs>
<g :transform="direction === 'rtl' ? `translate(${width} 0) scale(-1 1)` : undefined">
<!-- 導軌:球在這一格時加深,不在時退到後面 -->
<g
class="structure"
:class="{ 'is-focused': isFocused }"
:style="{ opacity: structureOpacity }"
>
<line
:x1="-OUTSIDE - 6"
:y1="RAIL_Y"
:x2="bucketX - 9"
:y2="RAIL_Y"
class="rail"
/>
</g>
<!-- 機關:球不在這一格時退到後面 -->
<g
class="parts"
:style="{ opacity: spotOpacity }"
>
<!-- 柵欄閘門,被繩子往上拉 -->
<g :clip-path="`url(#${clipId})`">
<g :transform="`translate(0 ${-drop})`">
<rect
:x="buttonFlowRect.left - 3"
:y="buttonFlowRect.top - 3"
:width="buttonFlowRect.width + 6"
:height="buttonFlowRect.height + 6"
rx="10"
class="grille-frame"
/>
<line
v-for="x in grilleBarList"
:key="x"
:x1="x"
:y1="buttonFlowRect.top - 3"
:x2="x"
:y2="buttonFlowRect.bottom + 3"
class="grille-bar"
/>
<line
:x1="buttonFlowRect.left - 3"
:y1="(buttonFlowRect.top + buttonFlowRect.bottom) / 2"
:x2="buttonFlowRect.right + 3"
:y2="(buttonFlowRect.top + buttonFlowRect.bottom) / 2"
class="grille-bar"
/>
</g>
</g>
<!-- 滑輪與兩條繩子 -->
<line
:x1="bucketX"
:y1="PULLEY_Y"
:x2="buttonFlowRect.right - 6"
:y2="ropeEndY"
class="rope"
/>
<line
:x1="bucketX + 8"
:y1="PULLEY_Y"
:x2="bucketX + 8"
:y2="bucketTop"
class="rope"
/>
<circle
:cx="bucketX + 4"
:cy="PULLEY_Y"
r="4"
class="pulley"
/>
<!-- 輻條:繩子走多遠,滑輪就轉多少 -->
<line
:x1="bucketX + 4"
:y1="PULLEY_Y"
:x2="bucketX + 4"
:y2="PULLEY_Y - 4"
class="spoke"
:transform="`rotate(${drop / 4 * 180 / Math.PI} ${bucketX + 4} ${PULLEY_Y})`"
/>
<path
:d="`M ${bucketX - 8} ${bucketTop} L ${bucketX - 6} ${bucketTop + 12} L ${bucketX + 6} ${bucketTop + 12} L ${bucketX + 8} ${bucketTop}`"
class="bucket"
/>
</g>
</g>
</svg>
</div>
</template>
<script setup lang="ts">
import type { BallControl } from '.'
import type { Point } from './motion'
import gsap from 'gsap'
import { computed, onBeforeUnmount, reactive, ref, useId, watch } from 'vue'
import { BALL_Y, OUTSIDE, RAIL_Y, SPRING } from '.'
import { useMachinePart, useSplitAttrs } from './use-machine-part'
// #region Props
interface Props {
/** @default 'submit' */
type?: 'submit' | 'button';
}
// #endregion Slots
defineOptions({ inheritAttrs: false })
// #endregion Props
const props = withDefaults(defineProps<Props>(), {
type: 'submit',
})
// #region Slots
defineSlots<{
/** 按鈕文字 */
default?: () => unknown;
}>()
const { rootAttrs, controlAttrs } = useSplitAttrs()
const PULLEY_Y = BALL_Y - 12
const BUCKET_TOP = RAIL_Y + 8
const clipId = `rube-goldberg-grille-${useId()}`
const rootRef = ref<HTMLDivElement>()
const buttonRef = ref<HTMLButtonElement>()
/**
* - lowered:0 為吊桶在上、柵欄落下,1 為吊桶沉下、柵欄拉起
* - jolt:疊加在吊桶下沉量上的晃動,接球、沉到底時用
* - tremble:沉下前的細抖,像快撐不住。跟 jolt 分開,接球的回彈還沒停也不會互搶
* - pop、ring:按鈕亮起時的彈跳與擴散光環
*/
const mechanism = reactive({ lowered: 0, jolt: 0, tremble: 0, pop: 1, ring: 0 })
let mechanismTween: gsap.core.Timeline | undefined
const part = useMachinePart({
elementRef: rootRef,
ready: () => true,
goal: true,
entry: () => ({ x: -OUTSIDE, y: BALL_Y }),
exit: () => bucketBallPoint(),
carry,
reset,
})
const { direction, width, height, unlocked, flip, spotOpacity, structureOpacity, isFocused } = part
const buttonRect = part.useLocalRect(buttonRef)
// 單獨使用、沒有外框時,柵欄一開始就是拉起的
mechanism.lowered = unlocked.value ? 1 : 0
/** 柵欄已經完全離開按鈕 */
const isRevealed = ref(unlocked.value)
// --- 幾何,以「由左往右」描述 ---
const buttonFlowRect = computed(() => {
const rect = buttonRect.value
const isRtl = direction.value === 'rtl'
return {
top: rect.top,
bottom: rect.bottom,
left: isRtl ? width.value - rect.right : rect.left,
right: isRtl ? width.value - rect.left : rect.right,
width: rect.width,
height: rect.height,
}
})
const bucketX = computed(() => buttonFlowRect.value.right + 26)
/** 柵欄多拉高一點,完全離開按鈕上緣 */
const drop = computed(() => (buttonFlowRect.value.height + 12) * mechanism.lowered + mechanism.jolt + mechanism.tremble)
const bucketTop = computed(() => BUCKET_TOP + drop.value)
/** 繩子穿過按鈕上緣的開口,柵欄拉進去之後繩頭就停在開口 */
const ropeEndY = computed(() => Math.max(buttonFlowRect.value.top - 3 - drop.value, buttonFlowRect.value.top - 6))
const grilleBarList = computed(() => {
const { left, right } = buttonFlowRect.value
const count = Math.max(2, Math.round((right - left) / 12))
return Array.from({ length: count - 1 }, (_, index) => left + (right - left) * (index + 1) / count)
})
function bucketBallPoint(): Point {
return { x: bucketX.value, y: bucketTop.value + 3 }
}
const plateStyle = computed(() => {
const { pop, ring } = mechanism
return {
transform: pop === 1 ? undefined : `scale(${pop})`,
boxShadow: ring > 0
? `0 0 0 ${ring * 10}px color-mix(in srgb, var(--rg-accent) ${(1 - ring) * 45}%, transparent)`
: undefined,
}
})
// --- 動作 ---
// 解鎖是整台機器的高潮,分三拍:
// 屏息:接住球先頓住,外框趁這時把走過的路回放一遍;緊繃:吊桶細細地抖,像快撐不住;
// 釋放:吊桶猛然沉到底再回彈、柵欄被拉起,柵欄離開按鈕的瞬間按鈕才亮起
// 重新上鎖:按鈕先暗掉,柵欄猛力砸回去
watch(unlocked, (value) => {
mechanismTween?.kill()
if (!value) {
isRevealed.value = false
mechanismTween = gsap.timeline()
.to(mechanism, { lowered: 0, jolt: 0, tremble: 0, pop: 1, ring: 0, duration: 0.35, ease: 'power3.in' })
.timeScale(part.speed())
return
}
mechanismTween = gsap.timeline()
.to({}, { duration: 0.07 })
.to(mechanism, { tremble: 0.6, duration: 0.04, repeat: 5, yoyo: true, ease: 'sine.inOut' })
.to(mechanism, { tremble: 0, duration: 0.03 })
.to(mechanism, { lowered: 1, duration: 0.5, ease: 'power3.in' })
.to(mechanism, { jolt: 5, duration: 0.06, ease: 'power2.out' })
.call(() => {
isRevealed.value = true
})
.to(mechanism, { pop: 1.08, ring: 0.01, duration: 0.1, ease: 'power2.out' }, '<')
.to(mechanism, { jolt: 0, ...SPRING.heavy })
.to(mechanism, { pop: 1, duration: 0.45, ease: 'elastic.out(1, 0.5)' }, '<')
.to(mechanism, { ring: 1, duration: 0.5, ease: 'power1.out' }, '<')
.set(mechanism, { ring: 0 })
.timeScale(part.speed())
})
async function carry(ball: BallControl) {
await ball.roll(() => [
{ x: -OUTSIDE, y: BALL_Y },
{ x: bucketX.value - 10, y: BALL_Y },
bucketBallPoint(),
].map(flip))
// 球留在吊桶裡,之後吊桶怎麼動,球就跟著怎麼動;接球的力道讓吊桶先往下一沉
ball.place(() => flip(bucketBallPoint()))
ball.impact({ x: 0, y: -1 }, 0.7)
gsap.timeline()
.to(mechanism, { jolt: 4, duration: 0.08, ease: 'power2.out' })
.to(mechanism, { jolt: 0, ...SPRING.heavy })
.timeScale(part.speed())
await ball.waitUntil(() => true)
}
function reset() {
// 吊桶跟著解鎖狀態動畫,球被拿走後自然會升回去,這裡不必處理
}
onBeforeUnmount(() => {
mechanismTween?.kill()
gsap.killTweensOf(mechanism)
})
</script>
<style scoped lang="sass">
.rg-button
position: relative
display: flex
justify-content: center
padding: 22px 18px 12px
/* 解鎖前是淡灰的停用樣式;解鎖後換成強調色的邊框與文字,一看就知道現在能按 */
.plate
min-width: 8rem
padding: 0.5rem 1.5rem
border: 1px solid var(--rg-accent)
border-radius: 0.5rem
background: var(--rg-accent-soft)
color: var(--rg-accent)
font-size: 0.9375rem
font-weight: 600
letter-spacing: 0.05em
box-shadow: 0 1px 2px var(--rg-control-shadow)
cursor: pointer
transition: color 0.3s, background-color 0.3s, border-color 0.3s, scale 0.3s
&:hover:not(:disabled)
background: var(--rg-accent)
color: white
&:active:not(:disabled)
scale: 0.97
transition: scale 0.05s
&:disabled
border-color: var(--rg-border)
background: var(--rg-surface)
color: var(--rg-line)
box-shadow: none
cursor: not-allowed
&:focus-visible
outline: 2px solid var(--rg-accent)
outline-offset: 2px
.mechanism
position: absolute
left: 0
top: 0
z-index: 1
overflow: visible
pointer-events: none
.structure, .parts
transition: opacity 0.4s
.rail
stroke: var(--rg-structure)
stroke-width: var(--rg-w-structure)
stroke-linecap: round
.is-focused .rail
stroke: var(--rg-mech)
/* 機關的主體用粗線,柵條、繩子、輻條這類細節用細線 */
.grille-frame, .pulley, .bucket
fill: var(--rg-surface)
stroke: var(--rg-mech)
stroke-width: var(--rg-w-mech)
stroke-linejoin: round
.grille-frame
fill: none
.grille-bar, .rope, .spoke
stroke: var(--rg-mech)
stroke-width: var(--rg-w-structure)
stroke-linecap: round
</style>