做一款中型遊戲(上):塔防《防線》——把「破關」換成「撐多遠」
2026年5月31日 · wemee (with AI assistant)
🎮 《做一款中型遊戲》 這是一個兩篇的系列,記錄我替遊戲區做兩款「中型」遊戲的開發心得。
- 塔防《防線》:把「破關」換成「撐多遠」(本篇)
- 戰術地城《推演》:把畫面換成可推演的棋局
我這個站的遊戲區,長期都是「單機制、單畫面、單局結束」的小品——貪吃蛇、打磚塊、下樓梯。玩五分鐘就摸透了。這次想做點不一樣的:一款中型遊戲。
但「中型」到底是什麼?不是畫面更大、不是關卡更多。我給自己的定義是:多個系統互相咬合。塔防剛好踩中——戰鬥、經濟、波次、升級四個系統交纏在一起,你佈一座塔的決定會同時牽動火力、金流跟下一波的應對。成品在 /game/tower-defense。
開工前先定了一條紀律:純粹做好玩,不綁任何 AI/RL 訓練接口。我站上的遊戲不少都接了「遊戲 → 強化學習 → 部落格」的內容飛輪,但這次我刻意不走那條。理由很簡單——一旦要餵訓練,GameCore 就得背 Gymnasium 相容、headless、可重現那些約束,設計會被綁手綁腳。這次我只想做一款好玩的塔防,如此而已。
中型的第一個難題:內容量
塔防的「肉」在內容:多塔種、多敵種、多波次。如果每加一種塔就要動一次渲染邏輯、每加一波就要改一次生成程式,這遊戲還沒做完我就先累死了。
所以從第一天起,所有內容都是資料,不是程式。塔、敵、波次各自是一張表:
// data/towers.ts —— 加一座塔 = 加一筆資料,不動任何邏輯
export const TOWER_DEFS = {
cannon: {
name: '加農炮',
tiers: [
{ damage: 30, range: 2.5, fireRateMs: 1200, cost: 80, splash: 1.2 },
{ damage: 55, range: 2.8, fireRateMs: 1100, cost: 120, splash: 1.4 },
{ damage: 95, range: 3.2, fireRateMs: 1000, cost: 200, splash: 1.6 },
],
},
// gun / frost 同理…
};
波次也一樣——一張「這波放哪些敵、間隔多久」的表,WaveManager 照表 spawn,完全不認得「加農炮」或「重甲怪」是什麼。內容跟邏輯一刀切開之後,平衡微調變成改數字,加新塔變成加一個 key。中型遊戲的內容量,是靠資料驅動扛下來的,不是靠多寫程式。
真正的轉念:關卡可以無限多嗎?
做完十波,我盯著「🎉 守住了!」的勝利畫面,心裡冒出一個念頭:為什麼塔防一定要有終點?
第一直覺是「那就寫更多波啊」。但寫到第幾波才夠?第 50 波?玩家撐到第 50 波還是會看到結束畫面。手寫波次是一條會見底的路。
換個角度想——我要的不是「更多關卡」,是難度自己會長。於是把波次做成循環:十波是一個模板,第 N 波就取第 (N-1) % 10 個模板,永遠不會用完:
// 第 11 波 = 回到第 1 波的模板,但進入第 2 輪
const template = WAVES[(currentWave - 1) % WAVES.length];
const cycle = Math.floor((currentWave - 1) / WAVES.length); // 0-based 輪數
關卡無限了,但這還不夠——同樣的十波無限重複,玩家第二輪就閉著眼睛過。難度得隨輪數漲。
這裡是整款遊戲我最滿意的一個設計決定:用兩條成長曲線的交叉點,當難度引擎。
const hpMul = Math.pow(1.5, cycle); // 每輪血量 ×1.5 —— 指數
const rewardMul = Math.pow(1.25, cycle); // 每輪獎勵 ×1.25 —— 也漲,但較慢
敵人血量指數成長(×1.5),殺敵獎勵也漲、但更慢(×1.25)。一開始你越打越有錢、火力滾雪球,爽感十足;但兩條曲線的底數不同,血量那條終究會把獎勵那條甩開。某一輪開始,你的金流再也跟不上敵人的肉量——失守不是會不會,而是第幾輪。
於是這款遊戲沒有勝利畫面,只有失守結束。你的成績不是「破關」,是**「撐到第幾輪」**。localStorage 記最佳波次,每次都想再多推一輪。把終點拿掉、改成一個衝分的數字,反而比「守住十波」更耐玩。
一個小細節:完成每一輪會跳「✨ 完成第 N 循環」的橫幅,HUD 也把波次標成「第 N 輪」。無限循環如果沒有可見的里程碑,玩家會無感;一個小橫幅就把「我又往前推了一輪」的成就感顯化出來。
HUD 歸 HUD,戰場歸戰場
戰場我用 Phaser 畫(格盤、塔、子彈、射程圈),但金幣/生命/波次讀數、塔商店、變速暫停那些外框 UI,我堅持用 HTML + Tailwind——這樣才接得上站台其他頁面的設計語言,而不是在 canvas 裡硬刻一套醜醜的按鈕。
問題是這兩個世界怎麼對話?答案是回呼 + 公開方法,讓 Phaser 場景跟 DOM 完全解耦:
// 場景狀態有變 → 透過回呼通知 DOM,Phaser 不認得任何 DOM 元素
private emitState(): void {
this.callbacks.onStateChange?.({
gold: this.economy.gold,
lives: this.economy.lives,
wave: this.currentWave,
cycle: this.cycle,
// …
});
}
DOM 那邊只管畫 HUD、把點擊轉成 game.selectTower('cannon') 這類方法呼叫;Phaser 那邊只管跑遊戲、狀態變了就 onStateChange 喊一聲。兩邊各自乾淨,誰都不必伸手進對方的肚子裡。這跟我做神諭那尊石像時「資料/邏輯/畫面三層分開」是同一個信念:讓每一塊只做一件事。
(踩過的小坑:Tailwind 會把它在原始碼裡掃不到的 class 清掉。我一度用 border-${color} 動態組類名,結果 production build 後邊框全消失。解法是寫成完整字面的對照表——{ cannon: 'border-accent-red' }——讓 Tailwind 掃得到。)
體驗是玩出來的,不是想出來的
核心做完、能從第一波打到失守之後,真正的工作才開始:自己玩,然後被自己的設計煩到。
幾個煩點,一個個變成功能:
- 每波都要手動按「開始下一波」——佈好防線後只想看戲,卻得一直點。→ 加一個「自動進行下一波」勾選框,每波結束留 1.2 秒喘息再自動開下一波。
- 只有 1x / 2x 變速——後段一輪要等很久。→ 補到 1/2/4/8x 循環切換。
- 取消建造很煩——進了建造模式,想取消得「再點一次那顆商店按鈕」,反直覺。→ 改成:點空地就蓋、點非空地(路徑/場外)就取消建造、點已建好的塔就直接進它的升級面板。
最後這一項,牽動到「建造模式」這個狀態散落在好幾處(商店高亮、場上幽靈預覽、點擊判定)。我把它收斂成單一寫入點——一個 setPlacingType() 函式,任何地方要改建造狀態都得經過它,它再透過 onPlacingChanged 回呼去同步商店高亮。狀態有了唯一的入口,「場上取消時商店按鈕沒跟著熄掉」這類不同步的 bug 就絕跡了。
這段體驗迭代沒有一行是「設計階段」想到的,全是坐下來玩、被煩到、才長出來的。遊戲的手感,真的得用手去磨。
收尾
回頭看,《防線》最關鍵的一步不是哪個塔的數值,而是那個轉念:把「破關」這個終點,換成「撐多遠」這個數字。
一旦難度引擎變成兩條曲線的交叉,我就不必再煩惱「要寫幾波才夠」——關卡自己會長、難度自己會升、失守自己會來。手寫內容是會見底的;設計一條會自己成長的曲線,才是讓一款塔防無限玩下去的辦法。
下一篇換另一款中型遊戲:一個完全資訊、敵人會預告下一步的戰術地城。那款的轉念更狠——它把「畫面」整個換成了「一局可以推演到底的棋」。
留言 0
留言載入中…