原本規劃裡,增益、道具、buff 這些東西是「留給明年」的。但做到這裡,我覺得戰鬥還是太單調:按攻擊、按攻擊、按攻擊,除了傷害浮動之外沒有任何決定要做。
所以我想加一顆骰子:
六個面,奇數幫勇者、偶數幫小怪。擲骰消耗一個回合,而且要等三回合才能再擲。
重點不是「多一個功能」,是多一個要下的決定 —— 而且那個決定有一半機率會反過來咬你。
| 點數 | 受益 | 效果 |
|---|---|---|
| 1 | 勇者 | 蓄力:下一次攻擊傷害 ×3 |
| 3 | 勇者 | 治療:回復 40 點血 |
| 5 | 勇者 | 破甲:小怪防禦 −5,持續 3 回合 |
| 2 | 小怪 | 狂暴:小怪攻擊 +5,持續 3 回合 |
| 4 | 小怪 | 再生:小怪回復 15 點血 |
| 6 | 小怪 | 硬化:小怪防禦 +3,持續 3 回合 |
(數值是後面測出來的,一開始不是這樣 —— 見下面的平衡那段。)
寫 buff 的時候,最直覺的做法是這樣:
state.enemy.atk = state.enemy.atk + 5; // 狂暴
我差點就這樣寫了,然後停下來想了三個問題:
只要基礎值被改過一次,就再也分不清「原本是多少」。
所以改成把兩件事分開:
state.enemy = { atk: 15, def: 3 }; // 基礎值,從頭到尾不動
state.buffs = [ ... ]; // 現在生效中的效果清單
function effectiveAtk(who) { // 要用的時候才加總
return Math.max(0, state[who].atk + sumBuff(who, "atk"));
}
基礎值不可變,效果用清單疊加。
這個決定的好處在重置的時候最明顯:
state.buffs = []; // 就這樣,還原完畢
因為基礎值從來沒被動過,清空清單就回到初始狀態。如果當初直接改 atk,重置就得記得每一個 buff 加了多少、要減回去多少 —— 而且只要漏掉一個,下一場就帶著髒資料開打。
回頭看,這跟 Day 23 那個「顯示層的清理不等於資料層的正確」是同一個家族的問題:分清楚哪些是原始資料、哪些是算出來的結果。
擲骰消耗的是勇者的回合。那麼持續型的 buff,該從哪一回合開始算?
我一開始想「都從當回合起算」,但這樣對勇者不公平:
第 8 回合:勇者擲骰(所以他這回合不攻擊) → 小怪照樣反擊
如果破甲從第 8 回合起算(8、9、10)
但第 8 回合勇者根本沒攻擊 → 這一回合的破甲白白浪費
→ 3 回合的效果,實際只用到 2 回合
而小怪沒有損失任何回合 —— 牠照樣反擊 —— 所以牠的 buff 沒有這個問題。
規則因此變成:持續型 buff 從「受益者真正能用到它」的那個回合開始算。
| 效果 | 受益者 | 第 N 回合骰到 |
|---|---|---|
| 蓄力、破甲 | 勇者 | N+1 起算三回合 |
| 狂暴、硬化 | 小怪 | N 起算三回合 |
實作上用一個 pending 標記:
state.buffs.push({ ..., turnsLeft: 3, pending: f.good });
function tickBuffs() { // 每回合結束時呼叫
for (const b of state.buffs) {
if (b.pending) { b.pending = false; continue; } // 剛掛上,這回合不倒數
b.turnsLeft = b.turnsLeft - 1;
}
state.buffs = state.buffs.filter((b) => b.turnsLeft > 0);
}
而 sumBuff() 會跳過 pending 的 —— 還沒生效的不算數。畫面上也看得出差別:「破甲(下回合起 3)」vs「狂暴 3」。
立即型的治療和再生就沒有這個問題,骰到就結算 —— 血剩 5 點的時候骰到治療,如果還要等一回合,那可能已經死了。
做完之後我自己玩了幾場,發現:
不骰骰子,我最快 9 回合贏;有了骰子,我最快 12 回合贏。
骰子讓我的紀錄變差了。而我還是一直骰。
人類真是賭性堅強。
Day 24 我寫過一個戰鬥模擬器,今天把它擴充成兩種策略的對照:
compareDice() // 跑「從不擲骰」和「一有機會就擲」各 1000 場
第一次的結果非常難看:
從不擲骰 :勝率 66.2%,獲勝平均 9.9 回合,最快 8 回合
一有機會就擲:勝率 16.6%,獲勝平均 11.8 回合,最快 8 回合
勝率掉了 50 個百分點。 也就是說,骰子不只讓紀錄變差 —— 它連「求生工具」都不是,它讓你更容易死。
算一下就懂了:擲一次骰的成本是「白挨一次打(約 10 點)+ 少造成一次傷害(約 6 點)」。而三個好處(蓄力 +6 傷害、治療 +25 血、破甲 +6 傷害)加起來,還打不過一個狂暴造成的 15 點傷害。
要反覆調數值,就會一直跑模擬。但這裡有個麻煩:模擬器自己重做了一份戰鬥邏輯(因為它不能碰畫面),所以同一套規則寫在兩個地方。
改了遊戲忘了改模擬器,兩邊就會悄悄分岔 —— 然後你會拿一個測錯東西的工具,去驗證一個不存在的平衡。
這在測試工作裡是很經典的坑:測試工具和被測物不一致,測出來的結果全是假的。
所以我先把六個面的數值抽成一份共用設定:
const DICE_FACES = {
1: { label: "蓄力", good: true, kind: "charge", multiplier: 3 },
2: { label: "狂暴", good: false, kind: "buff", target: "enemy", type: "atk", value: 5, turns: 3 },
...
};
遊戲和模擬器都讀這一份。改一個地方,兩邊自動同步,連遊戲裡的說明文字都是從這裡自動產生的。
我原本的調整方案是「勇者面加強、小怪面削弱」,但最後決定只改 1、3、5,小怪那三面完全不動。
理由是:不想削弱風險。 骰輸的代價要保持原本的痛,賭注才有緊張感。如果把狂暴從 +5 砍到 +3,骰子就會變成「反正沒什麼損失,骰就對了」—— 那就不是賭了。
| 面 | 原本 | 調整後 |
|---|---|---|
| 1 蓄力 | ×2 | ×3 |
| 3 治療 | 25 | 40 |
| 5 破甲 | −2 | −5 |
| 2、4、6 | — | 不動 |
破甲 −5 特別關鍵:小怪防禦只有 3,−5 等於直接清零,傷害從 9−3=6 變成 9−0=9,整整多五成。
從不擲骰 :勝率 66.2%,獲勝平均 10.0 回合,最快 8 回合
一有機會就擲:勝率 32.8%,獲勝平均 11.3 回合,最快 6 回合
兩個變化:
最快 6 回合,是不骰骰子永遠做不到的。 蓄力 ×3 加上破甲 −5 連發的時候,傷害可以從 6 跳到 27,兩三回合就能結束戰鬥。
於是這個遊戲終於有了兩種都說得通的玩法:
| 你想要什麼 | 怎麼玩 | 結果 |
|---|---|---|
| 穩穩打贏 | 不碰骰子 | 66% 勝率,約 10 回合 |
| 刷紀錄 | 一有機會就賭 | 33% 勝率,但有機會 6 回合 |
這才是真正的取捨 —— 不是「哪個比較好」,而是「你要什麼」。
而 Day 22 回顧時發現的那個問題(「每場都 4 回合,最少回合挑戰沒意義」),到今天才算真正解決:現在紀錄不只看運氣,還看你敢不敢賭。
有一點要誠實說明:模擬跑的是「一有機會就擲」這個極端策略 —— 不管血多血少、不管領先落後,冷卻一好就骰。
真人不會這樣玩。血多的時候賭、快死的時候老實打,實際勝率應該落在 33% 和 66% 之間。
模擬器測的是策略的邊界值,不是玩家的真實表現。 這跟測試時用極端輸入是同一件事:它告訴你「最壞會壞到哪裡」,但不告訴你「一般人會遇到什麼」。兩種資訊都需要,不能互相取代。
今天搞懂的事:
pending 標記處理明天收尾:排版、手機版,還有一些只有自己玩過才會發現的小問題。