Skip to content

每條游來游去的小魚,都是正在瀏覽這一頁的訪客,有一條是你自己 ( ´ ▽ ` )ノ 覺得干擾閱讀的話,可以把小魚關掉。

哥登堡表單 form ​

按鈕不會直接給你按,要等球繞完一大圈。(´-ω-`)

靈感來自哥登堡機械(Rube Goldberg machine),動用一整套機關,大費周章完成一件按一下就能做完的事。

技術關鍵字 ​

名稱描述
GSAP專業級 JavaScript 動畫函式庫,以時間軸編排一連串補間動畫
SVG可在 DOM 中使用的圖形格式,適合複雜圖形和動畫,配合 Vue v-bind 可以實現基於資料的動態效果
JS 動畫基於 JavaScript 實現的動畫,達成更複雜、精準的動畫控制,常見套件有 GSAP、anime.js 等
Vue Provide/InjectVue 的依賴注入機制,允許父元件向其所有後代元件提供資料或方法
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>

v0.91.0