iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0
JavaScript

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

Day4 推箱子3:用自己的語言來描述,讓角色可以被操控吧!

  • 分享至 

  • xImage
  •  

到目前為止我們都在講要如何讓畫面自己動起來,但是遊戲的重點還是要有玩家操作,因此今天我們最主要的目標是要讓玩家可以操作角色動起來,與此同時我們也持續在練習藉由思考來逐步完成專案的方法:

  • 第二天我們練習拆解目標並逐步實作出來
  • 第三天我們試著不一開始就找到最佳解,而是藉由逐步的試錯慢慢找到更好的寫法

今天我們要練習的是試著用自己的語言來描述邏輯,再來轉換為程式語言。不過在進到那一段之前,我們還是要先補一段關於 Terminal 的重要知識作為我們這個功能的基礎建設,馬上開始吧!

監聽鍵盤輸入

首先先幫大家整理一些關於 Terminal 的筆記:

process 是 node 執行程序的一個全域物件,每一個程序開始時就會被自動建立,有點像是管家,管理內部狀態並掌握和外部溝通的方法
stdout Standard Output 標準輸出,將結果印在畫面上,我們至今為止都一直在使用這個功能
stdin Standard Input 標準輸入,從鍵盤或檔案讀取資料

我們先把最下面的計時器整個拿掉,換成這一段

// stdin 為了節省資源,預設是暫停的,因此我們需要先喚醒才能使用
process.stdin.resume();
// 把監聽到的資料轉換成我們看得懂的語言
process.stdin.setEncoding('utf8');
// 接收到這些狀況的時候被觸發,'data' 指有新資料進來的時候
process.stdin.on('data', (key) => {
    // 傳入 key 觸發 後面的事件 
    if (key === '\x1b[A') player.y -= 1; // 上
    if (key === '\x1b[B') player.y += 1; // 下
    if (key === '\x1b[C') player.x += 1; // 右
    if (key === '\x1b[D') player.x -= 1; // 左
    // 重新渲染
    render();
});

這邊要注意一下方向性的問題,向左是減,向右是加這個符合一般的使用習慣,沒問題,但上下的方向就不那麼直覺了,因為 terminal 通常都是往下遞增,當我們打字、輸入enter,就會向下增加行數,也因此在 terminal 中的座標 向上是減,向下是加

結果我們發現每次按按鍵,都會直接在 terminal 上印出文字,或是點擊 enter 就會多一排 ######## ,這是因為 stdin 預設是直接顯示文字,而 enter 的作用就是把這一行文字送出給 node ,同時帶有 \n 的作用,所以會將文字向下推

因此我們需要去轉換成 raw mode 讓他自動將每一個按鍵都回傳給 node


process.stdin.resume();
process.stdin.setEncoding('utf8');
// 加入這一段,讓他能正確地把資料傳給 node
process.stdin.setRawMode(true);

process.stdin.on('data', (key) => {
    if (key === '\x1b[A') player.y -= 1; 
    if (key === '\x1b[B') player.y += 1;
    if (key === '\x1b[C') player.x += 1;
    if (key === '\x1b[D') player.x -= 1;
    render();
});

接著我們就發現 player 可以動了! 但是很快的衍伸出兩個問題

  1. player 可以超出畫面的範圍移動
  2. 關不掉遊戲ㄚㄚㄚ

為了解決關不掉遊戲的問題,請簡單的在 process.stdin.on 內加上逃生出口

// 如果按下字母 'q' 或是 'Q',就正式退出遊戲
    if (key === 'q' || key === 'Q') {
        console.log("\n遊戲結束,謝謝遊玩!");
        process.exit();
    }

    // 如果按下 Ctrl + C (在 Raw Mode 下對應的編碼是 '\x03'),這是強制退出遊戲
    if (key === '\x03') {
        process.exit();
    }

接著來處理超出畫面的問題,在移動之前先判定下一步是不是牆壁,如果是的話,就停在原地不動

    if (key === '\x1b[A') {
        if (map[player.y - 1][player.x] !== '#') player.y -= 1;
    }
    if (key === '\x1b[B') {
        if (map[player.y + 1][player.x] !== '#') player.y += 1;
    }
    if (key === '\x1b[C') {
        if (map[player.y][player.x + 1] !== '#') player.x += 1;
    }
    if (key === '\x1b[D') {
        if (map[player.y][player.x - 1] !== '#') player.x -= 1;
    }

接著來一題小考啦,要如何做到推動箱子呢?

練習用自己的話來表達邏輯

今天要處理的剩下兩件事情

  1. 推動箱子,一樣要避免箱子和角色超出牆壁
  2. 箱子推進目標點就結束遊戲並判定獲勝

大家有想法了嗎?

先試著用自己的語言寫下來吧

推動箱子,一樣要避免箱子和角色超出牆壁

自己的語言:

  1. 如果 player 的下一格是 box → 判斷下下一格是不是牆壁,不是牆壁的話就同時讓 player 和 box 前進一格;如果是牆壁就停在原地
  2. 如果 player 的下一格是 # → 停在原地
if (key === '\x1b[A') {
        // 如果 player 的下一格是 box,進入更細一層的判定
        if (player.y - 1 === box.y && player.x === box.x) {
            // 下下一格不是牆壁的話才執行
            if (map[player.y - 2][player.x] !== '#') {
                player.y -= 1
                box.y -= 1
            }
            // 是牆壁的話,靜默失敗就可以,什麼都不用寫

        } else if (map[player.y - 1][player.x] !== '#') {
            player.y -= 1
        }
    }

以此類推,把每一個方向都寫完吧

if (key === '\x1b[A') {
        // 判定是否為
        if (player.y - 1 === box.y && player.x === box.x) {

            if (map[player.y - 2][player.x] !== '#') {
                player.y -= 1
                box.y -= 1
            }

        } else if (map[player.y - 1][player.x] !== '#') {
            player.y -= 1
        }
    }
    if (key === '\x1b[B') {
        
        if(player.y + 1 === box.y && player.x === box.x){

            if (map[player.y + 2][player.x] !== '#') {
                player.y += 1
                box.y += 1
            }

        }else if (map[player.y + 1][player.x] !== '#') player.y += 1;
    }
    if (key === '\x1b[C') {

        if(player.y === box.y && player.x + 1 === box.x){

            if (map[player.y][player.x + 2] !== '#') {
                player.x += 1
                box.x += 1
            }

        }else if (map[player.y][player.x + 1] !== '#') player.x += 1;
    }
    if (key === '\x1b[D') {
        if(player.y === box.y && player.x - 1 === box.x){

            if (map[player.y][player.x - 2] !== '#') {
                player.x -= 1
                box.x -= 1
            }

        }else if (map[player.y][player.x - 1] !== '#') player.x -= 1;
    }

箱子推進目標點就結束遊戲並判定獲勝

用自己的話來說:

當 box 的座標等於 goal 座標,結束遊戲並印出獲勝

//注意這邊必須先渲染出最後一個畫面
render()

//這時才能印出恭喜獲勝
if (box.y === goal.y && box.x === goal.x){
        console.log("\n恭喜獲勝");
        process.exit();
    }

遊戲體驗優化:失敗判定

遊玩的時候我們會發現一但將箱子推到牆角邊,就沒救了,沒有機會再把他從牆邊推出來,但此時遊戲尚未結束,我們只能自行離開,因此來做一個小小的遊戲體驗優化,就是當箱子進入到四個牆角,遊戲就自動結束。

這邊一樣練習用自己的話來說,可以先把步驟寫成註解,再把對應的程式碼填進去,直接給大家看看寫好的完整版:

    //判斷box的上方是否是牆壁
    const top    = map[box.y - 1][box.x] === '#';
    //判斷box的下方是否是牆壁
    const bottom = map[box.y + 1][box.x] === '#';
    //判斷box的左方是否是牆壁
    const left   = map[box.y][box.x - 1] === '#';
    //判斷box的右方是否是牆壁
    const right  = map[box.y][box.x + 1] === '#';
    //如果上方或下方有其一,且左方或右方有其一,就表示位於牆角
    const isDeadlock = (top || bottom) && (left || right);
    
    // 因為這段加在勝負判定後面,所以不用再次防勝負判定,不過也可以寫一下
    if (isDeadlock) {
        process.stdout.write("\n箱子卡住了,遊戲結束\n");
        process.exit();
    }

這時候嘗試執行就會發現已經可以玩了,是不是很興奮呢!!
可以玩了

幫遊戲換個顏色吧

其實我們今天最主要的任務已經完成了,不過最後我們也來認識一下該如何在 Terminal 中為字體上色當作是今天的 bonus!先來看看這段簡單的 console.log

console.log("\x1b[31m%s\x1b[0m", "危險!怪物出現了!");

印出來的畫面會像這樣
https://ithelp.ithome.com.tw/upload/images/20260917/20182439yAPGsGqWaO.png

console.log 兩個參數,第一個參數是樣式與模板,第二個參數是要帶入的文字內容

\x1b 特殊字元 ESC,目的在告訴 terminal 這段話不要印出來,這段是控制指令

[31m 前景變紅色

%s 文字字串佔位符,會把後面的字串參數帶入進來,當然這邊也可以直接寫文字

[0m 重設樣式,讓後面的文字可以變回原本的顏色,為了驗證這個是不是有效,我們也可以試著加入第二段文字

console.log("\x1b[31m%s\x1b[0m%s", "危險!怪物出現了!","變為原本的顏色");

https://ithelp.ithome.com.tw/upload/images/20260917/20182439CUpONkOe92.png

接著再試試看把 \x1b[0m 這段拿掉,看看顏色是否維持在紅色

console.log("\x1b[31m%s%s", "危險!怪物出現了!","變為原本的顏色");

https://ithelp.ithome.com.tw/upload/images/20260917/201824390Fv7ttrM1I.png

大致的了解 Terminal 的顏色規則之後,我們來看看有哪些常用的顏色:

  • 31 紅色
  • 32 綠色
  • 33 黃色
  • 34 藍色
  • 35 紫色
  • 36 青色

因此我們可以試著在 render 中加入顏色,並且記得每一個字之後都要重置回來,才不會影響到後面的文字

            if (player.x === x && player.y === y) {
                row += `\x1b[33m@\x1b[0m`
            } else if (box.x === x && box.y === y) {
                row += `\x1b[34m$\x1b[0m`
            } else if (goal.x === x && goal.y === y) {
                row += `\x1b[31m.\x1b[0m`
            } else if (cell === '@' || cell === '$' || cell === '.') {
                row += ' '
            } else {
                row += `\x1b[32m${cell}\x1b[0m`
            }

這樣就可以看到上完色的遊戲畫面啦
https://ithelp.ithome.com.tw/upload/images/20260917/20182439ixP4nrpBx0.png

那麼以上就是今天的內容啦,不過你是不是也有發現我們現在不管怎麼打開都是同一個畫面呢?這樣這個遊戲根本就像是一次性的衛生紙而已嘛,用完一次就沒了!完全沒有能將玩家留下來一直持續玩下去的設計,所以明天我們就要來改造這個重要的體驗,你對於這個遊戲有哪些設計上的想法呢?可以試著先列出來,也歡迎留言在下面討論唷。

如果你想要看完整的程式碼,可以參考這裡:Day4 程式碼


上一篇
Day3 推箱子2:從最爛的方法開始!來寫個有座標的地圖吧!
下一篇
Day5 推箱子4:跟隨使用者的體驗來打造隨機地圖!
系列文
重新拿回思考力!一起來用 JavaScript 打造 CLI 好玩遊戲區!7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言