iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
JavaScript

QA 的 JavaScript 初心之路:30 天做出一個能玩的戰鬥小遊戲《勇者 vs 小怪》系列 第 23 篇

Day23|把滿血量改成負數,然後一筆假紀錄被永久存了下來

  • 分享至 

  • xImage
  •  

今天的主題:專案骨架——從練習檔變成一個「專案」

前面 22 天,每天都是一個 dayXX.html,HTML、CSS、JavaScript 全部擠在同一個檔案裡。今天開始做正式作品,第一件事就是把它拆開。

三個檔案,三種職責

day23/
├── index.html    ← 骨架:畫面上有哪些東西
├── style.css     ← 外觀:長什麼樣子
└── game.js       ← 邏輯:怎麼運作

這就是 Day 14 學到的「外觀歸 CSS,邏輯歸 JS」,只是現在連檔案都分開了。

連接方式:

<head>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  ...
  <script src="game.js"></script>
</body>

兩個位置都有理由:

  • <link> 放 <head>:樣式要早點載入,不然畫面會先閃一下沒樣式的版本
  • <script> 放 </body> 前:Day 10 的教訓 —— JS 要等元素都建立好才抓得到它們

為什麼檔名要叫 index.html

網頁伺服器有個預設規則:有人只輸入資料夾網址時,自動去找 index.html。

之後部署到 GitHub Pages,網址會是 https://帳號.github.io/ironman-js-battle/,不用在後面加檔名 —— 前提是檔案叫這個名字。這是我今天才知道的慣例由來。

血條:maxHp 終於派上用場

今天的新東西是血條,原理比我想的單純:

<div class="hp-bar">              <!-- 固定寬度的軌道 -->
  <div class="hp-fill" id="enemyHpFill"></div>   <!-- 實際血量 -->
</div>
const percent = (fighter.hp / fighter.maxHp) * 100;
fillEl.style.width = percent + "%";

就是改內層的寬度百分比。而算百分比需要「滿血是多少」—— 這就是 Day 14 補上的 maxHp 真正發揮作用的地方。當時它只是為了取代寫死的 18,現在它是血條的分母。

我也把兩邊的渲染合成同一個函式:

function renderFighter(fighter, nameEl, textEl, fillEl) { ... }

renderFighter(state.hero,  heroName,  heroHpText,  heroHpFill);
renderFighter(state.enemy, enemyName, enemyHpText, enemyHpFill);

勇者和小怪的血條邏輯完全一樣,沒理由寫兩次 —— Day 8 學的「一個函式做一件事、不重複」在這裡第一次用在「同樣的事情做兩遍」上。

我的測試:把 maxHp 改成負數

原本的 QA 題目是「如果 hp 變成負數會怎樣」。但我想到另一個方向:如果 maxHp 本身就是負的呢?

輸入驗證我們做過了(Day 15),但那是防「使用者輸入」。maxHp 是程式裡的設定值,從來沒有人檢查過它。

enemy: { name: "小怪", hp: 60, maxHp: -60, atk: 15, def: 3 },

結果是一連串的問題,而且一個比一個嚴重。

現象一:血條完全不動,但沒有任何錯誤

percent = (43 / -60) * 100 = -71.67
fillEl.style.width = "-71.67%";

width: -71.67% 是無效的 CSS。 瀏覽器的處理方式是:直接忽略這行宣告,不報錯、不警告 —— 血條就停在上一個有效的寬度。

JS 開開心心地設定了,瀏覽器默默地不理它。又一個安靜失敗,而且這次是在 CSS 層級。 我們的安靜失敗清單又多一條,而且這條完全不在 JavaScript 的世界裡。

插入 day23.png:小怪血量顯示 22 / -60,血條卻還是滿的;上面勇者的血條則正常運作

對照上圖:勇者那條(綠色)完全正常,小怪那條(紅色)血量都掉到 22 了還是滿的。同一段程式、同一個函式,只因為餵進去的 maxHp 不同,一邊正常一邊靜默失效。

現象二:「危險」判斷不是失效,是永遠成立

if (percent <= 30) { 加上危險樣式 }

percent 是負數,而負數永遠小於 30 —— 所以危險判斷從第一秒就成立,而且再也不會改變。

這比「失效」更難察覺:如果它完全不作用,你會注意到;但它一直在作用,只是永遠都是同一個結果。

現象三:最嚴重的 —— 一筆假紀錄被永久寫進去

按下「再戰一次」之後:

state.enemy.hp = state.enemy.maxHp;   // hp 被設成 -60

小怪的血變成 -60。然後第一次攻擊:

-60 - 17 = -77  →  <= 0  →  強制歸零,isOver = true

第一擊就結束戰鬥,回合數是 1。 接著 saveRecordIfBetter(1) 把「1 回合」存進了 localStorage。

一筆永遠破不了的假紀錄,而且它活過重新整理、活過關閉瀏覽器。前面兩個現象只是畫面怪怪的,這一個是資料被污染了。

一個設定值寫錯,最後污染了持久化的資料。 這是今天最值得記下來的一條路徑。

我發現自己那行保護寫錯地方了

我原本在渲染函式裡寫了這個:

const hp = Math.max(0, fighter.hp);   // 防止顯示負數

看起來有處理負數了。但在主控台裡,state.enemy.hp 還是負的。

因為那行只保護了「顯示」,沒有保護「資料」。

插入 day23-2.png:畫面顯示「小怪 0 / -60」,主控台展開的 state 裡 hp 是 -60

這張截圖把兩層的落差拍得很清楚:畫面上小怪的血是 0,主控台裡的 enemy.hp 是 -60。而且左下角那行「最少回合紀錄:1 回合」還在 —— 那筆假紀錄已經被寫進去了。

層 狀況
畫面 顯示 0,看起來正常
資料 實際是 -60
依賴資料的東西 判斷、紀錄、之後的存檔 —— 全部拿到錯的值

顯示層的清理不等於資料層的正確。 畫面看起來沒事,不代表資料沒事 —— 而所有後續的邏輯讀的都是資料,不是畫面。

這個分層的觀念我以前在測試時隱約有感覺(「畫面對不代表後端對」),今天第一次在自己寫的程式裡看到它的樣子。

真正的修法:在源頭大聲失敗

問題不在渲染函式,而在於沒有人檢查 maxHp 是否合理。所以修的地方應該是資料進入系統的那一刻:

function createFighter(name, maxHp, atk, def) {
  if (maxHp <= 0) {
    throw new Error(`${name} 的 maxHp 必須大於 0,收到 ${maxHp}`);
  }
  return { name, hp: maxHp, maxHp, atk, def };
}

throw new Error(...) 是主動製造一個大聲失敗 —— 與其讓錯誤的設定悄悄流進整個系統,不如一開始就吵起來。

這正好呼應 Day 21 的結論:安靜失敗最難查,所以有時候你要刻意把它變大聲。 以前我以為錯誤訊息是「壞事發生的證明」,現在覺得它更像是「有人幫你擋住了」。

拆檔案之後的實際感受

老實說我原本擔心:拆成三個檔案,會不會反而要開更多分頁、找東西更麻煩?

實際用下來是比較好找,關鍵在於編輯器可以左右分割顯示。改血條樣式時,左邊開 CSS、右邊開 JS,兩邊對照著看。以前擠在一個檔案裡,同樣的事得上下捲來捲去,而且很容易捲過頭。

檔案變多不等於更亂,重點是每個檔案的職責夠明確,你知道要去哪裡找。

小結

今天搞懂的事:

  • 正式專案拆成 index.html / style.css / game.js,職責分離
  • 檔名叫 index.html 是因為伺服器會自動找它(部署時網址才乾淨)
  • 血條 = 外層軌道 + 內層寬度百分比;算百分比需要 maxHp
  • 同樣的渲染邏輯用一個函式處理兩邊,不要寫兩次
  • 無效的 CSS 值會被瀏覽器直接忽略 —— 又一種安靜失敗
  • 負數讓 <= 判斷永遠成立,這比失效更難發現
  • 顯示層的清理不等於資料層的正確;所有後續邏輯讀的是資料
  • 錯誤的設定值該在源頭用 throw 擋下 —— 主動把安靜失敗變大聲

明天開始接戰鬥:讓小怪終於能還手。


上一篇
Day22|第一階段我需要一張表,第二階段我需要一張流程圖
下一篇
Day24|攻擊力差 1 點,勝率差了 47 個百分點
系列文
QA 的 JavaScript 初心之路:30 天做出一個能玩的戰鬥小遊戲《勇者 vs 小怪》 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言