遊戲寫到一半才懂的事 — 什麼時候該拆 Class?

遊戲寫到一半才懂的事 — 什麼時候該拆 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 的後續更新。