經過昨天的努力,我們已經可以自由的在兩個地圖中移動了,不過我們還需要可以跟 NPC 對話。
如果你玩過 RPG 遊戲的話,要開啟對話,通常都是移動到NPC 的附近之後,按按鈕啟動對話,所以我的想法也是這樣:
P 相鄰空位的時候,按空白鍵開啟對話。村民:
年輕人,我們這裡很久沒有人來了。
你:
A: 你看起來不舒服,需要幫忙嗎?
B: 看起來是這樣,那我走啦。
村民:
是的,我這是老毛病了,你願意到村子外面的森林幫我採點草藥嗎?
你:
A: 當然可以。
B: 抱歉,我趕時間。
今天我想先實作到這裡,之後再來處理接下任務之後回報的流程。
那麼我們要來想想這個狀態機該如何設計,本來想直接開始畫圖,不過我在打開白板的時候發現我都還沒想清楚有哪些狀態呢XD 回到從狀態的擬定開始吧:
到這邊有點感覺了喔,action 就到白板裡面去畫好了,給大家看一下我畫好的樣子:

既然繪製完成,感覺經歷昨天的洗禮,現在已經蠻知道清除畫面該怎麼做了,那我們就一樣從拆解步驟開始吧:
render 的執行,似乎可以用對話的狀態來判斷要不要執行 render ,這只是我預想的,之後實際寫看看一邊實作一邊會發現有很多要新增的細節,我就逐一把步驟寫下來
// 遊戲物件的區塊要記得加上這個變數,預設是 0
let task = 0;
// 加上這一段,在 render y 迴圈的最底下
// 村莊和森林都要加
if (y === canvaMinY){
row += ` 任務:${task}`;
}
// 監聽器內加上這句
if (kye === ' ') {
openNPCTalk ();
return; // 避免 render 繼續執行,印出地圖
}
P 四週:function openNPCTalk () {
const NPCX = npc.x;
const NPCY = npc.y;
const surroundingCells = [
{ x: NPCX, y: NPCY },
{ x: NPCX - 1, y: NPCY },
{ x: NPCX + 1, y: NPCY },
{ x: NPCX, y: NPCY - 1 },
{ x: NPCX, y: NPCY + 1 },
]
const isNext = surroundingCells.some(cell =>{cell.x === player.x && cell.y === player.y})
if(isNext) {
// 這裡要加上後續的邏輯
}
}
let npcStatus = 'notStarted'; // notStarted -> firstTalk -> SecondTalk -> completed
const firstTalk = [
'村民:',
'年輕人,我們這裡很久沒有人來了。',
'',
'你:',
'A: 你看起來不舒服,需要幫忙嗎?',
'B: 看起來是這樣,那我走啦。'
]
const secondTalk = [
'村民:',
'是的,我這是老毛病了,你願意到村子外面的森林幫我採點草藥嗎?',
'',
'你:',
'A: 當然可以。',
'B: 抱歉,我趕時間。'
]
firstTalk 和 SecondTalk 這兩個陣列內容就可以了。// 在 openNPCTalk 裡面
if(isNext && npcStatus === 'notStarted') {
npcStatus = 'firstTalk';
// 是的話就清除畫面
process.stdout.write(`\x1b[${viewHeight}A`); // 上移游標
process.stdout.write('\x1b[J'); // 清除畫面
process.stdout.write("\n".repeat(firstTalk.length));//推出空間
// 顯示對話畫面
let frame = '';
for ( let i = 0; i < firstTalk.length; i++){
frame += firstTalk[i] + '\n'
}
process.stdout.write(`\x1b${firstTalk.length}A`);//上移游標
process.stdout.write(frame);//印出
}
到這邊我們先印出來看看,畫面轉換是否有成功吧
成功了!但如同我們猜測的那樣,再按一次任意方向鍵之後會把地圖重新印出來,那我們就繼續往下寫:
firstTalk 或 secondTalk 就阻擋 render 印出地圖// 加在 render 開頭
if(npcStatus === 'firstTalk' || npcStatus === 'secondTalk') {
return;
}
實測之後確實按下方向鍵就沒反應了
// 按下 B 按鍵
if ((key === 'b' || key === 'B') && (npcStatus === 'firstTalk' || npcStatus === 'secondTalk')) {
npcStatus = 'notStarted';
// 按下 B 後回到遊戲畫面
process.stdout.write(`\x1b${firstTalk.length}A`);
process.stdout.write('\x1b[J');
process.stdout.write('\n'.repeat(viewHeight));
render();
return;
}
測試之後確實可以回到遊戲畫面!
// 新增監聽
if ((key === 'a' || key === 'A') && (npcStatus === 'firstTalk' || npcStatus === 'secondTalk')) {
pushNPCStatus();
return;
}
// 新增一個新的函式
function pushNPCStatus() {
if (npcStatus === "firstTalk") {
npcStatus = "secondTalk";
// 清除畫面
process.stdout.write(`\x1b[${firstTalk.length}A`); // 上移游標
process.stdout.write('\x1b[J'); // 清除畫面
process.stdout.write("\n".repeat(secondTalk.length));//推出空間
// 顯示對話畫面
let frame = '';
for (let i = 0; i < secondTalk.length; i++) {
frame += secondTalk[i] + '\n'
}
process.stdout.write(`\x1b${secondTalk.length}A`);//上移游標
process.stdout.write(frame);//印出
}else if (npcStatus === "secondTalk") {
npcStatus = "completed";
// 清除畫面
process.stdout.write(`\x1b[${secondTalk.length}A`); // 上移游標
process.stdout.write('\x1b[J'); // 清除畫面
process.stdout.write('\n'.repeat(viewHeight));//推出空間
render();
}
}
這裡順便把第二段對話到完成一起做完了,現在來玩看看吧
這裡我總共測試了三個情境:
這三個狀態都有切換正確!
剩下最後要來更新任務數字:
//pushNPCStatus 內的 secondTalk 區塊
else if (npcStatus === "secondTalk") {
npcStatus = "completed";
task+= 1; // 加上這句
// 其他不變
}
我們今天的任務終於完成啦!!今天寫起來總覺得特別輕鬆,我覺得是因為前面幾天花了很多功夫在練習設計狀態機、拆解步驟、重新渲染地圖等等的邏輯,所以寫到這裡,其實已經都是前面用過的概念和方法了。
這就是我先前說的吧,前期會走得很緩慢,但是逐步累積經驗之後,甚至開始可以預測可能的問題在哪邊,然後去實測發現真的有這個問題,就可以自信滿滿地拿出解決方案來快速解決。這或許就是我在這段旅程中期待看到的,從 0 開始長出經驗的過程。
明天我們要來完成採集草藥和背包的狀態機,這趟旅程終於快要結束啦,你期待最後的成品嗎?我們明天見!
如果你想要瀏覽完整的程式碼,可以參考這裡:第 26 天程式碼