遊戲寫到一半才懂的事 — 什麼時候該拆 Class?
如果你問我「寫遊戲的時候,什麼時候該把程式碼拆開?」我的答案是:等你痛了再拆。
這不是在開玩笑。我在做 ConjuGate(一個用 SpriteKit 寫的日文動詞變化學習遊戲)的過程中,真的是先把所有東西塞進一個 GameScene.swift,等到痛到不行了才開始拆。回頭看,這反而是對的。
一開始:所有東西都在 GameScene 裡
第一版的 ConjuGate,整個遊戲邏輯就是一個 1,132 行的 GameScene.swift。玩家移動、敵人生成、子彈發射、HUD 更新、碰撞判定——全部在同一個檔案裡。
你可能會說:「這不是很糟嗎?」
其實不會。在早期階段,你還在探索遊戲到底長什麼樣子。今天加了一個機制,明天可能就砍掉。如果一開始就花時間設計完美的架構,這些架構很可能跟著被砍掉的功能一起進垃圾桶。
想像你在畫草稿——你不會先把鉛筆線條上色。先畫出形狀,確定構圖對了,再來細修。
轉折點:Boss 戰讓一切爆炸
遊戲加了 Boss 戰之後,GameScene 膨脹到了 1,729 行。
這時候我開始感受到痛了:
- 想改 Boss 的血量邏輯,得在一千多行裡面找半天
- 改 HUD 的時候不小心動到武器的 code
- Debug 的時候要在腦袋裡同時追蹤太多狀態
這就是拆分的時機。不是因為教科書說要拆,而是因為你的大腦已經裝不下了。
用費曼的話來說:如果你沒辦法簡單地解釋一段 code 在做什麼,那不是你的問題,是 code 的問題。當你看著自己的 GameScene 卻需要「考古」才能理解——是時候拆了。
第一次拆分:按職責切
我從 GameScene 裡抽出了三個東西:
- BossController(171 行)— Boss 的生命週期、血量、動畫、狀態機。一個完整的小世界,跟其他遊戲邏輯幾乎沒有交集。
- WeaponSystem(215 行)— 子彈池、射擊模式(普通/散射/雷射)、無人機、砲塔、升級系統。它有自己的 update loop,天生就該獨立。
- HUDManager(234 行)— 所有 UI 的設定和更新。純粹的顯示邏輯,不碰任何 gameplay。
一刀下去,GameScene 從 1,729 行掉到 1,152 行,減少了 33%。
怎麼決定切在哪裡?問自己一個問題:「這塊 code 可以不知道 GameScene 的其他部分而獨立存在嗎?」 如果可以,它就該搬出去。
第二次拆分:從繼承到組合
敵人一開始只有一種,後來加了坦克型。我很自然地用了 OOP 繼承:
class EnemyNode: SKSpriteNode { ... }
class SlimeEnemy: EnemyNode { override func playHurt() { ... } }
class TankEnemy: EnemyNode { override func playDeath() { ... } }
看起來很教科書,對吧?
但當我要加第三種、第四種敵人的時候,問題來了:每種敵人都要一個新的 class,而這些 class 之間 90% 的 code 是一樣的,只有動畫素材不同。
所以我把繼承砍掉,改用組合(Composition):
// 不再需要 SlimeEnemy、TankEnemy...
// 只需要一個 EnemyNode + 一個 SkinConfig
struct SkinConfig {
let atlasName: String
let frameCount: Int
let displaySize: CGFloat
}
// 加新敵人?只要加一筆資料
let fireSlime = SkinConfig(atlasName: "Slime3", frameCount: 4, displaySize: 48)
整個 SlimeEnemy 和 TankEnemy 的 class 都刪掉了。新增一種敵人從「寫一個新 class + override 方法」變成「填一筆 struct 資料」。
這就是引入架構的時機:當你發現自己在做重複的事情,而且這個重複的模式已經穩定下來了。「穩定」很重要——如果模式還在變,抽象反而是枷鎖。
其他值得提的 Pattern
Object Pool — 解決 60 FPS 下的記憶體壓力
砲塔 + 無人機全開的時候,一秒要發射大約 68 顆子彈。每顆子彈都 SKSpriteNode() 新建一個 instance,GC 壓力直接讓畫面掉幀。
Object Pool 的概念很簡單:用完的子彈不要丟掉,洗一洗放回池子裡,下次要用直接拿。像餐廳的盤子一樣。
Data-Driven Design — 讓內容和程式碼分離
不只敵人,連道路主題(RoadTheme)也是資料驅動的。每一關的路面材質、邊緣裝飾、顏色,都定義在一個 struct 裡面。加新關卡不用碰渲染邏輯,只要加一筆資料。
Dirty Flag — HUD 不需要每幀更新
HUDManager 用 dirty flag 來決定要不要更新 UI。分數沒變?那 label 就不動。聽起來微不足道,但 60 FPS 下每個省下的 setText 呼叫都是真金白銀。
總結:什麼時候該拆?
| 情況 | 該做什麼 |
|---|---|
| 剛開始寫,功能還在探索 | 全部塞一個檔案沒關係,別過早優化 |
| 檔案超過 ~1,500 行,找 code 要翻半天 | 按職責拆分 — 問「這塊能獨立存在嗎?」 |
| 同樣的 pattern 出現第三次 | 考慮抽象化,但先確認 pattern 已經穩定 |
| 繼承樹變深,子 class 之間 90% 重複 | 改用組合(Composition),用資料代替 class |
| Per-frame allocation 導致掉幀 | Object Pool |
| 新增內容(關卡、敵人、道路)需要改程式碼 | Data-Driven Design,用 struct/config 取代 hard code |
記住:架構是為了讓你的大腦不爆炸,不是為了讓 code 看起來漂亮。先讓東西動起來,等痛了再治。痛點本身會告訴你該怎麼拆。
ConjuGate 是一個用 SpriteKit 寫的 iOS 遊戲,讓你在打怪的過程中學日文動詞變化。如果你有興趣,可以關注這個 blog 的後續更新。