iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0

今天的主題:受擊震動——Day 17 埋的伏筆,終於回收

Day 17 學完計時器之後,我的結論是:動畫和計時器用在表現層可以,用在規則層會破壞回合制。當時我把「受擊震動」記下來,留到敵人會反擊的那天再做。

今天就是那天。

震動不是裝飾,是為了回答一個問題

Day 24 讓小怪會反擊之後,一個回合裡其實發生了兩件事:勇者打小怪、小怪打勇者。但畫面上只看到兩個數字同時變。

玩家看不出剛剛是誰被打了。

震動要解決的就是這個 —— 它是功能性回饋,不是特效。

CSS 動畫的基本寫法

@keyframes shake {
  0%, 100% { transform: translateX(0); }
  15%      { transform: translateX(-20px); }
  30%      { transform: translateX(16px); }
  45%      { transform: translateX(-12px); }
  60%      { transform: translateX(8px); }
  75%      { transform: translateX(-4px); }
}

.fighter.shake {
  animation: shake 0.35s ease-in-out;
}

@keyframes 是動畫的劇本,百分比代表「播到幾成的時候長什麼樣」。幅度我刻意讓它越來越小,模擬被打之後的餘震。

用 transform: translateX() 而不是改 left,是因為 transform 不會影響版面計算,瀏覽器跑起來比較順。

踩到一個坑:transform 對 inline 元素完全無效。血量數字是 <span>(預設就是 inline),所以要先給它 display: inline-block 才動得起來。這個又是那種「不報錯、就是沒反應」的安靜失敗。

今天的重點:動畫不會重播

這是我原本沒想到的問題。

enemyBox.classList.add("shake");   // 第一下:動了
enemyBox.classList.add("shake");   // 第二下:什麼都沒發生

因為元素已經有這個 class 了,再加一次對 CSS 來說「狀態沒變」,動畫不會重新開始。連續攻擊時只有第一下看得到效果。

標準解法是「先移除 → 強迫重繪 → 再加上」:

function playEffect(el, className) {
  el.classList.remove(className);
  void el.offsetWidth;        // ← 關鍵的一行
  el.classList.add(className);
}

中間那行看起來完全莫名其妙:讀取一個寬度,然後把結果丟掉(void 的意思就是「執行它,但不要這個值」)。

它的作用是逼瀏覽器把前面的移除動作真的算完。

瀏覽器為了效能,會把連續的樣式修改累積起來一次處理。如果沒有這一行,remove 和 add 會被合併成「什麼都沒變」。而「讀取版面資訊」(寬度、高度、位置)會強迫它立刻結算 —— 因為它必須先算完才能給你正確的數字。

這個技巧叫強制重排(force reflow),很多動畫函式庫內部都在用。

實測結果:連續快速點擊,每一下都有震動,不會只震第一下。

但我立刻發現一個更嚴重的問題

做完之後我打了幾回合,發現:兩邊的血條一起震動,根本看不出攻擊順序。

原因很清楚:兩次震動都在同一個瞬間被觸發。整段 attack() 是同步執行的,傷害計算、血量更新、兩次動畫啟動,全部在同一毫秒內完成。

但回合制的本質是「你打我、我打你」,它有先後。 畫面卻把它壓縮成同一瞬間 —— 資訊在,順序不見了。

解法:讓反擊的震動晚一點播

function showHit(box, hpTextEl, delay = 0) {
  if (delay === 0) {
    playEffect(box, "shake");
    playEffect(hpTextEl, "flash");
    return;
  }
  setTimeout(() => {
    playEffect(box, "shake");
    playEffect(hpTextEl, "flash");
  }, delay);
}

showHit(enemyBox, enemyHpText);        // 小怪先震
showHit(heroBox, heroHpText, 380);     // 勇者 0.38 秒後才震

而這正好是 Day 17 那個判斷的完美應用:

計時器用在哪 可不可以
規則層(延遲才算傷害) 不行,那就變成即時制了
表現層(傷害早就算完,只是動畫晚播) 可以

傷害、血量、勝負在我點下去的那一瞬間就全部算完了。 計時器只負責演出的節奏 —— 玩家的操作沒變、遊戲的規則沒變、結果沒變,只有「看起來的先後」變了。

Day 17 我說「知道它能做什麼,然後決定不用」。今天發現不是不用,是用在對的地方。同一個工具,放在規則裡會毀掉遊戲,放在表現裡卻剛好補上了遊戲缺的那塊。

把震動幅度調到 500px 之後

說明裡我留了一個測試:把震動幅度調大看看。

我把幅度改成 -500px,結果整個超好笑 —— 它震出畫面了。

而且不只是視覺上跑掉:元素滑到畫面外之後,瀏覽器判斷「有東西超出範圍」,底下就跑出一條水平捲軸。動畫本身沒有錯,但它把版面撐壞了。

修法很簡單:

body {
  overflow-x: hidden;   /* 溢出畫面的部分切掉,不要生出捲軸 */
}

這件事很值得記下來:動畫不是獨立存在的,它會影響版面。我在測試工作上看過類似的問題 —— 某個動畫元件單獨看很正常,放進頁面就把排版擠歪了。

至於幅度,試過兩個極端之後我把最大位移定在 20px 左右。原本的 8px 太含蓄,幾乎注意不到;500px 當然是鬧著玩的。20px 大概是「一眼就看得到被打了,但不會覺得畫面在鬧脾氣」的程度。

這種數字沒有標準答案,只能自己看 —— 跟 Day 24 調攻擊力一樣,推不出來,要試。差別是那邊可以跑模擬驗證,這邊只能靠眼睛。

血條也得跟著晚,不然更奇怪

做完延遲之後我立刻發現下一個問題:勇者的血條瞬間就掉了,震動卻 0.38 秒後才來。 看起來像兩件無關的事。

所以要延後的不只是震動,而是「反擊」這件事的所有呈現:血條、數字、震動、訊息,要一起晚。

我把畫面更新拆成兩半:

function renderHero()  { renderFighter(state.hero,  ...); }
function renderEnemy() { renderFighter(state.enemy, ...); }

以前一律呼叫 updateScreen() 把兩邊一起更新,但一來一往的演出需要分開更新 —— 小怪被打時只動小怪,勇者被打時只動勇者。

順便把戰鬥紀錄改成會累積的

原本的訊息區只有一行,每次都被覆蓋 —— 你永遠只看得到最後一句。既然現在一個回合會產生兩則訊息,乾脆改成真正的戰鬥紀錄:

function addLog(html, className = "", showTurn = false) {
  const line = document.createElement("div");
  line.className = "log-line" + (className ? " " + className : "");
  line.innerHTML = (showTurn ? `<span class="log-turn">[${state.turn}]</span>` : "") + html;
  logText.appendChild(line);

  // 鞭屍可以按無限次,只留最近 200 行,免得越積越多
  while (logText.children.length > 200) {
    logText.removeChild(logText.firstChild);
  }

  logText.scrollTop = logText.scrollHeight;   // 自動捲到最底
}

document.createElement 和 appendChild 是 Day 15 提過但沒真的用上的東西 —— 造一個新元素、掛進去,今天終於派上用場。

改完之後一場戰鬥長這樣:

勇者 小明 出征!
[1] 小明 造成 12 點傷害!
    小怪 反擊 9 點!
[2] 小明 造成 8 點傷害!
    小怪 反擊 11 點!
    ...
    小怪 被擊敗了!(共 10 回合)

一來一往變成兩行,回合標記只標一次。而且驗證失敗的訊息也進紀錄,不會再把戰鬥內容洗掉。

然後我做出了會打人的屍體

改完之後我測了一下,結果紀錄裡出現這個:

小怪 被擊敗了!(共 11 回合)      ← 小怪已經死了
小怪 反擊 11 點!                ← 屍體打人
勇者請不要對小怪屍體動手!(第 1 下)
小怪 反擊 9 點!                 ← 屍體又打人
勇者 倒下了…… 撐了 11 回合      ← 勇者被屍體打死了

兩邊都是 0 血。我贏了之後又輸了。

插入 day25.png:紀錄裡小怪死後還在反擊,最後勇者也倒下

原因是連點。當我在 0.38 秒內又按一次:

第 11 回合:攻擊 → 小怪沒死 → 排一個 380 毫秒後的反擊演出 ⏳
(還沒到 380 毫秒,我又點了)
下一次點擊:攻擊 → 小怪死了 → endGame("win") → 結束
(380 毫秒到了)
⏳ 排隊中的那個反擊演出執行了 → 屍體反擊

那個 setTimeout 是在小怪還活著的時候排的,它不知道後來發生了什麼事。

更糟的是「勇者死了沒」這個判斷也是排隊當下就算好的,所以它會拿過期的結果去呼叫 endGame("lose"),把已經是 "win" 的結局覆蓋掉。

這就是 Day 19 學到的非同步的陰暗面:程式不再是一條直線,排隊時的狀態和執行時的狀態可能已經不一樣了。

修法用到了前面兩天的東西

第一層:演出沒播完,不能開始下一回合。

第二層:重置時剪線。 如果打到一半按「再戰一次」,那個排隊中的演出會在新的一場跑出來亂噴訊息:

if (pendingTimer !== null) {
  clearTimeout(pendingTimer);
  pendingTimer = null;
}

Day 17 學 clearInterval 的時候我只覺得「原來計時器要自己收尾」,今天才遇到真的非收不可的場合。

擋下來之後,我想告訴玩家為什麼

第一層防護我本來用 attackBtn.disabled = true,按鈕會變灰。但這樣玩家只知道「不能按」,不知道「為什麼」。

我想讓它回一句話,結果卡住了 —— disabled 的按鈕根本不會觸發點擊事件,程式收不到訊號,自然也沒機會回話。

所以改成用旗標擋,按鈕只在視覺上變淡:

if (isAnimating) {
  addLog(`${hero.name}請不要偷襲!`, "log-warning");
  return;
}
button.busy { opacity: 0.5; cursor: progress; }

現在連點會看到「勇者請不要偷襲!」,而不是一個沒反應的灰按鈕。

這件事讓我理出一個分別:

點擊事件 能不能解釋原因
disabled = true 收不到 只能讓按鈕變灰
旗標 + 視覺樣式 收得到 可以回話

「擋下操作」和「告訴使用者為什麼被擋」是兩件事,disabled 只做到第一件。

而我在「失敗」的時候還是用 disabled —— 因為那是永久狀態,不需要解釋「等一下就好了」;演出中是暫時狀態,值得說一句。

無障礙:有些人不該看到動畫

@media (prefers-reduced-motion: reduce) {
  .fighter.shake,
  .fighter-hp.flash,
  .hp-fill {
    animation: none;
    transition: none;
  }
}

有些人對畫面晃動敏感(暈眩、前庭功能異常、某些偏頭痛),作業系統有一個「減少動態效果」的設定。這段 CSS 的意思是:使用者開了那個設定,就把動畫全部關掉。

實測結果:血條變成瞬間減少,不再有滑動過渡,震動也完全消失。資訊照樣看得到,只是不動。 這就是對的行為 —— 無障礙不是「拿掉功能」,是「換一種方式傳達同樣的資訊」。

我同事的色弱模式,讓我回頭檢查了自己的遊戲

無障礙這件事,我在測試工作上還沒正式做過,但有個親身經驗:

我有同事有點色盲,所以我們的專案管理工具大家都開色弱模式 —— 不同顏色的標籤會帶不同的符號。

這裡有兩件事我覺得做得好。第一是用符號補上顏色,第二是大家都開,而不是只有那位同事自己開 —— 這樣討論的時候看到的是同一套東西,不會出現「我說紅色那個」「哪個紅色?」的斷線。

這背後是無障礙設計的第一原則:不要只用顏色傳達資訊。

想到這裡我回頭檢查自己的遊戲。它用顏色傳達了不少狀態:血條綠/黃/紅、傷害數字紅色、背景配色。如果玩家是紅綠色盲,這些幾乎全部失效。

但檢查下來,關鍵資訊其實都有顏色以外的備援:

資訊 用了顏色 非顏色的線索
剩多少血 血條綠/黃/紅 數字 6 / 100 + 血條長度
已經死亡 文字變灰 刪除線
剛剛誰被打 — 震動

而今天做的震動,本身就是不依賴顏色的回饋 —— 它是動作。對色盲玩家、對開了色弱模式的使用者,效果完全一樣。

這不是我刻意設計的,是回頭檢查才發現的。但這也說明一件事:無障礙不一定要另外加功能,很多時候是確認「這個資訊有沒有第二種傳達方式」。

小結

今天搞懂的事:

  • @keyframes + animation 做 CSS 動畫;transform 比改位置更順,但對 inline 元素無效
  • 已經有的 class 再 add 一次不會重播動畫,要先 remove、void el.offsetWidth 強制重排、再 add
  • 同步執行的兩個動畫會同時發生 —— 回合制需要的「先後感」要靠延遲做出來
  • 計時器不是不能用,是要用在表現層:傷害早就算完,它只負責演出節奏
  • 動畫會影響版面:幅度過大會撐出捲軸,overflow-x: hidden 收掉
  • 要延後的不只是動畫,是那件事的所有呈現(血條、數字、訊息)要一起晚
  • 用 createElement + appendChild 把訊息改成會累積的戰鬥紀錄
  • 排隊中的動畫不知道遊戲已經結束 —— 連點會做出會打人的屍體,而且會把勝利覆蓋成失敗
  • 修法:演出期間擋住下一次操作、重置時 clearTimeout 剪線
  • disabled 的按鈕收不到點擊事件,所以沒辦法解釋「為什麼不能按」;暫時性的阻擋用旗標,永久性的才用 disabled
  • prefers-reduced-motion 尊重使用者的「減少動態效果」設定
  • 不要只用顏色傳達資訊 —— 數字、刪除線、震動都是顏色以外的備援

明天處理結果畫面,把勝利和失敗的收尾做完整。


上一篇
Day24|攻擊力差 1 點,勝率差了 47 個百分點
下一篇
Day26|不完整的說明,比沒有說明更糟
系列文
QA 的 JavaScript 初心之路:30 天做出一個能玩的戰鬥小遊戲《勇者 vs 小怪》 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言