iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
JavaScript

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

Day25 RPG3:用狀態機來完成切換地圖!

  • 分享至 

  • xImage
  •  

到目前為止,我們已經做好了ㄧ個村莊的地圖,今天要做到是使用者可以藉由 % 這個入口從村莊移動到森林,也可以從森林移動回村莊。

那就如同我們在第 23 天討論到的,這個 RPG 遊戲的主軸是練習狀態機,所以我們就先來試著畫出地圖切換的狀態機吧!

畫出狀態機

https://ithelp.ithome.com.tw/upload/images/20261008/20182439lSzywLarEl.png

在這個狀態機的流程當中,我希望當玩家抵達 % 的時候觸發轉換,離開狀態的時候要清除該地圖,轉換時要顯示「地圖轉換中」的文字,接著要印出目標地圖,並將使用者顯示於 % 的位置。

那麼我們就按照這個狀態機來拆解步驟:

  1. 玩家移動之後判斷是否抵達 %
  2. 清除畫面
  3. 印出地圖轉換中
  4. 清除畫面
  5. 印出新的地圖

在這裡關鍵會在新的地圖怎麼產生,這邊我比較沒有概念,不過也沒關係,總之就先寫看看!

決定要把邏輯寫在哪裡

首先我找到 movePlayer 這個函式,打算在每一次移動之前判斷下一個位置是不是 % ,然後就卡住了XD

function movePlayer(dx, dy) {
    // 藉由傳入的 dx 和 dy 來定位下一個座標,這樣我們就知道要往哪邊走
    const nextX = player.x + dx; // 下一個 x  
    const nextY = player.y + dy; // 下一個 y 

    if (map[nextY][nextX] === ' ') {
        player.x = nextX; // player 的座標移動到下一個位置
        player.y = nextY;
    } else if (map[nextY][nextX] === '%') {
        // 這裡應該要做什麼?

    }
}

我寫完這段才想到:執行 movePlayer 移動玩家座標之後,才會執行 render 去渲染畫面,也就是說如果我在這裡就轉換地圖,會導致使用者看不見角色移動到 % 上,地圖就被轉換了。

而且這邊是使用 map 來判斷前面是不是可以走,那如果你記得的話 map 裡面其實只有牆壁 #而沒有 % 或是 P ,所以這裡實際上是沒有辦法這樣判斷的。

所以再來觀察看看放在哪邊更好,在前面的遊戲裡面,勝負判斷之後印出結果都是在監聽器執行 render 之後,如果放在這邊呢?這樣就可以確保一定可以看到使用者移動到 % 上面。

function handleRPGInput(key) {

        //前面不動

        // 監聽器內四個方向換上這個邏輯
        if (key === '\x1bA') {
            movePlayer(0, -1)
        }
        if (key === '\x1b[B') {

            movePlayer(0, 1)
        }
        if (key === '\x1b[C') {

            movePlayer(1, 0)
        }
        if (key === '\x1b[D') {
            movePlayer(-1, 0)
        }

        // 重新渲染
        render();

        // player 是否在 % 上

        // 是的話就清除畫面

        // 顯示地圖轉換中
    }

感覺也許可行,先寫到可以印出「地圖轉換中」就好。

       // 重新渲染
        render();

        // player 是否在 % 上
        if (player.x === exit.x && player.y === exit.y) {

        // 是的話就清除畫面
        process.stdout.write(`\x1b[${viewHeight}A`);
        process.stdout.write('\x1b[J');
        
        // 顯示地圖轉換中
        process.stdout.write(`\x1b[32m地圖轉換中...\x1b[0m`);

        }

試著執行看看:

1

可以耶!!那麼再來就是要讓這個轉換中的文字持續個一秒鐘,再切換到新的地圖。

        // 顯示地圖轉換中
        process.stdout.write(`\x1b32m地圖轉換中...\x1b[0m`);
        // 一秒之後
        setTimeout(()=>{
            // 清空畫面
            process.stdout.write('\r'); // 游標回到該行行首
            process.stdout.write('\x1b[J');
            // 推出畫面高度並渲染畫面
            process.stdout.write("\n".repeat(viewHeight));
            render()
        },1000)

這邊要注意,因為 process.stdout.write(\x1b[${viewHeight}A); 的作用是將游標上移,但是這邊我們只有要回到行首,因此我們使用 \r 就可以了,執行看看:

2

這樣我們就做到將地圖切換出去再切換回來啦,不過到這裡為止都還沒有真正切換地圖!

地圖切換

寫到這邊其實狀態機內的 action 寫差不多了,我想我們應該終於可以來加一個狀態切換的邏輯了:

setTimeout(()=>{
            process.stdout.write('\r'); // 游標回到該行行首
            process.stdout.write('\x1b[J');
            //轉換地圖
            currentMap = currentMap === 'village' ? 'forest' : 'village'; // 這裡加這一行
            process.stdout.write("\n".repeat(viewHeight));
            render()
        },1000)

然後記得要去制定這個變數用來儲存狀態,初始化的時候是在村莊內。

// 當前地圖
let currentMap = 'village';

從觀察開始

接下來想帶大家來觀察一下 render 函式中的這一段:

for (let y = 0; y < map.length; y++) {

        if(y < canvaMinY || y > canvaMaxY) {
            continue;
        }

        let row = ''

        for (let x = 0; x < map[y].length; x++) {

            if(x < canvaMinX || x > canvaMaxX) {
                continue;
            }

            // 小優化,把常用的寫法儲存成變數
            const cell = map[y][x]

            if (player.x === x && player.y === y) {
                row += `\x1b[33m@\x1b[0m`
            } else if (npc.x === x && npc.y === y) {
                row += `\x1b[34mP\x1b[0m`
            } else if (exit.x === x && exit.y === y) {
                row += `\x1b[37m%\x1b[0m`
            } else {
                row += `\x1b[32m${cell}\x1b[0m`
            }

        }

        //把這行儲存進去 frame,並記得結尾換行
        frame += row + '\n'
    }

從這裡我們知道 render 是透過 map 來印出 # 的位置,其他的物件則是直接由變數來印出,那麼如果我們要替換掉改成如下的地圖:

const forest = [
        '##############################',
        '#           TTT  H           #',
        '#           TTTTT   H        #',
        '#              TTTT    TTT   #',
        '#     T     TTTTT      TTT   #',
        '#%    TT               TTT   #',
        '#     TTT H      TTTTTH      #',
        '#     TTTT       TTT         #',
        '##############################',
    ]

我們就會需要

  1. 不去計算內部牆壁
  2. 隱藏掉其他的物件
  3. 只顯示 T 和 H
  4. 修改 % 的位置

此時一樣,我們先用地圖計算出所有物件的位置好了,這個比較容易一點。

function createForest() {
    let walls = [];
    let trees = [];
    let herbs = [];
    let exit;

    // 之後換地圖只要改這裡
    const forest = [
        '##############################',
        '#           TTT  H           #',
        '#           TTTTT   H        #',
        '#              TTTT    TTT   #',
        '#     T     TTTTT      TTT   #',
        '#%    TT               TTT   #',
        '#     TTT H      TTTTTH      #',
        '#     TTTT       TTT         #',
        '##############################',
    ]

    for (let y = 0; y < forest.length; y++) {

        for (let x = 0; x < forest[y].length; x++) {

            if (forest[y][x] === '#') {
                walls.push({ x: x, y: y })

            }
            if (forest[y][x] === 'T') {
                trees.push({ x: x, y: y })
            }
            if (forest[y][x] === 'H') {
                herbs.push({ x: x, y: y })
            }
            if (forest[y][x] === '%') {
                exit = { x, y }
            }
        }

    }

    return { walls, trees, herbs, exit };
}

// 森林物件
const forest = createForest();
const trees = forest.trees;
const herbs = forest.herbs;
const forestExit = forest.exit;
const forestWalls = forest.walls;

這邊比照原本村莊的寫法,計算出所有的物件,再來就剩下切換的邏輯了,試著寫看看,第一版一定超級醜,但也沒關係,至少是我思考出來的結果。

首先先分別計算出兩個地圖:

const villageMap = createMap(30, 9, villageWalls);
const forestMap = createMap(30, 9, forestWalls);

然後在 render 裡去切換,把原本的 map 替換成剛剛計算出來的地圖(很醜我知道啦,先寫看看嘛):

if (currentMap === 'village') {

        // 每次開始 render 前先重算一次
        resetCameraVertex( villageMap );
        const canvaMinX = cameraVertex.x;
        const canvaMaxX = cameraVertex.x + viewWidth - 1;
        const canvaMinY = cameraVertex.y;
        const canvaMaxY = cameraVertex.y + viewHeight - 1;
        player.x === villageExit.x;
        player.y === villageExit.y;

        for (let y = 0; y < villageMap.length; y++) {

            if (y < canvaMinY || y > canvaMaxY) {
                continue;
            }

            let row = ''

            for (let x = 0; x < villageMap[y].length; x++) {

                if (x < canvaMinX || x > canvaMaxX) {
                    continue;
                }

                // 小優化,把常用的寫法儲存成變數
                const cell = villageMap[y][x]

                if (player.x === x && player.y === y) {
                    row += `\x1b[33m@\x1b[0m`
                } else if (npc.x === x && npc.y === y) {
                    row += `\x1b[34mP\x1b[0m`
                } else if (villageExit.x === x && villageExit.y === y) {
                    row += `\x1b[37m%\x1b[0m`
                } else {
                    row += `\x1b[32m${cell}\x1b[0m`
                }

            }

            //把這行儲存進去 frame,並記得結尾換行
            frame += row + '\n'
        }

    }else if (currentMap === 'forest') {
        // 每次開始 render 前先重算一次
        resetCameraVertex( forestMap );
        const canvaMinX = cameraVertex.x;
        const canvaMaxX = cameraVertex.x + viewWidth - 1;
        const canvaMinY = cameraVertex.y;
        const canvaMaxY = cameraVertex.y + viewHeight - 1;
        player.x === forestExit.x;
        player.y === forestExit.y;

        for (let y = 0; y < forestMap.length; y++) {

            if (y < canvaMinY || y > canvaMaxY) {
                continue;
            }

            let row = ''

            for (let x = 0; x < forestMap[y].length; x++) {

                if (x < canvaMinX || x > canvaMaxX) {
                    continue;
                }

                // 小優化,把常用的寫法儲存成變數
                const cell = forestMap[y][x]

                if (player.x === x && player.y === y) {
                    row += `\x1b[33m@\x1b[0m`
                } else if (trees.x === x && trees.y === y) {
                    row += `\x1b[36mT\x1b[0m`
                } else if (herbs.x === x && herbs.y === y) {
                    row += `\x1b[35mH\x1b[0m`
                } else if (forestExit.x === x && forestExit.y === y) {
                    row += `\x1b[37m%\x1b[0m`
                } else {
                    row += `\x1b[32m${cell}\x1b[0m`
                }

            }

            //把這行儲存進去 frame,並記得結尾換行
            frame += row + '\n'
        }
    }

對應到其他有使用到 map 的地方也都要用 currentMap 去重算,這兩個地方:

function movePlayer(map,dx, dy) { }

function resetCameraVertex( map ) { }

最後是監聽器這邊

function handleRPGInput(key) {

        // 制定兩個內部變數來取代原本外層的 map 和 exit
        let map = [];
        let exit;
        
        // 用 currentMap 計算出來
        if (currentMap === 'village'){
            map = villageMap;
            exit = villageExit;
        }else if (currentMap === 'forest') {
            map = forestMap;
            exit = forestExit;
        }

        // 中間省略

        if (key === '\x1bA') {
            movePlayer(map,0, -1) // 這裡要加上 map
        }
        if (key === '\x1b[B') {

            movePlayer(map,0, 1)
        }
        if (key === '\x1b[C') {

            movePlayer(map,1, 0)
        }
        if (key === '\x1b[D') {
            movePlayer(map,-1, 0)
        }
        
        // 後面一樣

執行看看:

3

問題修復

前面我們雖然可以轉換地圖了,可是我發現了幾個問題:

  1. forest 內部的物件沒有印出來
  2. player 切換到 forest 時的位置不是在指定的 % 的位置
  3. 重複多按幾次之後會破版,而且奇怪的是此時 player 的位置就有在指定位置了

再重看一次程式碼,發現 player 指定給 % 這個位置,不應該放在 render 的開頭,因為這樣會導致每次重新渲染 forest 都會一再的將 player 設定回 % 的位置,所以我們應該把這一段改放到監聽器那邊,因為這是整個狀態機的其中一環,流程上是接在地圖轉換之後、重新渲染之前這裡會比較順,來看看我們加上的這兩段:

setTimeout(() => {
                process.stdout.write('\r');
                process.stdout.write('\x1bJ');
                currentMap = currentMap === 'village' ? 'forest' : 'village';
                
                // 重新計算 exit 和 map
                if (currentMap === 'village') {
                    map = villageMap;
                    exit = villageExit;
                } else if (currentMap === 'forest') {
                    map = forestMap;
                    exit = forestExit;
                }
                // 指定 player 位置到新的 exit
                player.x = exit.x;
                player.y = exit.y
                
                process.stdout.write("\n".repeat(viewHeight));
                render()
            }, 1000)

現在來試玩看看:

4

這樣玩家出現在指定的位置了!!沒錯,這樣才會有從右邊離開村莊,並且進入到森林的左邊的感覺嘛,這樣方向才對。

接著檢查 forest 物件為什麼沒印出來,有沒有發現村莊的物件 NPC 其實是單一座標?但是森林物件不管是 trees 還是 herbs 都是陣列,所以當然比對不到,我們必須要把 render改成爆爆王也有寫過的陣列寫法:

                if (player.x === x && player.y === y) {
                    row += `\x1b33m@\x1b[0m`
                } else if (trees.some(tree => tree.x === x && tree.y === y)) {
                    row += `\x1b[36mT\x1b[0m`
                } else if (herbs.some(herb => herb.x === x && herb.y === y)) {
                    row += `\x1b[35mH\x1b[0m`
                } else if (forestExit.x === x && forestExit.y === y) {
                    row += `\x1b[37m%\x1b[0m`
                } else {
                    row += `\x1b[32m${cell}\x1b[0m`
                }

測試一下:

5

可以了!!現在我們可以成功轉換地圖啦。

至於「重複多按幾次之後會破版」的問題,我也在多次的實驗中發現,原來是因為監聽器的最後都一定會接上一個 render ,因此會打破我們原本訂好的轉換畫面,而直接印出新的遊戲畫面造成破版。

可以用我們在共用入口的時候用過的方法 process.stdin.off('data', handleRPGInput); 來關掉轉換期間的監聽:

// player 是否在 % 上
        if (player.x === exit.x && player.y === exit.y) {

            // 在這裡關掉監聽
            process.stdin.off('data', handleRPGInput);
            
            // 省略

            setTimeout(() => {
                // 省略
                render() // 渲染地圖之後再開啟監聽
                process.stdin.on('data', handleRPGInput);
            }, 1000)

        }

另外我還發現理論上 T 是樹,樹不能夠走的,那要怎麼樣阻止 player 去移動到樹上呢?判斷能不能往前一格的邏輯在 movePlayer

function movePlayer(map, dx, dy) {
    const nextX = player.x + dx; // 下一個 x  
    const nextY = player.y + dy; // 下一個 y 
    
    // 加上這段判斷,如果位於森林且下一個位置是樹,就截斷函式
    const isTree = trees.some(tree => tree.x === nextX && tree.y === nextY);
    if (currentMap === "forest" && isTree){
        return;
    }
    
    if (map[nextY][nextX] === ' ') {
        player.x = nextX; // player 的座標移動到下一個位置
        player.y = nextY;
    }
}

最後再玩一次:

6

完成了!!!那麼明天我們要來做 NPC 對話!你也可以試著畫畫看對話的狀態機,我們明天見!

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


上一篇
Day24 RPG2:為了夢想與希望!用力思考得出的攝影機跟隨效果
下一篇
Day26 RPG4:在完成 NPC 對話的過程中,看見經驗在發酵
系列文
重新拿回思考力!一起來用 JavaScript 打造 CLI 好玩遊戲區! 共 27 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言