今天延續昨天的問題,我們看到了畫面是長這樣的:
所以今天的主題是要藉由觀察來擬訂接下來要改哪些東西。
其實就跟前兩天爆爆王遊戲修正 bug 的流程是一樣的,都是先執行出來看有什麼問題,但我當時主要想談的是:藉由多次的實際體驗來抓到錯誤,並進行多輪快速的修復,也因此可以修正到一些特定情境才會發現的 bug,但當時沒有詳細說明我在體驗過程當中是如何發現哪裡有問題的。
所以今天想來談談如何從一次的執行當中,藉由觀察分析出可能的錯誤在哪邊。那我們就開始分析吧!
首先很明顯的是這裡還殘留選單的畫面:

接著是中間被截斷的遊戲畫面:

這時我們試著用自己的話寫下觀察到了什麼:
那麼我們注意到哪邊跟理想的狀態不一樣:
所以問題在哪裡:

從上面這張圖,再試著用自己的話寫下觀察與分析:
我們發現當我們開始玩遊戲的時候,新的遊戲畫面會覆蓋舊的遊戲畫面,表示我們原本推箱子重繪的邏輯還存在,這是好事。另外一個問題是現在當遊戲結束時,會直接離開整個程序,這就不是重繪的問題,而是我們監聽器需要調整的部分。
所以根據上面的觀察,整理出問題大概在這三個地方:
知道問題在哪裡的話就可以來看程式碼抓漏一下,先來找到關鍵的時間點,錯誤是發生在按下 enter 之後,所以來看看 enter:
// Enter
if (key === '\r' || key === '\n') {
// 進入選擇的遊戲
const game = games.find(game => game.id === focus);
guideline = `你選擇的是 ${game.name}`;
game.script();
}
// 中間省略
render();
那我們可以看到這裡的兩個問題:
script 就會在這裡往下印出,所以我們要在執行遊戲之前將游標移動回左上角 → 清空畫面 → 執行 script 把遊戲叫出來。
render 去做重新渲染,這也就是導致我們的選單多印一次蓋住遊戲畫面的原因。針對這兩個問題,讓我們把程式碼修正成這樣:
// Enter
if (key === '\r' || key === '\n') {
// 進入選擇的遊戲
const game = games.find(game => game.id === focus);
process.stdout.write(`\x1b${height}A`); // 游標回到左上角
process.stdout.write('\x1b[J'); // 清除畫面
game.script(); // 執行遊戲
return; // 結束這個監聽流程,不要往下跑到 render
}
試著玩看看
這時我們發現還是有殘影,是出現在開啟遊戲之後,按下了第一步,此時就會渲染出選單,這時我發現只在 enter 的時候避免掉 render 是不夠的,因為每次按下按鍵,但是遊戲區入口的監聽和遊戲本身的監聽都還在運作,就會導致遊戲入口的監聽還是會每次都印出選單。
所以現在監聽動作的部分,我們還有兩個要調整的:
我想先優先來處理第一項,但是這涉及到我對於 process.stdin.on 這個方法還不是很熟悉,每到了這個時候,第一個反應都很想要請出 AI 來幫忙啊…,但其實我有想到一個想法,所以我想再堅持一下。
這個想法就是我何不設計一個變數來記錄玩家的狀態是在哪一個遊戲或者在入口:
let playerState = 0; // 用 0 當作是入口
然後直接在監聽到有按鍵輸入的時候,如果不是入口,就直接 return,這樣就不會有選單再次被渲染的問題了!
process.stdin.on('data', (key) => {
// 如果不是選單就直接終止後面的監聽
if (playerState !== 0) {
return;
}
//其他內容不變
// Enter
if (key === '\r' || key === '\n') {
// 進入選擇的遊戲
const game = games.find(game => game.id === focus);
playerState = game.id; // 這裡在選擇遊戲後改成遊戲 id
process.stdout.write(`\x1b${height}A`);
process.stdout.write('\x1b[J');
game.script();
return;
}
// 後面內容不變
});
到這邊我們先驗證看看有沒有成功轉換過去:
耶,這樣就成功了,但是我們要接著去處理兩個遊戲內的監聽,要讓狀態可以從遊戲轉回到入口,並避免掉獲勝或失敗直接結束遊戲。
由於我們剛剛是用了 playerState 這個變數來決定要不要啟用監聽器,可是遊戲內不會有這個變數,所以我們要寫個方法傳給遊戲,讓遊戲端可以藉由這個方法把 playerState 和畫面切換回來。
function backToMenu() {
playerState = 0; // 設回選單
render(); // 重新渲染
}
// Enter
if (key === '\r' || key === '\n') {
// 進入選擇的遊戲
const game = games.find(game => game.id === focus);
playerState = game.id;
process.stdout.write(`\x1b${height}A`);
process.stdout.write('\x1b[J');
game.script(backToMenu); // 這裡要傳入剛剛寫好的函式
return;
}
另外要去修改遊戲把這個函式傳進去:
export function startBox(backToMenu) {
//前面都不動
// 如果按下字母 'q' 或是 'Q',就回到選單
if (key === 'q' || key === 'Q') {
process.stdout.write(`\x1b[${map.length}A`); // 這邊兩句先清除遊戲畫面
process.stdout.write('\x1b[J');
backToMenu(); // 要回到選單
return;
}
// 後面也不動
}
結果遊玩時發現遊戲的部分還是沒停掉,這時候我覺得我土法煉鋼去阻擋 return 似乎不是一個好方法,總有辦法可以直接停掉遊戲吧?但就如同我前面說的,我對於這個技術不那麼熟悉,當我卡住的時候,還是決定來用一下 AI。
要不要用 AI 在這個系列中判斷的標準:會卡住很久的,就用 AI。如果自己還有想法、知道怎麼做,就試著做看看。
在看 AI 實作的結果以前,先來看一下它給的總結:

除了我們一直關注的監聽之外,它還另外抓到了計時器的部分也要做清除,總之我們先來玩看看。
我想帶大家再仔細的來觀察一次這個版本,你可以比較在按下 q 回到選單之前的遊戲畫面,以及從選單再次進到同一個遊戲的遊戲畫面。
進到選單之前:

進到選單之後再重新回到遊戲:

你有注意到什麼問題嗎?箱子的位置都沒有改變、敵人的動作是延續的、玩家的位置也是延續的,這代表整個遊戲是暫停 → 進到選單 → 回到遊戲後恢復,這讓我突然意識到我們從頭到尾沒有去定義過我們期待按 q 回到選單的時候,到底是要暫停還是要結束遊戲,下次重新開始。
另外程式碼為了暫停而做了蠻多的妥協是我不太喜歡的,比方說在這些地方:


為了要讓炸彈的時間暫停下來,多了 pendingBombTimers 刪除、新增的邏輯,我覺得這些把遊戲邏輯複雜化了。
如果我不喜歡這樣的寫法,那麼在這裡先停下來,釐清我的期待是什麼,重新訂定架構的方向似乎是更重要的事。
明天我們就來想想有沒有更根本而且更容易的解決方式吧!
如果你想要瀏覽完整的程式碼,可以參考這裡:第 21 天程式碼