到昨天為止,小怪都只會站著挨打。今天讓牠還手,戰鬥才真的是雙向的。
Day 22 畫流程圖的時候,我就標了一個陷阱:
[玩家攻擊] → [敵人死了?] ─是→ [勝利] ← 死了就不該反擊
↓ 否
[敵人反擊] → [勇者死了?] ─是→ [失敗]
↓ 否
[回合 +1]
「敵人死了嗎」必須先判斷,不然會變成「小怪死了還打你一下」。
程式裡用 return 提前結束來保證這件事:
state.enemy.hp = state.enemy.hp - heroDmg;
if (state.enemy.hp <= 0) {
endGame("win");
updateScreen();
return; // 直接結束,下面的反擊完全不會執行
}
// 敵人還活著,才輪到牠打
const enemyDmg = calcDamage(state.enemy.atk, state.hero.def);
這是 Day 11 學到的第二種 return 用法 —— 不交任何值,只是說「這次到此為止」。
之前 isOver 只記錄「結束了沒」,但現在有兩種結束方式,所以要多一個欄位:
state.result = null; // null = 進行中 / "win" / "lose"
這是 Day 18 那個教訓的延伸:用旗標記錄狀態本身,比從數值反推可靠。如果只靠「誰的血是 0」來判斷,萬一之後加了「逃跑」「平手」,那套推論就不夠用了。
兩種結局的處理也不一樣:
| 勝利 | 失敗 | |
|---|---|---|
| 按鈕 | 變成「鞭屍!!」,還能按 | 停用 |
| 訊息 | 被擊敗了 + 回合數 | 倒下了 + 撐了幾回合 |
| 紀錄 | 比對並儲存 | 不存 |
失敗時停用按鈕,是因為死掉的人沒有鞭屍的餘地。
而這立刻帶出一個要注意的事:reset() 必須把 attackBtn.disabled 放回 false,不然輸過一次就永遠不能再玩了。這就是 Day 22 那張「重置要還原什麼」表格的價值 —— 每加一個狀態,就要回去看那張表。
寫完之後我開始測「失敗」的流程,結果怎麼打都贏。
算一下就知道為什麼:
| HP | 每次傷害 | 需要幾次攻擊 | |
|---|---|---|---|
| 勇者 | 100 | 14~20 | 打死小怪要 3~5 次 |
| 小怪 | 60 | 7~13 | 打死勇者要 8~15 次 |
勇者先攻,而且早五六個回合就達標。「失敗」那段程式碼永遠不會被執行。
這讓我卡了一下,因為它引出一個很實際的問題:
一段永遠跑不到的程式碼,算不算寫好了?我要怎麼驗證它是對的?
這在測試工作裡是很常見的處境 —— 某個錯誤處理的分支,正常操作永遠進不去。你不能因為「反正跑不到」就不管它,因為總有一天會跑到。
我的做法是先改數值讓它跑得到:把勇者攻擊力從 20 調到 8,然後真的輸一場,確認失敗流程長什麼樣。

而且這一場輸得很戲劇化 —— 小怪只剩 1 點血。再一下就贏了。
驗證的結果是:訊息正確、「鞭屍!!」按鈕被鎖起來、紀錄沒有被存進去(失敗不該算紀錄)、state.result 是 "lose"。失敗流程確實是對的。
確認失敗流程沒問題之後,問題變成:那到底該設多少?
我的想法是:先用基準傷害把兩邊「需要幾次攻擊」算到接近,剩下交給亂數決定。
小怪殺勇者:100 / (15 - 5) = 10 次
勇者殺小怪:60 / (8 - 3) = 12 次
10 和 12,看起來很接近。但實際上 —— 小怪整整早兩回合達標,而 ±3 的浮動不足以翻盤兩個回合的差距。
我從「勇者必勝」跳到了「勇者幾乎必輸」。
要真正勢均力敵,兩邊都該是 10 次:
勇者要 10 次殺死小怪 → 每次要 60/10 = 6 點傷害
6 = atk - 3 → atk = 9
8 和 9 只差 1。 差這麼一點,結果會差多少?用猜的不行,得實測。
一開始我想的是手動打 20 場記錄輸贏。但算一下:一場要按十幾下,20 場就是兩百多下滑鼠,而且 Day 16 學過 —— 20 場的樣本還是太小。
所以我寫了一個模擬器:
function simulate(heroAtk, times = 1000) {
let wins = 0;
for (let i = 0; i < times; i++) {
let heroHp = 100, enemyHp = 60, turn = 1;
while (true) {
enemyHp = enemyHp - calcDamage(heroAtk, 3);
if (enemyHp <= 0) { wins = wins + 1; break; }
heroHp = heroHp - calcDamage(15, 5);
if (heroHp <= 0) { break; }
turn = turn + 1;
}
}
// 輸出勝率、平均回合數、最快回合數
}
它只跑計算,完全不碰畫面,所以 1000 場一瞬間就結束 —— 這正好呼應 Day 19 學到的:畫面更新才是慢的部分。
這件事有個名字叫自動化測試:把「人重複做很多次」的事交給程式。我在工作上很熟悉這個概念,但今天是第一次自己寫一個。
勇者 atk=8 :勝率 17.5%(175/1000),平均 10.4 回合,最快 9 回合
勇者 atk=9 :勝率 64.9%(649/1000),平均 10.0 回合,最快 8 回合
勇者 atk=10:勝率 97.8%(978/1000),平均 9.0 回合,最快 7 回合
勇者 atk=20:勝率 100%(1000/1000),平均 4.0 回合,最快 3 回合

攻擊力 8 到 9,勝率從 17.5% 跳到 64.9% —— 差了 47 個百分點。再加 1 點變成 10,又跳到 97.8%。
一個數字差 1,整個遊戲的體感完全不同。如果不是跑模擬,我永遠不會知道這個區間這麼陡。
我原本用算式推論 atk = 9 應該是「五五波」,因為兩邊都需要 10 次攻擊。實際跑出來是 64.9%,明顯偏向勇者。
原因是我的算式漏了一件事:勇者先攻。同樣需要 10 次的情況下,勇者的第 10 次會先落下。算式只算了「需要幾次」,沒有算「誰先出手」。
這件事很值得記下來:數學推導給方向,實測給答案。 我的算式把範圍縮小到 8~10 之間是有用的,但要決定最後那個數字,還是得跑。
Day 22 回顧時我發現一個問題:每場都剛好 4 回合,「最少回合挑戰」根本沒有意義。
看模擬結果的最後兩欄:
| 攻擊力 | 平均回合 | 最快回合 | 變化空間 |
|---|---|---|---|
| 20(原本) | 4.0 | 3 | 幾乎沒有 |
| 9 | 10.0 | 8 | 2 回合以上 |
原本的設定平均 4 回合、最快 3 回合,中間只有一格的空間,難怪每場都一樣。改成 9 之後,平均 10、最快 8,終於有東西可以挑戰了。
一個調整同時解決了兩個問題:讓遊戲有輸的可能,也讓紀錄有意義。這是我第一次感受到「數值設計」不只是填數字,而是在決定整個遊戲的體驗。
最後我把勇者的攻擊力定在 9。64.9% 的勝率對一個小品來說剛好:大多數人會贏,但偶爾會翻船 —— 而正是那偶爾的一次,才讓贏的時候有感覺。如果選 10,97.8% 的勝率等於沒有輸的體驗;如果選 8,17.5% 又太挫折。

上圖是調整後的一場勝利 —— 贏的時候勇者只剩 6 點血。這種「差一點就輸」的感覺,原本的設定裡完全不存在。
現在的做法是「先把兩邊基準值調到接近,剩下交給亂數」。它可以用,但沒有多好。
因為之後如果要擴充 —— 不同的怪物需要不同數值、勇者會有不同裝備、還會有增益和減益狀態 —— 那時候就不能再靠手算兩個數字了。真正的遊戲需要一套可以推算的數值系統,而不是一次一次試。
不過那是以後的事。以現在這個「一隻勇者打一隻小怪」的規模,手算加模擬驗證已經夠用了。
今天搞懂的事:
return 提前結束來保證result 記錄「怎麼結束的」,不只是「結束了沒」明天讓被打的時候有感覺 —— Day 17 埋的受擊震動,終於要做了。