# 禮物開箱動態|AI 使用規格(gift-open v1.0)
> 給 AI/工程師直接照做的規格。只靠 CSS 動畫+一支精簡的 JS 控制器,無任何相依套件。
> 預覽與互動說明:https://gift-open-fx.pages.dev/ | 單檔可直接開啟:https://gift-open-fx.pages.dev/gift-open.standalone.html
## 0. 一句話
點擊按鈕後:禮物盒抖動 0.7 秒 → 閃光蓋住 → 最亮時把「未開」圖換成「已開」圖 → 光芒爆開並慢轉、一圈光環、12 枚金幣(幣面為錢幣符號)+8 顆閃光點噴出、4 顆浮光點持續飄、獎勵數字浮出。全程約 2.3 秒,之後只剩光芒慢轉與浮光點。
## 1. 檔案與取得方式
全部是靜態檔,可直接下載。
| 檔案 | 用途 |
|---|---|
| https://gift-open-fx.pages.dev/gift-open.css | 全部動畫與樣式(本文第 10 節即為其完整內容) |
| https://gift-open-fx.pages.dev/gift-open.js | 狀態控制器 `GiftOpen`(本文第 11 節即為其完整內容) |
| https://gift-open-fx.pages.dev/assets/gift_closed.webp | 未開圖,340×340 透明 WebP(≈18 KB) |
| https://gift-open-fx.pages.dev/assets/gift_open.webp | 已開圖,340×340 透明 WebP(≈28 KB),與未開圖「同框」 |
| https://gift-open-fx.pages.dev/gift-open.standalone.html | 單一檔案版(CSS、JS、圖片都內嵌),拿來直接開或當範例 |
## 2. HTML 結構
根元素 class 必須是 `gfx`;子元素的順序決定疊層,不可調換。
```html
+6.00
```
## 3. 初始化與 API
```js
const gift = GiftOpen(document.getElementById('gift'), {
speed: 1, // 時間倍率;慢動作除錯用 2、4
flash: true, // false=不閃光,兩張圖直接硬切
onOpen: () => {}, // 換成「已開」圖的那一刻(可在此呼叫領取 API 的 UI 更新)
onDone: () => {} // 整段播完(約 2.3 秒)
});
button.onclick = () => gift.play(); // 回傳 Promise;播放中重複呼叫會回傳同一個 Promise
// gift.reset() 回到未開狀態並重新產生金幣、星星
// gift.setSpeed(2) setFlash(false) destroy()
```
**按鈕載入狀態(點擊後按鈕內要轉圈)**:
```html
```
```js
claim.addEventListener('click', () => {
claim.disabled = true; // 不可重複點
claim.setAttribute('aria-busy', 'true');
claim.innerHTML = ''; // 按鈕內轉圈
gift.play();
});
// onDone 時:claim.disabled = false; claim.removeAttribute('aria-busy'); claim.textContent = '重播';
```
轉圈樣式 `.gfx-spinner` 已包含在 gift-open.css(第 12 段),顏色用 `--gfx-spinner-rgb` 調整。
- `--gfx-speed` 會被寫在 `.gfx` 的父元素上,讓同層的 `.gfx-reward` 一起變慢。
- 初始化時會先 `decode()` 兩張圖,換圖那一刻不會閃空白。
## 4. 狀態機與時間軸
speed = 1,單位 ms,自 `play()` 起算。
| 時間 | 動作(JS 對 `.gfx` 加/去 class) | 發生什麼 |
|---|---|---|
| 0 | 加 `is-playing` `is-shaking` | 盒子左右抖動並逐步放大到 1.08;光暈放大到 1.25 倍(0.7 s) |
| 700 | 加 `is-flashing` | 閃光自中心擴散(0.62 s,約 190 ms 時最亮) |
| 890 | 去 `is-shaking`;加 `is-open` `is-pop` `is-burst`;呼叫 `onOpen` | 閃光最亮 → 換成已開圖;光環一圈(1.1 s);光芒爆開(1.2 s)後開始慢轉;亮度閃一下(1.2 s);金幣 12+星星 8 噴出(0.9–1.3 s);浮光點淡入;盒子彈跳(0.55 s);獎勵數字浮出(延遲 0.25 s、0.5 s) |
| 2290 | 去 `is-playing`;呼叫 `onDone` | 播完。之後只剩光芒慢轉+浮光點循環 |
| 任意 | 切換 `is-paused` | 元件離開畫面或分頁隱藏時,暫停全部動畫 |
## 5. CSS 變數(Token)
| 變數 | 預設 | 說明 |
|---|---|---|
| `--gfx-speed` | `1` | 全部時間乘上此倍率。設在 `:root` 或與 `.gfx-reward` 的共同祖先 |
| `--gfx-w` `--gfx-h` | `323px` `220px` | 舞台尺寸;所有效果以中心為原點,超出舞台不裁切 |
| `--gfx-box` | `170px` | 盒子邊長。圖檔是 340px(2 倍解析度),請維持圖檔 ≥ 顯示尺寸的 2 倍 |
| `--gfx-halo` | `240px` | 常駐光暈直徑 |
| `--gfx-rays` | `380px` | 光芒直徑。可見半徑約為直徑的 0.34 倍(≈130px);要更大範圍改 440(≈150px) |
| `--gfx-spin` | `16s` | 光芒轉一圈的時間 |
| `--gfx-gold` | `255,238,175` | 主光色 RGB(逗號分隔,用於 `rgba(var(--gfx-gold), α)`)。換冷色例:`200,230,255` |
| `--gfx-gold-hi` | `255,246,205` | 開箱瞬間的亮部光色 |
| `--gfx-mote-scale` | `.743` | 浮光點整體範圍 |
| `--gfx-coin-symbol` | `"$"` | 噴出金幣的幣面符號,**字串需含引號**。例:`"¥"`、`"€"`。設在 `:root` 或任一祖先 |
| `--gfx-spinner-rgb` | `42,29,0` | 按鈕內轉圈的顏色 RGB(預設為金色按鈕上的深色字) |
## 6. 元素與 keyframes 對照
| 元素 | 作用 | keyframes | 時長/緩動 | 只動的屬性 |
|---|---|---|---|---|
| `.gfx__box` | 抖動 | `gfx-shake` | 0.7 s/ease-in-out | transform |
| `.gfx__box` | 開啟彈跳 | `gfx-pop` | 0.55 s/cubic-bezier(.2,1.6,.4,1) | transform |
| `.gfx__flash` | 閃光 | `gfx-flash` | 0.62 s/ease-out | opacity, transform |
| `.gfx__halo` | 開箱後光暈 | `gfx-halo-beat` | 1.2 s/ease-out | transform, opacity |
| `.gfx__ring` | 光環(只播一次) | `gfx-ring-once` | 1.1 s/cubic-bezier(.15,.7,.3,1) | transform, opacity |
| `.gfx__rays` | 光芒爆開 | `gfx-rays-burst` | 1.2 s/cubic-bezier(.1,.8,.3,1) | transform, opacity |
| `.gfx__rays` | 光芒慢轉 | `gfx-spin` | 16 s/linear,延遲 1.2 s,無限 | rotate |
| `.gfx__glow` | 亮度閃一下 | `gfx-glow-pulse` | 1.2 s/ease-out | opacity |
| `.gfx__coin` ×12 | 噴出(幣面為 `--gfx-coin-symbol`) | `gfx-coin` | 0.9–1.3 s(隨機)/cubic-bezier(.15,.7,.3,1) | transform, opacity |
| `.gfx__spark` ×8 | 閃點 | `gfx-spark` | 0.9 s/ease-out | transform, opacity |
| `.gfx__mote` ×4 | 上飄+左右飄移+閃爍 | `gfx-mote-rise`/`-sway`/`-twinkle` | 4.2–7 s/1.8–3.2 s/1–2.2 s,無限 | transform, translate, opacity |
| `.gfx-spinner` | 按鈕內轉圈(選用) | `gfx-spinner` | 0.8 s/linear,無限;不受 `--gfx-speed` 影響 | transform |
| `.gfx-reward` | 數字浮出 | `gfx-rise` | 0.5 s(延遲 0.25 s)/ease-out | opacity, transform |
## 7. 素材規格
要換成別的主題(寶箱、紅包⋯)時照此準備。
- 兩張圖:未開、已開;透明背景 PNG/WebP;顯示尺寸的 2 倍以上(預設 340px)。
- **兩張必須同框**:同一裁切框、同角度、同面向、同大小與位置,只有蓋子/內容不同。換圖那一刻才銜接得上。
- **已開圖不可烤入飛散的金幣、光芒、星星**,這些全由 CSS 負責,否則會出現兩層特效。
- 兩張圖的外框範圍請對齊畫布,不要各自撐滿畫面。
## 8. 效能與無障礙規則
以下必須遵守。
1. **只動 `transform`、`opacity`、`rotate`、`scale`、`translate`。** 禁止動畫 width/height/top/left/margin/box-shadow/filter。
2. `will-change` 只在 `.is-playing` 期間存在;不要常駐。
3. 離開視窗或分頁隱藏時必須暫停(控制器已處理,切換 `.is-paused`)。
4. 整個元件只有光環一處使用 `mix-blend-mode: screen`,不要再加其他混合模式。
5. 元件的容器不可 `overflow:hidden`(光芒、光環、閃光會超出舞台)。若特效超出視窗寬度造成**頁面橫向捲軸**(手機常見,透明的層也會撐寬),只在最外層頁面容器加 `overflow-x:clip`,不要加在元件的容器上。元件本身有 `isolation:isolate`,特效不會蓋到外面的文字。
6. 同一頁面同時在畫面內的實例建議 ≤ 3 個。
7. 尊重 `prefers-reduced-motion: reduce`:已內建——不抖、不閃、不噴金幣,光芒只淡入。
8. 動畫是純裝飾,根元素已加 `aria-hidden="true"`;獎勵結果請另外用真正的文字(例如 `aria-live="polite"`)告知。
實測條件:桌機 Chrome、CPU 4 倍降速、播放 5 秒、**只含元件的測試頁**。結果約 120 FPS、超過 20 ms 的格數 0、排版重算 2 次(共約 1 ms)。放進真實頁面後數字會受頁面其他內容影響(例如本說明頁因按鈕狀態切換,曾量到 1 格 26 ms)。手機請實機再測。
## 9. 常見修改
照表調整,不要動 keyframes。
| 想做的事 | 怎麼改 |
|---|---|
| 光芒範圍變大 | `--gfx-rays: 440px` |
| 光芒轉慢/轉快 | `--gfx-spin: 24s`/`10s` |
| 換光色 | `--gfx-gold`、`--gfx-gold-hi` |
| 整段變慢除錯 | `gift.setSpeed(4)` 或 `:root{--gfx-speed:4}` |
| 換成別的盒子(寶箱、紅包…) | 只換兩張圖,其餘不動(須符合第 7 節) |
| 少一個特效 | 從 HTML 移除對應元素(`.gfx__ring`、`.gfx__glow`、`.gfx__motes` 皆獨立,可單獨移除);不要移除 `.gfx__flash` 又保留硬切換 |
| 金幣翻轉角度 | `gift-open.js` 的 `spawnParticles` 內 `--r`,目前 ±60°(符號有上下方向,翻太多會倒著);要多翻滾可放大 |
| 換金幣的幣面符號 | `:root{ --gfx-coin-symbol: "¥"; }`(字串要含引號) |
| 金幣、星星數量 | `gift-open.js` 的 `spawnParticles` 內的 `12`、`8` |
| 光環要重複 | 不建議;設計上光環只在開箱那一下出現一次 |
## 10. 完整 CSS(gift-open.css)
```css
/*!
* gift-open.css v1.0
* 禮物開箱動態:純 CSS 特效 + 極薄的 JS 控制(gift-open.js)
*
* 鐵則
* 1. 所有動畫只動 transform / opacity(含 rotate、scale、translate 屬性),不觸發排版與重繪。
* 2. 只用 CSS 變數 --gfx-* 調整外觀與節奏;不要改 keyframes 內的數值。
* 3. 光芒、光環、閃光會超出舞台範圍:元件的容器請勿設 overflow:hidden。
* 若特效超出視窗造成頁面橫向捲軸(手機常見),只在最外層頁面容器加 overflow-x:clip。
*
* 狀態 class(由 gift-open.js 依序加到 .gfx 根元素):
* is-playing → is-shaking → is-flashing → is-open + is-pop + is-burst
* is-paused(離開畫面/分頁隱藏時暫停無限動畫)
*/
/* ───────── 1. Token(可調參數)───────── */
:root{
--gfx-coin-symbol:"$"; /* 金幣幣面的符號(字串,需含引號)。例:"¥"、"€"。可設在 :root 或任一祖先 */
--gfx-spinner-rgb:42,29,0; /* 按鈕轉圈的顏色 RGB(預設是金色按鈕上的深色字)*/
--gfx-speed:1; /* 時間倍率:1 正常、2 慢 2 倍、0.5 快 2 倍。
請設在 :root 或元件與「獎勵數字」的共同祖先,兩邊才會一起變慢 */
}
.gfx{
--gfx-w:323px; /* 舞台寬 */
--gfx-h:220px; /* 舞台高 */
--gfx-box:170px; /* 盒子邊長 */
--gfx-halo:240px; /* 常駐光暈直徑 */
--gfx-rays:380px; /* 光芒直徑;實際可見半徑約為直徑的 0.34 倍(≈130px) */
--gfx-spin:16s; /* 光芒轉一圈的時間(開箱後 1.2 秒才開始轉) */
--gfx-gold:255,238,175; /* 主光色 RGB(逗號分隔,供 rgba() 使用) */
--gfx-gold-hi:255,246,205; /* 亮部光色 RGB(開箱瞬間的亮閃) */
--gfx-mote-scale:.743; /* 浮光點整體範圍縮放 */
position:relative;
width:var(--gfx-w);
height:var(--gfx-h);
isolation:isolate; /* 特效的 z-index 只在元件內部排序,不會蓋到外面的文字 */
}
/* ───────── 2. 共同定位(全部以舞台正中心為原點)───────── */
.gfx__halo,.gfx__ring,.gfx__rays,.gfx__flash,.gfx__box,
.gfx__coin,.gfx__spark,.gfx__motes{position:absolute;left:50%;top:50%;pointer-events:none}
.gfx__glow{position:absolute;inset:0;pointer-events:none}
/* ───────── 3. 盒子與兩張圖(硬切換,由閃光遮住)───────── */
.gfx__box{
z-index:3;
width:var(--gfx-box);height:var(--gfx-box);
margin:calc(var(--gfx-box)/-2) 0 0 calc(var(--gfx-box)/-2);
}
.gfx__img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
.gfx__img--open{opacity:0}
.gfx.is-open .gfx__img--closed{opacity:0}
.gfx.is-open .gfx__img--open{opacity:1}
/* 0–700ms 抖動(逐步放大到 1.08)→ 開箱瞬間彈跳 */
.gfx.is-shaking .gfx__box{animation:gfx-shake calc(.7s*var(--gfx-speed)) ease-in-out}
.gfx.is-pop .gfx__box{animation:gfx-pop calc(.55s*var(--gfx-speed)) cubic-bezier(.2,1.6,.4,1)}
@keyframes gfx-shake{
0%,100%{transform:rotate(0) scale(1)}
15%{transform:rotate(-6deg) scale(1.02)}
30%{transform:rotate(6deg) scale(1.03)}
45%{transform:rotate(-5deg) scale(1.04)}
60%{transform:rotate(5deg) scale(1.05)}
80%{transform:rotate(-2deg) scale(1.08)}
}
@keyframes gfx-pop{0%{transform:scale(.86)}100%{transform:scale(1)}}
/* ───────── 4. 常駐光暈 ───────── */
.gfx__halo{
z-index:1;
width:var(--gfx-halo);height:var(--gfx-halo);
margin:calc(var(--gfx-halo)/-2) 0 0 calc(var(--gfx-halo)/-2);
border-radius:50%;
background:radial-gradient(circle,rgba(239,222,171,.28) 0%,rgba(239,222,171,0) 65%);
transition:transform .4s,opacity .4s;
}
.gfx.is-shaking .gfx__halo{transform:scale(1.25);opacity:1} /* 抖動時蓄力放大 */
.gfx.is-open .gfx__halo{ /* 開箱後更亮,停在 1.3 倍 .8 透明 */
background:radial-gradient(circle,rgba(var(--gfx-gold),.45) 0%,rgba(239,222,171,0) 68%);
animation:gfx-halo-beat calc(1.2s*var(--gfx-speed)) ease-out forwards;
}
@keyframes gfx-halo-beat{
0%{transform:scale(1.42);opacity:1}
100%{transform:scale(1.3);opacity:.8}
}
/* ───────── 5. 閃光(蓋住換圖那一瞬間)───────── */
.gfx__flash{
z-index:6;
width:420px;height:420px;margin:-210px 0 0 -210px;
border-radius:50%;opacity:0;
background:radial-gradient(circle,#fff 0%,#fff6d6 22%,rgba(255,230,160,.85) 38%,rgba(255,220,140,0) 66%);
}
.gfx.is-flashing .gfx__flash{animation:gfx-flash calc(.62s*var(--gfx-speed)) ease-out forwards}
@keyframes gfx-flash{
0%{opacity:0;transform:scale(.3)}
30%{opacity:1;transform:scale(1)} /* 約 190ms 最亮 → 此刻換圖 */
100%{opacity:0;transform:scale(1.25)}
}
/* ───────── 6. 光環(開箱時只播一次)───────── */
.gfx__ring{
z-index:2;
width:440px;height:440px;margin:-220px 0 0 -220px;
border-radius:50%;opacity:0;
mix-blend-mode:screen; /* 疊在光芒上時,交會處自然變亮 */
background:radial-gradient(circle,
rgba(var(--gfx-gold),0) 34%,rgba(255,240,190,.55) 43%,rgba(var(--gfx-gold),.18) 50%,rgba(var(--gfx-gold),0) 58%);
}
.gfx.is-open .gfx__ring{animation:gfx-ring-once calc(1.1s*var(--gfx-speed)) cubic-bezier(.15,.7,.3,1) forwards}
@keyframes gfx-ring-once{
0%{transform:scale(.45);opacity:1}
100%{transform:scale(1.6);opacity:0}
}
/* ───────── 7. 光芒:8 道、柔邊、單層;爆開減速後慢轉 ───────── */
.gfx__rays{
z-index:2;
width:var(--gfx-rays);height:var(--gfx-rays);
margin:calc(var(--gfx-rays)/-2) 0 0 calc(var(--gfx-rays)/-2);
border-radius:50%;opacity:0;
background:repeating-conic-gradient(from 0deg,
rgba(var(--gfx-gold),0) 0deg,rgba(var(--gfx-gold),.6) 10deg,rgba(var(--gfx-gold),0) 20deg,rgba(var(--gfx-gold),0) 45deg);
-webkit-mask:radial-gradient(circle,#000 14%,rgba(0,0,0,.75) 40%,transparent 68%);
mask:radial-gradient(circle,#000 14%,rgba(0,0,0,.75) 40%,transparent 68%);
}
.gfx.is-open .gfx__rays{
animation:
gfx-rays-burst calc(1.2s*var(--gfx-speed)) cubic-bezier(.1,.8,.3,1) forwards,
gfx-spin calc(var(--gfx-spin)*var(--gfx-speed)) linear calc(1.2s*var(--gfx-speed)) infinite;
}
@keyframes gfx-rays-burst{
0%{opacity:0;transform:scale(.35) rotate(-60deg)}
35%{opacity:1}
100%{opacity:1;transform:scale(1) rotate(0deg)}
}
@keyframes gfx-spin{to{rotate:360deg}} /* 用 rotate 屬性,與 burst 的 transform 互不干擾 */
/* 亮部:與光芒同位置、同爆開,外層 .gfx__glow 只做「亮一下就退」的透明度脈動 */
.gfx__glow{z-index:2;opacity:0}
.gfx__rays--bright{
opacity:1;
background:repeating-conic-gradient(from 0deg,
rgba(var(--gfx-gold-hi),0) 0deg,rgba(var(--gfx-gold-hi),.75) 10deg,rgba(var(--gfx-gold-hi),0) 20deg,rgba(var(--gfx-gold-hi),0) 45deg);
}
.gfx.is-open .gfx__glow{animation:gfx-glow-pulse calc(1.2s*var(--gfx-speed)) ease-out both}
.gfx.is-open .gfx__rays--bright{
animation:
gfx-rays-burst-flat calc(1.2s*var(--gfx-speed)) cubic-bezier(.1,.8,.3,1) forwards,
gfx-spin calc(var(--gfx-spin)*var(--gfx-speed)) linear calc(1.2s*var(--gfx-speed)) infinite;
}
@keyframes gfx-glow-pulse{0%{opacity:.7}35%{opacity:0}100%{opacity:0}}
@keyframes gfx-rays-burst-flat{
0%{transform:scale(.35) rotate(-60deg)}
100%{transform:scale(1) rotate(0deg)}
}
/* ───────── 8. 金幣與星星(全部 CSS 繪製,位置由 JS 以變數帶入)─────────
金幣幣面是錢幣符號(--gfx-coin-symbol);.gfx__spark 是飄散的四角閃光點,與幣面無關 */
.gfx__coin{
z-index:5;
width:26px;height:26px;margin:-13px;border-radius:50%;opacity:0;
background:radial-gradient(circle at 35% 30%,#fff3cf 0 18%,#f3cf6f 45%,#b8862f 100%);
box-shadow:inset 0 0 0 3px rgba(176,127,42,.55),0 0 8px rgba(255,214,120,.6);
}
.gfx__coin::after{ /* 幣面上的錢幣符號(文字,會跟著金幣一起縮放、旋轉)*/
content:var(--gfx-coin-symbol);
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
font:800 16px/1 ui-rounded,"SF Pro Rounded",system-ui,-apple-system,"Segoe UI","Helvetica Neue",Arial,sans-serif;
color:#a8741f;
text-shadow:0 1px 0 rgba(255,243,207,.75); /* 浮雕感:下緣一道亮邊 */
}
.gfx.is-burst .gfx__coin{
animation:gfx-coin calc(var(--d)*var(--gfx-speed)) cubic-bezier(.15,.7,.3,1) calc(var(--delay)*var(--gfx-speed)) forwards;
}
@keyframes gfx-coin{ /* --x --y --r --s --d --delay 由 JS 逐顆指定 */
0%{opacity:0;transform:translate(0,0) scale(.4) rotate(0)}
10%{opacity:1}
70%{opacity:1;transform:translate(var(--x),var(--y)) scale(var(--s)) rotate(var(--r))}
100%{opacity:0;transform:translate(var(--x),calc(var(--y) + 60px)) scale(var(--s)) rotate(var(--r))}
}
.gfx__spark{
z-index:5;
width:14px;height:14px;margin:-7px;opacity:0;background:#fff8e1;
clip-path:polygon(50% 0,62% 38%,100% 50%,62% 62%,50% 100%,38% 62%,0 50%,38% 38%);
}
.gfx.is-burst .gfx__spark{animation:gfx-spark calc(.9s*var(--gfx-speed)) ease-out calc(var(--delay)*var(--gfx-speed)) forwards}
@keyframes gfx-spark{
0%{opacity:0;transform:translate(var(--x),var(--y)) scale(.2)}
40%{opacity:1;transform:translate(var(--x),var(--y)) scale(1.2)}
100%{opacity:0;transform:translate(var(--x),var(--y)) scale(.4)}
}
/* ───────── 9. 浮光點 4 顆(開箱後淡入,持續循環)─────────
三層各用不同週期(互不整除):上飄/左右飄移/忽明忽暗,所以看起來不會同步 */
.gfx__motes{
z-index:7;width:0;height:0;
transform:scale(var(--gfx-mote-scale));
opacity:0;transition:opacity .4s;
}
.gfx.is-open .gfx__motes{opacity:1}
.gfx__mote,.gfx__mote-sway{position:absolute;left:0;top:0;width:0;height:0}
.gfx__mote-dot{
position:absolute;width:var(--d);height:var(--d);margin:calc(var(--d)/-2);border-radius:50%;
background:radial-gradient(circle,#fffbea 0%,#fff1c4 30%,rgba(255,236,170,.5) 52%,rgba(255,236,170,0) 74%);
}
@keyframes gfx-mote-rise{ /* --x --y0 --y1 */
0%{transform:translate(var(--x),var(--y0));opacity:0}
18%{opacity:1}
80%{opacity:.8}
100%{transform:translate(var(--x),var(--y1));opacity:0}
}
@keyframes gfx-mote-sway{0%,100%{translate:calc(var(--a)*-1) 0}50%{translate:var(--a) 0}} /* --a */
@keyframes gfx-mote-twinkle{0%,100%{opacity:.35}50%{opacity:1}}
.gfx__motes *{animation-play-state:paused} /* 開箱前不跑 */
.gfx.is-open .gfx__motes *{animation-play-state:running}
/* ───────── 10. 獎勵數字(選用;放在 .gfx 之後的兄弟節點)───────── */
.gfx-reward{opacity:0;transform:translateY(10px)}
.gfx.is-open ~ .gfx-reward{animation:gfx-rise calc(.5s*var(--gfx-speed)) ease-out calc(.25s*var(--gfx-speed)) forwards}
@keyframes gfx-rise{to{opacity:1;transform:none}}
/* ───────── 11. 效能與無障礙 ───────── */
/* 播放期間才提示 GPU,播完由 JS 移除 */
.gfx.is-playing .gfx__box,.gfx.is-playing .gfx__rays,.gfx.is-playing .gfx__glow,
.gfx.is-playing .gfx__flash,.gfx.is-playing .gfx__ring,
.gfx.is-playing .gfx__coin,.gfx.is-playing .gfx__spark{will-change:transform,opacity}
/* 離開畫面或分頁隱藏 → 暫停所有動畫(JS 切換此 class) */
.gfx.is-paused,.gfx.is-paused *{animation-play-state:paused!important}
/* 系統開了「減少動態效果」:不抖、不噴、不旋轉,只淡入 */
@keyframes gfx-fade-in{from{opacity:0}to{opacity:1}}
@media (prefers-reduced-motion:reduce){
.gfx__ring,.gfx__glow,.gfx__flash,.gfx__coin,.gfx__spark,.gfx__motes{display:none}
.gfx.is-shaking .gfx__box{animation:none}
.gfx.is-open .gfx__rays{animation:gfx-fade-in .3s ease-out forwards}
}
/* ───────── 12. 按鈕內的轉圈(選用)─────────
點擊後把按鈕設為 disabled,並把按鈕內容換成 。
轉圈不受 --gfx-speed 影響(慢動作除錯時也維持正常轉速)。 */
.gfx-spinner{
display:inline-block;width:18px;height:18px;vertical-align:middle;
border-radius:50%;
border:2px solid rgba(var(--gfx-spinner-rgb),.25);
border-top-color:rgb(var(--gfx-spinner-rgb));
animation:gfx-spinner .8s linear infinite;
}
@keyframes gfx-spinner{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion:reduce){.gfx-spinner{animation-duration:1.6s}} /* 載入提示不可拿掉,只放慢 */
```
## 11. 完整 JS(gift-open.js)
```js
/*!
* gift-open.js v1.0 — 禮物開箱動態控制器(無任何相依套件)
*
* 用法
* const gift = GiftOpen(document.getElementById('gift'), {
* speed: 1, // 時間倍率(慢動作可設 2、4)
* flash: true, // false=不閃光,兩張圖直接硬切(除錯用)
* onOpen: () => {}, // 換成「已開」圖的那一刻
* onDone: () => {} // 整段播完
* });
* gift.play(); // → Promise,播完 resolve;播放中重複呼叫會回傳同一個 Promise
* gift.reset(); // 回到未開狀態(並重新產生金幣、星星)
* gift.setSpeed(2);
* gift.setFlash(false); // 開/關閃光(false=兩張圖硬切)
* gift.destroy();
*
* 時間軸(speed=1,單位 ms)
* 0 加 is-playing、is-shaking 抖動
* 700 加 is-flashing 閃光
* 890 去 is-shaking;加 is-open is-pop is-burst 閃光最亮 → 換圖+爆開(呼叫 onOpen)
* 2290 去 is-playing 播完(呼叫 onDone)
*/
(function (global) {
'use strict';
var SHAKE = 700, FLASH_PEAK = 190, SETTLE = 1400;
var RM = global.matchMedia ? global.matchMedia('(prefers-reduced-motion: reduce)') : { matches: false };
// 固定種子的亂數:浮光點每次載入長得一樣,方便比對
function rng(seed) { return function () { seed = seed * 16807 % 2147483647; return (seed - 1) / 2147483646; }; }
function div(cls, css) { var d = document.createElement('div'); d.className = cls; if (css) d.style.cssText = css; return d; }
function buildMotes(root) {
var box = root.querySelector('.gfx__motes');
if (!box || box.childElementCount) return;
var r = rng(23), R = function (a, b) { return a + r() * (b - a); };
for (var i = 0; i < 4; i++) {
var t = R(4.2, 7), w = R(1.8, 3.2), o = R(1, 2.2);
var rise = div('gfx__mote',
'--x:' + R(-65, 65).toFixed(0) + 'px;--y0:' + R(15, 35).toFixed(0) + 'px;--y1:' + R(-145, -100).toFixed(0) + 'px;' +
'animation:gfx-mote-rise ' + t.toFixed(2) + 's cubic-bezier(.3,.6,.4,1) ' + (-r() * t).toFixed(2) + 's infinite');
var sway = div('gfx__mote-sway',
'--a:' + R(6, 14).toFixed(0) + 'px;animation:gfx-mote-sway ' + w.toFixed(2) + 's ease-in-out ' + (-r() * w).toFixed(2) + 's infinite');
var dot = div('gfx__mote-dot',
'--d:' + R(6, 12).toFixed(1) + 'px;animation:gfx-mote-twinkle ' + o.toFixed(2) + 's ease-in-out ' + (-r() * o).toFixed(2) + 's infinite');
sway.appendChild(dot); rise.appendChild(sway); box.appendChild(rise);
}
}
// 金幣 12 枚、星星 8 顆:每次 reset 重新隨機,每一次開箱都不一樣
// 金幣翻轉限制在 ±60°:幣面的錢幣符號有上下方向,翻太多會倒著顯示
function spawnParticles(root) {
[].slice.call(root.querySelectorAll('.gfx__coin,.gfx__spark')).forEach(function (n) { n.remove(); });
var i, a, dist, c, s;
for (i = 0; i < 12; i++) {
a = (-90 + (i - 5.5) * 16 + (Math.random() * 10 - 5)) * Math.PI / 180; dist = 90 + Math.random() * 60;
c = div('gfx__coin',
'--x:' + (Math.cos(a) * dist).toFixed(1) + 'px;--y:' + (Math.sin(a) * dist).toFixed(1) + 'px;' +
'--r:' + (Math.random() * 120 - 60).toFixed(0) + 'deg;--s:' + (.7 + Math.random() * .6).toFixed(2) + ';' +
'--d:' + (.9 + Math.random() * .4).toFixed(2) + 's;--delay:' + (Math.random() * .15).toFixed(2) + 's');
root.appendChild(c);
}
for (i = 0; i < 8; i++) {
a = Math.random() * Math.PI * 2; dist = 60 + Math.random() * 80;
s = div('gfx__spark',
'--x:' + (Math.cos(a) * dist).toFixed(1) + 'px;--y:' + (Math.sin(a) * dist).toFixed(1) + 'px;' +
'--delay:' + (.1 + Math.random() * .5).toFixed(2) + 's');
root.appendChild(s);
}
}
function GiftOpen(root, opts) {
opts = opts || {};
var speed = opts.speed || 1, flash = opts.flash !== false, running = null;
var offscreen = false, hidden = document.hidden, timers = [];
var imgs = [].slice.call(root.querySelectorAll('.gfx__img'));
// 先解碼兩張圖,換圖那一刻才不會閃空白
var ready = Promise.all(imgs.map(function (i) { return i.decode ? i.decode().catch(function () {}) : Promise.resolve(); }));
function applyPause() { root.classList.toggle('is-paused', offscreen || hidden); }
var io = global.IntersectionObserver
? new IntersectionObserver(function (es) { offscreen = !es[es.length - 1].isIntersecting; applyPause(); }, { threshold: 0.1 })
: null;
if (io) io.observe(root);
function onVis() { hidden = document.hidden; applyPause(); }
document.addEventListener('visibilitychange', onVis);
function wait(ms) {
return new Promise(function (res) { timers.push(setTimeout(res, ms * speed)); });
}
function setSpeed(n) {
speed = n;
(root.parentElement || document.documentElement).style.setProperty('--gfx-speed', n);
}
function setFlash(v) { flash = !!v; }
function reset() {
timers.forEach(clearTimeout); timers = []; running = null;
root.classList.remove('is-playing', 'is-shaking', 'is-flashing', 'is-open', 'is-pop', 'is-burst');
spawnParticles(root);
}
function play() {
if (running) return running;
running = ready.then(function () {
root.classList.add('is-playing');
var skip = RM.matches; // 系統要求減少動態:不抖、不閃,直接開
return (skip ? Promise.resolve() : (root.classList.add('is-shaking'), wait(SHAKE)))
.then(function () { if (flash && !skip) { root.classList.add('is-flashing'); return wait(FLASH_PEAK); } })
.then(function () {
root.classList.remove('is-shaking');
root.classList.add('is-open', 'is-pop', 'is-burst');
if (opts.onOpen) opts.onOpen();
return wait(skip ? 300 : SETTLE);
})
.then(function () { root.classList.remove('is-playing'); if (opts.onDone) opts.onDone(); });
});
return running;
}
function destroy() {
reset(); if (io) io.disconnect(); document.removeEventListener('visibilitychange', onVis);
}
buildMotes(root); spawnParticles(root);
if (speed !== 1) setSpeed(speed);
return { play: play, reset: reset, setSpeed: setSpeed, setFlash: setFlash, destroy: destroy };
}
global.GiftOpen = GiftOpen;
})(window);
```
## 12. 驗收清單
整合完請逐項確認。
- [ ] 點擊後按鈕立刻變成不可點,且按鈕內出現旋轉的圈;播完才恢復。
- [ ] 點擊後依序:抖動 → 閃光 → 換圖,換圖瞬間看不到空白或跳動。
- [ ] 噴出的金幣幣面是錢幣符號(預設 $),不是星星。
- [ ] 光環只出現一次;之後只剩光芒慢轉與浮光點。
- [ ] 未開與已開的盒身位置、大小沒有跳動。
- [ ] 把分頁切走再回來,動畫不會卡住或錯亂;元件滑出畫面時 CPU 使用率下降。
- [ ] 系統開啟「減少動態效果」後:不抖、不閃、不噴金幣,光芒淡入。
- [ ] DevTools Performance:動畫期間沒有持續的 Layout/Paint,只有 Composite。