iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Modern Web

微光採集瓶:用 Vue 3 打造每日美好記錄網頁系列 第 12 篇

【Day 12】自然散落效果:用隨機微位移打破過度整齊的堆疊

  • 分享至 

  • xImage
  •  

現在瓶子裡的光珠,雖然會沉底、往上堆疊,但每一顆都排成一整齊的橫排,像是被尺量過一樣,這在真實世界裡幾乎不可能發生,一把珠子灑進瓶子,一定不會是這麼整齊的。今天,我們要讓光珠帶著一點點隨機位移,看起來更自然、合理。

一、用微幅位移營造高低落差

珠子或小石頭掉進玻璃罐時,之所以看起來自然,主要來自兩個細節:

  1. 高低落差(垂直位移):珠子落下的時候會卡在彼此的縫隙裡,有的陷下去一點、有的稍微凸出來一點,不會剛好連成一條水平直線。
  2. 左右錯位(水平位移):每一排的珠子不會乖乖對齊上一排的中線,會有些微的左右偏差。

因此,只要讓每顆光珠在生成時,身上帶有一點點微小的水平與垂直隨機偏移(例如 -4px 到 +4px),原本生硬的排版就會瞬間產生自然的堆疊落差。

補充

如果要做到像真實世界那樣:小球掉下來會互撞、沿著圓弧表面滾動、滑進凹槽、最後堆成一座中間隆起的小山丘,純靠 Vue 和 CSS 排版其實很難做到完全逼真,前端標準解法通常是引入 2D 物理引擎(例如 Matter.js)。

但使用物理引擎通常需要把元素畫在 Canvas 上,這會帶來一些額外的代價:

  1. 原本用 CSS 寫好的發光效果(box-shadow)沒辦法直接沿用。
  2. 後續要做的「點擊光珠看小卡」或「月底流星離場動畫」,在 Canvas 上的處理會複雜許多,偏離目前以 Vue 3 核心功能為主的開發節奏。

因此現階段我們先用 Vue 與 CSS 能做到的方式(微幅隨機位移),先做出錯落有致的光珠。至於真正的碰撞與滾動模擬,就留作之後有時間再挑戰的擴充功能!

二、在資料物件中加入隨機偏移量

既然畫面是由資料決定的,最保險的做法,就是讓每顆光珠在產生時,把自己的位移量一起存進資料裡。
打開 src/components/GemJar.vue,找到 addGem() 函式。我們在組裝 newGem 物件的地方,加入兩組隨機數:

function addGem() {
  if (todayNote.value.trim() === '') {
    alert('請先寫下一件今天的美好小事喔!')
    return
  }

  // X軸:產生 -4px ~ +4px 之間的隨機整數偏移量
  const randomOffsetX = Math.floor(Math.random() * 9) - 4
  // Y軸:產生 -3px ~ +3px 之間的隨機整數偏移量
  const randomOffsetY = Math.floor(Math.random() * 7) - 3

  // 1. 組成新光珠物件(紀錄微幅偏移量)
  const newGem = {
    id: Date.now(),
    color: selectedColor.value,
    note: todayNote.value,
    offsetX: randomOffsetX, // X 軸水平偏移量
    offsetY: randomOffsetY  // Y 軸垂直偏移量
  }

  gems.value.push(newGem)

  isGlowing.value = true
  todayNote.value = ''

  setTimeout(() => {
    isGlowing.value = false
  }, 1000)
}

名詞小科普:什麼是 Offset?

「Offset」在程式設計中通常翻作「偏移量」。意思是相較於原本應該排在的預設基準位置,往左、右或上、下偏離了多少距離。

小提醒:
位移幅度建議抓在 3~4px 左右即可。如果偏移量太大,光珠之間會重疊得太誇張,甚至穿出瓶身外;微幅的錯位剛剛好能打破規矩感,同時也不會讓瓶內看起來太亂。

三、動態綁定到畫面元素上:透過 CSS 變數解耦動畫

我一開始想用 marginLeft 與 marginTop 來推動偏移,但實測後發現:在 Flexbox 的交叉軸(垂直軸)上,單顆珠子的 marginTop 容易被整排的行高機制吸收,導致只有左右水平在動、垂直方向完全拉不開高低差。
如果直接用 transform: translate() 加上位移,就會跟昨天寫好的 @keyframes dropIn 衝突,因為動畫裡的 transform 會蓋掉我們在 HTML 行內寫的位移。

最終得到較好的解法是:把隨機偏移量透過 CSS 變數(CSS Variables)傳入,並在 @keyframes 中直接採用。

1. <template> 傳入 CSS 變數

<div class="gem-pile">
  <div
    v-for="gem in gems"
    :key="gem.id"
    class="gem-dot"
    :style="{
      backgroundColor: gem.color,
      '--offset-x': `${gem.offsetX}px`,
      '--offset-y': `${gem.offsetY}px`
    }"
  ></div>
</div>

語法小科普:為什麼變數名稱前面要加 --?

這是 W3C 官方規定的「CSS 自訂屬性(Custom Properties)」標準語法。

因為 CSS 已經有成百上千個官方原生屬性(如 color、margin),為了避免開發者自己取的變數名稱跟未來 CSS 官方可能推出的新屬性撞名,規定所有自訂變數開頭一律強制加上兩條破折號 --。

只要瀏覽器看到 -- 開頭,就知道這不是內建排版樣式,而是我們自訂的變數

2. <style scoped> 讓掉落動畫停在計算好的位置

在 CSS 裡,我們使用原生函式 var(--offset-x, 0) 來讀取傳進來的數值(後面的 , 0 是備用預設值)。讓下墜、輕彈到最後定格的每個階段,都讀取剛才傳進來的變數:

/* 單顆光珠:透過 CSS 變數接收 (X, Y) 偏移量 */
.gem-dot {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  box-shadow: 0 0 6px rgba(255, 255, 255, 0.3);
  animation: dropIn 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}

@keyframes dropIn {
  0% {
    transform: translate(var(--offset-x, 0), -60px);
    opacity: 0;
  }
  60% {
    transform: translate(var(--offset-x, 0), calc(var(--offset-y, 0) + 4px));
    opacity: 1;
  }
  80% {
    transform: translate(var(--offset-x, 0), calc(var(--offset-y, 0) - 2px));
  }
  100% {
    /* 最終停在計算好的 (X, Y) 隨機偏移點 */
    transform: translate(var(--offset-x, 0), var(--offset-y, 0));
  }
}

四、成果驗證

https://ithelp.ithome.com.tw/upload/images/20260926/201780672ZDj2dv3Zb.png

存檔後回到瀏覽器測試:

  1. 連續放入 10 顆以上的光珠,觀察瓶底的堆疊狀況。
  2. 確認光珠之間是不是有了高低起伏,不再完全整齊擺放?
  3. 確認新光珠從上方落下時動畫正常播放,落地後停在自己的隨機位置上?

今日小結

  • 營造自然堆疊:透過微幅的垂直與水平隨機位移(-4px 到 +4px),讓光珠堆疊看起來更隨性自然。
  • 資料驅動樣式:將外觀偏移量直接記錄在光珠物件中,資料結構更完整,後續存取或讀取歷史紀錄時外觀也不會隨機抽換。
  • CSS 變數結合動畫:利用 CSS 自訂屬性(--offset)將動態座標注入 @keyframes,既能打破 Flexbox 的行高限制拉出高低差,又不會覆蓋掉落動畫。

明日預告

現在光珠一顆顆漂亮且自然地堆在瓶子裡了。但採集光珠最大的價值,在於回溫當初的心情。明天(Day 13),我們要替光珠加上「懸浮與點擊預覽」功能:滑鼠移到光珠上時稍微浮起提示,點擊後彈出小對話框,重新看見那天寫下的那件微小美好。


上一篇
【Day 11】動態互動:讓光珠從瓶口落下的進場動畫
下一篇
【Day 13】點亮記憶:光珠懸浮回饋與點擊預覽卡片
系列文
微光採集瓶:用 Vue 3 打造每日美好記錄網頁 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言