iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
JavaScript

重新拿回思考力!一起來用 JavaScript 打造 CLI 好玩遊戲區!系列 第 26 篇

Day26 RPG4:在完成 NPC 對話的過程中,看見經驗在發酵

  • 分享至 

  • xImage
  •  

經過昨天的努力,我們已經可以自由的在兩個地圖中移動了,不過我們還需要可以跟 NPC 對話。

如果你玩過 RPG 遊戲的話,要開啟對話,通常都是移動到NPC 的附近之後,按按鈕啟動對話,所以我的想法也是這樣:

  1. 當使用者移動到 P 相鄰空位的時候,按空白鍵開啟對話。
  2. 對話時清除地圖畫面,改渲染遊戲畫面如下:
村民:
年輕人,我們這裡很久沒有人來了。

你:
A: 你看起來不舒服,需要幫忙嗎?
B: 看起來是這樣,那我走啦。
  1. 當使用者選擇 A ,就進入下一段對話
村民:
是的,我這是老毛病了,你願意到村子外面的森林幫我採點草藥嗎?

你:
A: 當然可以。
B: 抱歉,我趕時間。
  1. 當使用者選擇 A 的時候,顯示「已接受任務」,並且右側要顯示任務數量:1。
  2. 當使用者在這兩段選擇 B 的時候就關閉對話,回到地圖畫面。

今天我想先實作到這裡,之後再來處理接下任務之後回報的流程。

繪製狀態機

那麼我們要來想想這個狀態機該如何設計,本來想直接開始畫圖,不過我在打開白板的時候發現我都還沒想清楚有哪些狀態呢XD 回到從狀態的擬定開始吧:

  1. status 狀態:
    • 尚未開始對話
    • 第一句對話
    • 第二句對話
    • 對話完成:這邊不回到尚未開始,因為任務已經接下來了
  2. event 事件:
    • 玩家按下空白鍵,從尚未開始對話,轉到第一句對話
    • 玩家按下 B ,從第一句對話或從第二句對話轉到尚未開始對話
    • 玩家按下 A,從第一句對話轉到第二句對話,或從第二句對話轉到對話完成

到這邊有點感覺了喔,action 就到白板裡面去畫好了,給大家看一下我畫好的樣子:

https://ithelp.ithome.com.tw/upload/images/20261009/20182439o2hKiGoTpH.png

拆解步驟

既然繪製完成,感覺經歷昨天的洗禮,現在已經蠻知道清除畫面該怎麼做了,那我們就一樣從拆解步驟開始吧:

  1. 儀表板要先加上任務數字,初始化 0
  2. 監聽玩家按下空白鍵,判斷玩家是否位於 P 的四週
  3. 是的話,修改狀態,清除地圖畫面,印出對話內容
  4. 此時如果在印出對話內容的時候按其他按鍵,應該會像之前那樣又印出地圖畫面吧!所以需要阻止 render 的執行,似乎可以用對話的狀態來判斷要不要執行 render ,這只是我預想的,之後實際寫看看
  5. 監聽玩家按下 A ,判斷當前位於第一句對話還是第二句對話
  6. 是第一句對話,修改狀態、清除畫面,印出第二句對話
  7. 是第二句對話,修改狀態、清除畫面,印出已接下任務
  8. 一秒之後清除已接下任務,印出地圖

實作

一邊實作一邊會發現有很多要新增的細節,我就逐一把步驟寫下來

  1. 儀表板加上任務數字,初始化 0
// 遊戲物件的區塊要記得加上這個變數,預設是 0 
let task = 0;
// 加上這一段,在 render y 迴圈的最底下
// 村莊和森林都要加
if (y === canvaMinY){
   row += ` 任務:${task}`;
}
  1. 監聽空白鍵
// 監聽器內加上這句
if (kye === ' ') {
   openNPCTalk ();
   return; // 避免 render 繼續執行,印出地圖
}
  1. 判斷玩家是否在 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) {
        // 這裡要加上後續的邏輯
    }
}
  1. 制定狀態和文字內容,在物件區加上這一段
let npcStatus = 'notStarted'; // notStarted -> firstTalk -> SecondTalk -> completed
const firstTalk = [
    '村民:',
    '年輕人,我們這裡很久沒有人來了。',
    '',
    '你:',
    'A: 你看起來不舒服,需要幫忙嗎?',
    'B: 看起來是這樣,那我走啦。'
]
const secondTalk = [
    '村民:',
    '是的,我這是老毛病了,你願意到村子外面的森林幫我採點草藥嗎?',
    '',
    '你:',
    'A: 當然可以。',
    'B: 抱歉,我趕時間。'
]
  1. 清除地圖並印出對話畫面,這邊我選擇用跟 render 一樣的形式,先組合出 frame 再印出對話,這樣之後只要維護 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);//印出
        
    }

到這邊我們先印出來看看,畫面轉換是否有成功吧

1

成功了!但如同我們猜測的那樣,再按一次任意方向鍵之後會把地圖重新印出來,那我們就繼續往下寫:

  1. 如果狀態在 firstTalk 或 secondTalk 就阻擋 render 印出地圖
// 加在 render 開頭
if(npcStatus === 'firstTalk' || npcStatus === 'secondTalk') {
        return;
    }

實測之後確實按下方向鍵就沒反應了

  1. 如果按下 B 就切換狀態並回到地圖:這次的比較簡單,我就直接在監聽裡面完成
        // 按下 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;
        }

2

測試之後確實可以回到遊戲畫面!

  1. 按下 A 之後切換到第二段對話
// 新增監聽
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();

    }
}

這裡順便把第二段對話到完成一起做完了,現在來玩看看吧

3

這裡我總共測試了三個情境:

  • 第一段對話按 B 關掉對話回到地圖,重新打開從第一段對話開始
  • 第二段對話按 B 關掉對話回到地圖,重新打開從第一段對話開始
  • 一路按 A 最後回到地圖,這時就打不開對話了

這三個狀態都有切換正確!

剩下最後要來更新任務數字:

//pushNPCStatus 內的 secondTalk 區塊
else if (npcStatus === "secondTalk") {
        npcStatus = "completed";
        task+= 1; // 加上這句
        
        // 其他不變
    }

4

我們今天的任務終於完成啦!!今天寫起來總覺得特別輕鬆,我覺得是因為前面幾天花了很多功夫在練習設計狀態機、拆解步驟、重新渲染地圖等等的邏輯,所以寫到這裡,其實已經都是前面用過的概念和方法了。

這就是我先前說的吧,前期會走得很緩慢,但是逐步累積經驗之後,甚至開始可以預測可能的問題在哪邊,然後去實測發現真的有這個問題,就可以自信滿滿地拿出解決方案來快速解決。這或許就是我在這段旅程中期待看到的,從 0 開始長出經驗的過程。

明天我們要來完成採集草藥和背包的狀態機,這趟旅程終於快要結束啦,你期待最後的成品嗎?我們明天見!

如果你想要瀏覽完整的程式碼,可以參考這裡:第 26 天程式碼


上一篇
Day25 RPG3:用狀態機來完成切換地圖!
下一篇
Day27 RPG6:撿拾藥草到背包的狀態機練習
系列文
重新拿回思考力!一起來用 JavaScript 打造 CLI 好玩遊戲區! 共 27 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言