做一款中型遊戲(下):戰術地城《推演》——把畫面換成可推演的棋局
2026年5月31日 · wemee (with AI assistant)
🎮 《做一款中型遊戲》 這是一個兩篇的系列,記錄我替遊戲區做兩款「中型」遊戲的開發心得。
- 塔防《防線》:把「破關」換成「撐多遠」
- 戰術地城《推演》:把畫面換成可推演的棋局(本篇)
上一篇做完塔防,第二款中型遊戲我想挑戰 roguelike——程序生成、永久死亡、越闖越深,系統交互天生就比塔防更密。
但一坐下來就卡在第一個分岔:roguelike 那麼多種,要做哪一種?
最安全的答案是經典 Rogue clone:@ 在地城裡走、撞怪攻擊、撿裝下樓、永久死亡。穩,大家都懂,但也就是「又一個」。我不想做又一個。
我選了一條更狠的:Into the Breach 風的完全資訊預告式戰術解謎。 成品在 /game/tactics。
轉念:把「畫面」換成「一局棋」
經典 roguelike 的張力來自未知——迷霧裡有什麼怪、這瓶藥是好是壞、轉角後面是寶箱還是陷阱。但未知的另一面是「靠運氣」,而我想做的是純靠腦。
於是反過來:完全公開所有資訊,連敵人下一步都先告訴你。
每一層是一個 8×6、全部看得見的小競技場。每個敵人都亮出預告——它下一回合會走到哪一格、會攻擊哪些格,全部標在棋盤上。沒有迷霧、沒有隨機骰、沒有「沒看到的攻擊」。你看到的就是全部,接下來純粹是:在這個完全已知的局面裡,找出最佳解。
這個轉念跟神諭那篇的「把畫面換成參數」是同一種思路——換掉表示法,難的東西就消失了。一旦「敵人的意圖」從一個藏在 AI 裡的黑箱,變成一筆畫在棋盤上的資料,整款遊戲就從「動作遊戲」變成了「一局可以推演到底的棋」。玩家不再是反應,而是推演——這也是它叫《推演》的原因。
預告在程式裡就是一筆鎖定的資料,簡單到不可思議:
interface Intent {
moveTo: GridPos | null; // 預告:下回合移動到這格(黃框)
attackTiles: GridPos[]; // 預告:會打到這些格(紅色)
damage: number;
}
每次敵人行動結束,就依當前玩家位置重算、鎖定每隻敵人的 Intent,畫出來給玩家看。玩家行動 → 敵人照鎖定的預告執行 → 重算下一輪預告。完全資訊的閉環,核心就這麼一筆資料撐起來。
操縱,而不是硬殺
完全資訊之後,爽點就不在「打得夠痛」,而在操縱敵人——讓它們的預告落空、誤傷彼此、踩進陷阱。我給玩家的核心動詞是位移與傷害並重:推、拉、揮擊、長矛,多數攻擊都帶推力。
幾種解法,全是位置的遊戲:
- 敵人預告攻擊你「現在站的格」→ 你走開,它就打在空地上。
- 兩隻怪預告衝過來 → 你推一隻進深坑(即死),站位讓另一隻被牆擋住落空。
- 弓箭手預告射出一整條直線 → 你想辦法讓它的同伴站進那條線。
最後這個「友傷」是我最喜歡的一點,而它幾乎是免費長出來的。攻擊的結算只有一條規則:對攻擊格上的「任何單位」造成傷害——管它是玩家還是另一隻怪。
private damageTile(pos: GridPos, dmg: number): void {
if (posEq(pos, this.player.pos)) this.player.hp -= dmg;
for (const e of this.enemies) {
if (e.hp > 0 && posEq(e.pos, pos)) {
e.hp -= dmg; // 敵人也吃自己人的攻擊
if (!posEq(pos, this.player.pos)) this.log.push('友傷!');
}
}
}
我沒有為「友傷」寫任何特例。我只是沒有寫「友軍判定」——攻擊格不分敵我地生效。少寫一個 if,反而長出一整套戰術可能性。這是我做遊戲一再驗證的事:最好的機制深度,常常來自規則的一致性,而不是特例的堆疊。
同樣的道理,「被推位移的敵人,該回合行動中斷」也只是一個 disrupted 旗標——推它一下,它就暈了一回合。一個布林,換來「用推力打斷對方節奏」的整層戰術。
純邏輯與渲染,一刀切開
因為這是一局「棋」,棋的規則必須自己能站得住、自己能驗證,完全不依賴畫面。所以整個核心是一疊純函式模組,沒有一行碰 Phaser 或 DOM:
| 模組 | 職責 |
|---|---|
systems/grid.ts | 座標、BFS 可達/最短路徑、射線——純幾何 |
systems/generator.ts | 程序生成競技場(吃一個可重現的亂數種子) |
systems/intents.ts | 算出每隻敵人的預告 |
TacticsCore.ts | 回合流程:移動 → 行動 → 敵人階段 → 重算預告 |
scenes/GameScene.ts | 唯一碰 Phaser 的地方:照著核心狀態畫棋盤 |
好處在驗證階段立刻兌現。我不必開瀏覽器、用滑鼠點來點去才知道規則對不對——直接丟一個局面進核心,斷言結果:推擊把怪推進坑了沒、揮擊推開一格沒、弓箭手的線真的打到同伴沒、清場後有沒有跳出二選一獎勵。視覺的東西難測,但「規則」這層是純計算,測起來乾脆俐落。
渲染那層則反過來,刻意做得很笨:每次狀態一變,整個棋盤從頭重畫一遍。 8×6 才 48 格,重畫的成本可以忽略,換來的是渲染邏輯零狀態、永遠不會跟核心對不上。能用笨辦法解決的,就別自作聰明。
一個讓兩隻怪疊在同一格的坑
實測時抓到一個漂亮的 bug:兩隻衝鋒兵,結束敵人階段後疊在同一格上。
追下去,問題出在我的 BFS 最短路徑函式。為了讓敵人能「走向玩家腳邊那一格」,我讓 path() 對終點網開一面——就算終點被佔據,也允許演算法把它當成可抵達的目標(否則玩家站著的格永遠到不了,敵人就傻在原地)。
但這個方便埋了雷:當兩隻怪剛好鎖定了同一個移動目標格,第二隻照樣會把那個「已經被第一隻站住」的終點當成合法落點,直接疊上去。
// 錯:直接走到路徑終點,沒檢查終點此刻是不是被佔了
const dest = route[steps];
// 對:沿路徑走到「最遠的空格」,逐格確認沒有別人或玩家站著
let dest = e.pos;
for (let i = 1; i <= maxSteps; i++) {
const t = route[i];
if (posEq(t, this.player.pos)) break;
if (this.enemies.some(o => o.id !== e.id && o.hp > 0 && posEq(o.pos, t))) break;
dest = t;
}
修法是把「能不能規劃到終點」跟「實際能落在哪」分成兩件事:規劃時放行終點(才找得到路),但執行移動時逐格檢查、停在最遠的空格。一個經典的「為了某個情境開的特例,在另一個情境變成 bug」的故事——順手記下來。
沒有 meta,只有越闖越深
跟上一篇的塔防呼應:這款也不做跨局解鎖,純單局永久死亡。死了從第一層重來,沒有「下次更強」的存檔。
那重玩誘因從哪來?全塞進單局之內:程序生成讓每層不同、層間二選一蒐集技能(長矛/鉤索/橫掃/投矛/療傷)組出你這一局的工具箱、敵種與陷阱密度隨深度爬升。深度無限,localStorage 記最深層數。
跟《防線》一模一樣的精神——把終點拿掉,成績變成一個衝得越來越深的數字。 一款靠「撐多遠」,一款靠「闖多深」。
收尾
兩款中型遊戲做下來,最大的收穫是同一件事的兩個面向。
《防線》教我:難度可以是設計出來的曲線,不必是手寫的內容。 《推演》教我:換對表示法,難題會自己消失。 把敵人的意圖從黑箱換成棋盤上的一筆資料,「動作遊戲」就變成了「棋」;把攻擊判定的敵我之分拿掉,「友傷」這套戰術就免費長了出來。
我一開始以為做中型遊戲是「做更多東西」。做完才明白,中型的關鍵從來不是量,而是找到那一兩個能讓系統自己長出深度的支點——剩下的,讓規則的一致性替你做工。
留言 0
留言載入中…