iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0

今天的主題:狀態管理——把散落的變數收進一個盒子

到昨天為止,我的遊戲有四個獨立的變數在記錄「現在的情況」:

const hero = {...};           // 勇者資料
const enemy = {...};          // 小怪資料
let whipCount = 0;            // 鞭屍次數
let battleStarted = false;    // 戰鬥是否開始

四個東西,散在檔案各處。今天要把它們收進同一個盒子。

為什麼要收在一起

Day 12 回顧的時候,我寫過自己卡關的真正原因:

我一直在回頭找「我要的這個動作該加在哪一行」,還要在腦中模擬:執行到這裡時,每個變數是多少?

變數越散,腦中要追蹤的東西越多。 具體的麻煩有三個:

問題 實際情況
debug 要到處找 想知道「現在遊戲是什麼狀態」,得同時看四個地方
容易漏掉 做「再戰一次」時,很容易忘記重置 whipCount
無法一次觀察 沒辦法用一行 console.log 看到全貌

解法:單一狀態物件

const state = {
  hero:  { name: "勇者", hp: 100, maxHp: 100, atk: 20, def: 5 },
  enemy: { name: "小怪", hp: 60,  maxHp: 60,  atk: 15, def: 3 },
  turn: 1,
  whipCount: 0,
  battleStarted: false,
  isOver: false,
};

觀念上沒有新東西 —— 它就是 Day 6 學的物件,跟 Python 的字典是同一回事。真正的改變不是語法,是把「哪些資訊屬於遊戲狀態」這件事明確化。

這叫 single source of truth(單一事實來源):每個資訊只有一個權威的存放位置。想知道現在的情況,只要看 state 一個地方。

用 const 宣告但內容照樣能改 —— Day 6 驗證過的規則,在這裡變成優點:整包 state 不會被意外換掉,但裡面的值隨時能更新。

插入 day18.png:編輯器裡搜尋 state,整份程式引用它的位置全被標記出來

順手做的兩個改動

重構的時候我加了兩個新欄位。

state.turn:記錄第幾回合,畫面上也顯示出來。之前根本沒有回合概念,但 Day 17 決定要做「最少回合挑戰」,這個數字現在有了用途。

state.isOver:遊戲是否結束。以前判斷「已經死了」是看 enemy.hp <= 0,那是從結果反推狀態。現在改成明確的旗標:

if (state.isOver) {          // 以前是 if (enemy.hp <= 0)
  // 鞭屍分支
}

差別在哪?如果之後加入「小怪逃跑」「戰鬥中斷」這類結束方式,血量還是滿的,但遊戲確實結束了 —— 那時候 hp <= 0 就完全失效。用旗標記錄「狀態」本身,比從數值去猜要可靠。

重構完之後,我發現一個老朋友

有了 state 之後,我在每次攻擊後加了一行:

console.log("目前狀態:", state);   // 一行看見全貌

理論上這樣就能留下每一回合的完整紀錄。連續攻擊四下、打贏、再鞭屍幾下,主控台確實留下了八筆紀錄。

但展開來看的時候,問題出現了:顯示的全部是最後的狀態,不是每個階段當下的狀態。

插入 day18-2.png:摺疊預覽與展開內容互相矛盾

這張截圖把整件事講得很清楚。同一批紀錄:

紀錄 摺疊起來的預覽 點開之後
第 1 筆 turn: 2, whipCount: 0 turn: 4, whipCount: 1
第 2 筆 turn: 3, whipCount: 0 turn: 4, whipCount: 4
第 4 筆 turn: 4, whipCount: 0 turn: 4, whipCount: 4

摺疊時那排灰色的預覽文字是正確的歷史 —— 第一次攻擊時 turn 真的是 2。但只要一展開,它就去讀「現在」的 state,全部變成最新的值。

這就是 Day 6 那個活參照陷阱。當時我是在三個元素的陣列上撞到它,今天是整個遊戲狀態。

而且有個更細的地方:第 1 筆展開後是 whipCount: 1,第 2 筆卻是 4 —— 因為我展開它們的時間點不同。展開的那一瞬間讀一次,然後畫面就固定在那裡了。

集中管理讓好處變大,也讓陷阱變大

這件事值得停下來想一下。

狀態集中之後,「一行 console.log 看見全貌」確實做到了。但同一個動作現在包含了整個遊戲 —— 而它整包都是活的參照。以前印一個 whipCount 頂多錯一個數字,現在一次錯八個欄位。

工具讓觀察變容易,但沒有讓紀錄變可靠。 想留下真正的歷史快照,還是得自己動手:

console.log(structuredClone(state));              // 深層複製一份
console.log(JSON.parse(JSON.stringify(state)));   // 老方法,同樣有效

(Day 6 學的 [...arr] 在這裡不夠用,因為它只複製第一層 —— state.hero 還是同一個物件。這種「只複製表面」和「連裡面一起複製」的差別,是我今天新學到的。)

重構之後有比較輕鬆嗎

老實說,寫的當下感覺不大 —— 因為觀念上沒學到新東西,只是把同樣的資料換個地方放。hero.hp 變成 state.hero.hp,還多打幾個字。

但有兩個地方立刻感受到差別:

  1. 編輯器搜尋 state,整個遊戲的狀態流動一目瞭然 —— 哪些函式會改狀態、哪些只是讀,一眼就分得出來
  2. 寫「再戰一次」的重置時,我對照 state 的欄位一個一個檢查 —— 如果還是四個散落的變數,我很可能會漏掉其中一個

第二點特別重要。集中管理沒有自動幫我避免錯誤,但它給了我一張可以對照的清單。這大概就是「工程化」的意思:不是變得更聰明,是讓犯錯更容易被發現。

小結

今天搞懂的事:

  • 單一事實來源:每個資訊只有一個權威存放位置,想知道現況只看一個地方
  • state 物件語法上就是 Day 6 的物件,真正的改變是把「什麼算狀態」明確化
  • 用旗標(isOver)記錄狀態,比從數值(hp <= 0)反推可靠
  • 集中管理讓觀察變容易,但 console.log 的活參照陷阱也跟著放大
  • [...arr] 只複製第一層;要完整快照得用 structuredClone 或 JSON 轉一圈
  • 重構不會讓人變聰明,但會給你一張對照清單,讓漏掉的東西容易被看見

明天學非同步 —— Day 17 那個「排到清單最後面」的機制,會正式變成主角。


上一篇
Day17|計時器:地雷、定時炸彈,和我決定不用它的理由
下一篇
Day019|非同步:畫面凍住的 3 秒,我自己放棄了點擊
系列文
QA 的 JavaScript 初心之路:30 天做出一個能玩的戰鬥小遊戲《勇者 vs 小怪》 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言