Skip to content

Every little fish swimming around is a visitor on this page right now, and one of them is you ( ´ ▽ ` )ノ If they get in the way of reading, feel free to turn them off.

Rube Goldberg Form form ​

The button won't let you press it until the ball rolls all the way around. (´-ω-`)

Inspired by the Rube Goldberg machine: a whole contraption built to do something a single click could have done.

Tech Keywords ​

NameDescription
GSAPProfessional JavaScript animation library that sequences tweens on timelines
SVGGraphics format usable in the DOM, great for complex shapes and animation; combined with Vue v-bind it enables data-driven effects
JS AnimationJavaScript-driven animation for more complex, precise control; popular libraries include GSAP and anime.js
Vue Provide/InjectVue dependency injection that lets a parent component provide data or methods to all its descendants
Element getBoundingClientRect()Gets an element position and size relative to the viewport

Usage Examples ​

Basic Usage ​

The input, radio group, select, checkbox, and button are machine parts themselves. Put them inside form-rube-goldberg and they get chained together automatically.

The ball leaves the hopper, rolls back and forth along the rail on top of each component, and works its way down to the button.

Every
View example source code
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>

How It Works ​

The frame gives each component a direction in DOM order, the first left to right, the second right to left, and joins them with pipes. There is only one ball, drawn by the frame. When it rolls into a component, the component takes over: it decides how the ball rolls, where it waits, and which animation plays, then hands the ball back at its exit.

The ball carries its speed: it speeds up on the way down, friction pulls it back to a cruising speed on flat rails, and that speed carries over between sections, so it never slows down at a gate that is already open. Beads and the ball flung by the check mark follow projectile motion.

Source Code ​

API ​

The frame works with five field components:

  • form-rube-goldberg: The frame that connects the pipes and rolls the ball. It renders a <form> itself, so put @submit, action, novalidate and the like on it directly instead of nesting another form inside.
  • form-rube-goldberg-input: The input.
  • form-rube-goldberg-radio: The radio group.
  • form-rube-goldberg-select: The select. A star wheel sits on the right: slot 0 is a stopper fin, and up to 7 pockets follow, with the options taking turns around them, so there's no limit on how many options you can have. Picking an option turns its pocket to the top; the ball drops in, the wheel turns half a revolution and drops the ball into the exit pipe. Pick the placeholder again and the fin swings back to the top; if the ball had already passed, it gets knocked off and starts over.
  • form-rube-goldberg-checkbox: The checkbox.
  • form-rube-goldberg-button: The button, always placed last. It stays disabled until the portcullis is fully raised, so pressing Enter in an input can't submit the form either.

Attributes such as name, required and autocomplete go to the native control inside each component, while class and style stay on the component's outer element, so the fields work with native form submission.

Set the main colour with color: the ball, the waiting field, the ripples and the button all follow it. For separate light and dark colours, pass light-dark(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 ​

Bind a string with 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 ​

Bind the value of the selected option with v-model.

Props ​

interface Props {
  /** 群組名稱,顯示在選項左邊 */
  label?: string;
  /** 選項清單,元件建立後請勿增減數量 */
  options: Option[];
  /** 欄位是否有效。有傳就以它為準,預設是選了任何一個選項就有效 */
  ready?: boolean;
}
defineOptions({ inheritAttrs: false })

🧩 FormRubeGoldbergSelect ​

Bind the value of the selected option with v-model; it is undefined when the placeholder is picked.

Props ​

interface Props {
  label?: string;
  /** 選項清單,數量不限。轉盤最多 7 個凹槽,選項依序輪流分到各個凹槽 */
  options: Option[];
  /** 還沒選時顯示的提示,也是一個可以選回去的無效選項 */
  placeholder?: string;
  /** 欄位是否有效。有傳就以它為準,預設是選了任何一個選項就有效 */
  ready?: boolean;
}
defineOptions({ inheritAttrs: false })

🧩 FormRubeGoldbergCheckbox ​

Bind a boolean with 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