現在瓶子裡的光珠,雖然會沉底、往上堆疊,但每一顆都排成一整齊的橫排,像是被尺量過一樣,這在真實世界裡幾乎不可能發生,一把珠子灑進瓶子,一定不會是這麼整齊的。今天,我們要讓光珠帶著一點點隨機位移,看起來更自然、合理。
珠子或小石頭掉進玻璃罐時,之所以看起來自然,主要來自兩個細節:
因此,只要讓每顆光珠在生成時,身上帶有一點點微小的水平與垂直隨機偏移(例如 -4px 到 +4px),原本生硬的排版就會瞬間產生自然的堆疊落差。
如果要做到像真實世界那樣:小球掉下來會互撞、沿著圓弧表面滾動、滑進凹槽、最後堆成一座中間隆起的小山丘,純靠 Vue 和 CSS 排版其實很難做到完全逼真,前端標準解法通常是引入 2D 物理引擎(例如 Matter.js)。
但使用物理引擎通常需要把元素畫在 Canvas 上,這會帶來一些額外的代價:
box-shadow)沒辦法直接沿用。因此現階段我們先用 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 左右即可。如果偏移量太大,光珠之間會重疊得太誇張,甚至穿出瓶身外;微幅的錯位剛剛好能打破規矩感,同時也不會讓瓶內看起來太亂。
我一開始想用 marginLeft 與 marginTop 來推動偏移,但實測後發現:在 Flexbox 的交叉軸(垂直軸)上,單顆珠子的 marginTop 容易被整排的行高機制吸收,導致只有左右水平在動、垂直方向完全拉不開高低差。
如果直接用 transform: translate() 加上位移,就會跟昨天寫好的 @keyframes dropIn 衝突,因為動畫裡的 transform 會蓋掉我們在 HTML 行內寫的位移。
最終得到較好的解法是:把隨機偏移量透過 CSS 變數(CSS Variables)傳入,並在 @keyframes 中直接採用。
<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 官方可能推出的新屬性撞名,規定所有自訂變數開頭一律強制加上兩條破折號--。只要瀏覽器看到
--開頭,就知道這不是內建排版樣式,而是我們自訂的變數
<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));
}
}

存檔後回到瀏覽器測試:
--offset)將動態座標注入 @keyframes,既能打破 Flexbox 的行高限制拉出高低差,又不會覆蓋掉落動畫。現在光珠一顆顆漂亮且自然地堆在瓶子裡了。但採集光珠最大的價值,在於回溫當初的心情。明天(Day 13),我們要替光珠加上「懸浮與點擊預覽」功能:滑鼠移到光珠上時稍微浮起提示,點擊後彈出小對話框,重新看見那天寫下的那件微小美好。