點一下就能看效果;往下有時間軸、參數表、可直接複製的 CSS/JS/HTML,以及給 AI 的提示詞與規格。
給 AI/工程師直接照做的規格。只靠 CSS 動畫+一支精簡的 JS 控制器,無任何相依套件。
預覽與互動說明:https://gift-open-fx.pages.dev/ | 單檔可直接開啟:https://gift-open-fx.pages.dev/gift-open.standalone.html
按「可領取」開始;播完按「重播」會回到未開狀態,再按一次可再播。示範文案為簡體,沿用產品畫面。慢 4× 最適合檢查換圖那一瞬間。
你是前端工程師。請把「禮物開箱動態」整合進我的專案,嚴格照規格,不要自行改動任何動畫數值或 keyframes。
1. 先讀完整規格(內含完整 CSS、JS、HTML 結構、時間軸與驗收清單):
https://gift-open-fx.pages.dev/AI-SPEC.md
2. 取得下列檔案:
https://gift-open-fx.pages.dev/gift-open.css
https://gift-open-fx.pages.dev/gift-open.js
https://gift-open-fx.pages.dev/assets/gift_closed.webp
https://gift-open-fx.pages.dev/assets/gift_open.webp
3. 在要放盒子的位置貼上規格第 2 節的 HTML 結構,根元素 class 必須是 gfx;用 const gift = GiftOpen(el, { onOpen, onDone }) 初始化,按鈕點擊呼叫 gift.play()。
4. 只能用 CSS 變數 --gfx-*(規格第 5 節)調外觀與節奏。所有動畫只可動 transform/opacity;元件的容器不可 overflow:hidden。
5. 若要換成別的盒子,只換兩張圖,且必須同框、已開圖不可烤入特效(規格第 7 節)。
6. 完成後逐項回報規格第 12 節的驗收清單。點擊按鈕後:禮物盒抖動 0.7 秒 → 閃光蓋住 → 最亮時把「未開」圖換成「已開」圖 → 光芒爆開並慢轉、一圈光環、12 枚金幣(幣面為錢幣符號)+8 顆閃光點噴出、4 顆浮光點持續飄、獎勵數字浮出。全程約 2.3 秒,之後只剩光芒慢轉與浮光點。
全部是靜態檔,可直接下載。
| 檔案 | 用途 |
|---|---|
| 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、圖片都內嵌),拿來直接開或當範例 |
根元素 class 必須是 gfx;子元素的順序決定疊層,不可調換。
<!-- 根元素 class 必須是 gfx;effects 的順序決定疊層,請勿調換 -->
<div class="gfx" id="gift" aria-hidden="true">
<div class="gfx__halo"></div>
<div class="gfx__ring"></div>
<div class="gfx__rays"></div>
<div class="gfx__glow"><div class="gfx__rays gfx__rays--bright"></div></div>
<div class="gfx__box">
<img class="gfx__img gfx__img--closed" src="assets/gift_closed.webp" alt="" width="340" height="340">
<img class="gfx__img gfx__img--open" src="assets/gift_open.webp" alt="" width="340" height="340">
</div>
<div class="gfx__motes"></div> <!-- JS 自動放入 4 顆浮光點 -->
<div class="gfx__flash"></div>
<!-- JS 自動加入 .gfx__coin ×12、.gfx__spark ×8 -->
</div>
<!-- 選用:獎勵數字,必須是 .gfx 的「後面的兄弟節點」 -->
<div class="gfx-reward">+6.00</div>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()按鈕載入狀態(點擊後按鈕內要轉圈):
<button id="claim" type="button">可领取</button>claim.addEventListener('click', () => {
claim.disabled = true; // 不可重複點
claim.setAttribute('aria-busy', 'true');
claim.innerHTML = '<span class="gfx-spinner" aria-hidden="true"></span>'; // 按鈕內轉圈
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() 兩張圖,換圖那一刻不會閃空白。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 | 元件離開畫面或分頁隱藏時,暫停全部動畫 |
| 變數 | 預設 | 說明 |
|---|---|---|
--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(預設為金色按鈕上的深色字) |
| 元素 | 作用 | 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 |
要換成別的主題(寶箱、紅包⋯)時照此準備。
以下必須遵守。
transform、opacity、rotate、scale、translate。 禁止動畫 width/height/top/left/margin/box-shadow/filter。will-change 只在 .is-playing 期間存在;不要常駐。.is-paused)。mix-blend-mode: screen,不要再加其他混合模式。overflow:hidden(光芒、光環、閃光會超出舞台)。若特效超出視窗寬度造成頁面橫向捲軸(手機常見,透明的層也會撐寬),只在最外層頁面容器加 overflow-x:clip,不要加在元件的容器上。元件本身有 isolation:isolate,特效不會蓋到外面的文字。prefers-reduced-motion: reduce:已內建——不抖、不閃、不噴金幣,光芒只淡入。aria-hidden="true";獎勵結果請另外用真正的文字(例如 aria-live="polite")告知。實測條件:桌機 Chrome、CPU 4 倍降速、播放 5 秒、只含元件的測試頁。結果約 120 FPS、超過 20 ms 的格數 0、排版重算 2 次(共約 1 ms)。放進真實頁面後數字會受頁面其他內容影響(例如本說明頁因按鈕狀態切換,曾量到 1 格 26 ms)。手機請實機再測。
照表調整,不要動 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 |
| 光環要重複 | 不建議;設計上光環只在開箱那一下出現一次 |
/*!
* 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,並把按鈕內容換成 <span class="gfx-spinner" aria-hidden="true"></span>。
轉圈不受 --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}} /* 載入提示不可拿掉,只放慢 *//*!
* 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);整合完請逐項確認。