Day 10 讓數字上了畫面,但那是「重新整理一次、自動打一拳」。今天要讓它等玩家決定什麼時候攻擊。
attackBtn.addEventListener("click", attack);
這行讀起來是:「在 attackBtn 上掛一個監聽器,聽 click 這件事;聽到了就執行 attack」。
| 零件 | 意思 |
|---|---|
addEventListener |
掛一個監聽器(add + Event + Listener) |
"click" |
聽哪一種事件 |
attack |
聽到之後執行哪個函式 |
而且第二個參數不能加括號,這是今天的第一個坑:
attackBtn.addEventListener("click", attack); // 交出「函式本人」,等點擊才跑
attackBtn.addEventListener("click", attack()); // 立刻執行它,把回傳值交出去
前十天我的程式都是「從上到下跑一遍,結束」。今天開始不一樣:
從上到下跑完(掛好監聽器) → 停下來等 → 玩家點擊 → attack() 執行 → 再等 → 再點 → ...
程式不再是一條直線,而是掛好機關之後坐等觸發。這是互動程式的本質,也是我第一次寫出「會等人的程式」。
我先照最單純的寫法做:一次點擊扣一次血。連按幾下,小怪的血從 60 → 43 → 26 → 9,然後……繼續往下掉,-8、-25、-42,永無止境。
我打出了一隻打不死的小怪,牠已經從怪物進化成小強了。
原因很清楚:程式裡從來沒有人負責「宣布結束」。attack() 每次被呼叫就老老實實扣血,它根本不知道什麼叫「死了」。
這其實是 Day 4 那個邊界值討論的延伸。當時我寫 hp <= 0 → 勇者陣亡,但那只是印一句話,並沒有真的阻止後續動作。遊戲需要的不只是「報告狀態」,而是「狀態達成之後改變規則」。
我故意把監聽器寫成 attack(),重新整理,還沒點任何東西,主控台就冒出:
目前小怪 HP: 43
自動開始第一下戰鬥了。 小怪莫名被打了一拳。
但更麻煩的是第二層:attack() 執行完的回傳值是 undefined(它沒有 return——Day 8 的老朋友),所以真正交給 addEventListener 的是 undefined。而瀏覽器對這種情況完全不吭聲:不報錯、也不掛任何東西。所以後來我點按鈕,永遠沒反應。
又一個安靜失敗。這個家族越來越大了:字串 s[0] = "明" 不生效、同名函式安靜覆蓋、遮蔽留下的 undefined……而它們共同的特徵是:比報錯的錯誤難查得多。
把 "click" 換成 "mouseover"(滑鼠移過去),按鈕就變成「碰到就掉血」。這讓我馬上想到:這個機制可以拿來做音樂遊戲。
事件的種類很多:click、dblclick、keydown、mouseover、mouseout、change……而同一套 addEventListener 語法可以聽任何一種,這是它強大的地方。真正的音樂遊戲用的是 keydown 配上時間判定,但地基就是今天學的這一行。
回到打不死的小強。標準做法是把按鈕關掉(attackBtn.disabled = true),但我想做得有趣一點——讓玩家可以繼續按,但按鈕會變成「鞭屍!!」,訊息變成「勇者請不要對小怪屍體動手!」。
功能上是一樣的(小怪血量停在 0 不再變動),但多了一點個性。這是我第一次不是照著範例做,而是自己決定「這個遊戲該怎麼回應玩家」。
寫完之後我重跑一次,發現一個問題:玩家打出致命一擊的那一下,看到的訊息是「請不要對屍體動手!」。
可是那一刻他才剛把小怪打死,還沒開始鞭屍啊——勝利的瞬間沒有勝利訊息,直接被當成鞭屍犯了。
原因是我把兩個狀態混在一起了:
| 狀態 | 應該顯示 |
|---|---|
| 這一擊剛好把牠打死 | 小怪被擊敗了! |
| 打之前牠就已經死了 | 請不要對屍體動手! |
我原本的 if (enemy.hp <= 0) 寫在扣血之後,所以分不出這兩種情況。解法是在扣血之前先問一次「牠是不是已經死了」:
function attack() {
if (enemy.hp <= 0) { // 打之前就已經死了
logText.textContent = `${hero.name}請不要對${enemy.name}屍體動手!`;
return; // 直接結束,不扣血、不算傷害
}
const damage = calcDamage(hero.atk, enemy.def);
enemy.hp = enemy.hp - damage;
if (enemy.hp <= 0) { // 這一擊剛好打死
enemy.hp = 0;
attackBtn.textContent = `鞭屍!!`;
logText.textContent = `${enemy.name}被擊敗了!`;
} else {
logText.textContent = `${hero.name} 造成 ${damage} 點傷害!`;
}
updateScreen();
console.log("目前小怪 HP:", enemy.hp);
}
這裡也學到 return 的第二種用法:Day 8 的 return 是「交出結果」,今天這個是提前結束函式——不交任何值,只是說「這次不用做了」。

上圖的主控台剛好記錄了整條時間線,而且有個細節證明修對了:紀錄只有四筆,第五次點擊(鞭屍那下)完全沒印東西——因為它撞到函式開頭的 return 就結束了,根本沒跑到 console.log。
修完上面那個,我重跑,結果血量完全不動了。
一開始很困惑,但主控台明明印著 目前小怪 HP: 43——資料真的在變,只是畫面沒跟上。原因是我在整理程式碼時,把兩個分支裡重複的 updateScreen() 刪掉了,卻忘記在外面補上一次,所以整個 attack() 從頭到尾沒有任何人去更新畫面。
這是全新的一類 bug。前十天的錯誤都是「跑不動」或「算錯」,今天這個是:資料對、計算對、程式不報錯,只有畫面沒跟上。
因為從 Day 10 開始,同一件事有了兩份:
| 住在哪 | 誰負責改 | |
|---|---|---|
| 資料 | enemy.hp(記憶體裡的物件) |
attack() 直接改 |
| 畫面 | <p id="enemyHp"> 的文字 |
必須有人呼叫 updateScreen() |
它們是兩個獨立的東西,不會自動同步。JS 改了資料,畫面不會自己知道。
從 QA 的角度看,這種 bug 最陰險:功能其實是好的,只是玩家看不到。如果測試只盯著主控台,會判定「通過」。
後來我查到,這個「資料和畫面不同步」是前端最經典的問題類型,也是 React、Vue 這些框架存在的理由——它們的賣點就是「資料改了,畫面自動跟著改」,讓開發者不必手動呼叫 updateScreen()。我今天親手體驗了沒有框架的世界長什麼樣,以後學框架應該會特別有感。
除錯的時候我多加了一行 console.log(logText),想順便留下每回合的訊息紀錄。結果拍了兩張截圖對照,發現一件荒謬的事。
擊敗小怪的那一刻:
<p id="log">小怪被擊敗了!</p> ← 這筆的 HP 是 43(那時明明該是「造成 17 點傷害」)
<p id="log">勇者 造成 17 點傷害!</p> ← 這筆的 HP 是 26
<p id="log">小怪被擊敗了!</p> ← 這筆的 HP 是 9
<p id="log">小怪被擊敗了!</p> ← 這筆的 HP 是 0

再點一下進入鞭屍狀態,同一批紀錄又變了:
<p id="log">勇者請不要對小怪屍體動手!</p> ← HP 43
<p id="log">勇者請不要對小怪屍體動手!</p> ← HP 26
<p id="log">小怪被擊敗了!</p> ← HP 9(只有這行沒更新)
<p id="log">勇者請不要對小怪屍體動手!</p> ← HP 0

旁邊的 HP 數字(43、26、9、0)是正確的歷史,因為那是數字;但 <p> 元素顯示的文字會跟著現在的畫面跑,而且更新得零零落落——有幾行變了、有幾行還是舊的,取決於瀏覽器什麼時候剛好重畫了那一列。
這正是 Day 6 那個活參照陷阱,但在 DOM 上更嚴重:Day 6 的陣列至少有規則可循(摺疊是快照、展開是即時),DOM 元素連摺疊起來的預覽都不能信。
所以規則很清楚:想在主控台留下「當下的文字紀錄」,就 log 字串本身:
console.log(logText); // 活參照,文字會變,不可信
console.log(logText.textContent); // 字串,永久凍結當下的值
今天搞懂的事:
addEventListener(事件, 函式) 掛監聽器;第二個參數不能加括號,加了就變成「立刻執行,並把 undefined 掛上去」return 的第二種用法:提前結束函式,不交任何值console.log(元素) 是活參照,顯示的文字會跟著畫面變(而且更新得不完整);要留紀錄請 log 元素.textContent
按鈕會動了、小怪會死了。明天做第一階段回顧,把前 12 天學的東西整理一遍,然後開始把這些零件真正組成一個遊戲。