iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
JavaScript

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

Day11 爆爆王 3:從監聽動作到儀表板印出,Terminal 環境小複習

  • 分享至 

  • xImage
  •  

由於後續的炸彈爆炸和 AI 敵人的製作都會涉及到監聽玩家動作以及顯示玩家血量值、敵人總數的儀表板,因此我決定在進入炸彈爆炸之前,要先來完成這兩件事。而這兩件事會大量碰到的就是踩到一堆 Terminal 環境的坑,我們就一起來複習一下 Terminal 的環境,一邊來打造監聽玩家動作和儀表板吧!

監聽玩家動作

這個其實就是複製先前推箱子移動的程式碼來使用,首先別忘了這三句設定。

process.stdin.resume();
process.stdin.setEncoding('utf8');
process.stdin.setRawMode(true);

這裡我想再補充一下,由於在撰寫過程中發現我對於這三段的理解不一定全面,因此想藉此重新說明這三句,也訂正一下前面的說法:

  1. process.stdin.resume() :一開始我們打開 terminal 的時候可以輸入文字,然後執行了 node.js 之後啟動遊戲,就沒有辦法自由輸入文字了,我的理解是 node.js 接管 terminal 的時候監聽預設是暫停的,我前面說這是跟節省資源有關,這邊需要修正一下,應該說是因為絕大多數的 node.js 腳本都不需要和使用者在終端機互動,另一個部分是鍵盤輸入會打斷程式執行的生命週期,因此才會預設暫停,總之為了把這個預設暫停的監聽打開來,所以我們需要用 resume 喚醒,好讓 node.js 開始去監聽鍵盤的輸入。

  2. process.stdin.setEncoding('utf8'):這段就一樣是把輸入的資料整理成我們可以讀得懂的語言。

  3. process.stdin.setRawMode(true):原本我的說法是讓他正確的傳資料給 node.js,但沒講我正確和不正確的定義,其實也不應該用正確與否來解釋這段話。這句真實的功能是把原本需要按 enter 才會送出的資料,變成每按一個按鍵都會立刻送出,才更符合即時遊戲的需求。

理解了上面三段設定之後,接著來寫監聽器,清除掉不必要的邏輯變成這個單純的版本:

// 接收到這些狀況的時候被觸發,'data' 指有新資料進來的時候
process.stdin.on('data', (key) => {

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

    // 如果按下 Ctrl + C 強制退出遊戲
    if (key === '\x03') {
        process.exit();
    }

    // 四個方向
    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();

    
});

接著我想保留 movePlayer 這個設計,利用共用函式來達成移動的效果,所以我們來補上 movePlayer。

雖然說推箱子遊戲的時候,已經有寫過一次,但那一次是 AI 幫忙做的,我這次想再練習一次以終為始的來設計這個函式,所以再畫一次 input 、output 圖來幫助我們釐清這個函式的目標:

https://ithelp.ithome.com.tw/upload/images/20260923/20182439TpBF46uwqt.png

那我們就接著寫邏輯吧!但當然還是可以參考一下之前的函式,不然也太累了XD

function movePlayer(dx, dy) {
    const nextX = player.x + dx; // 下一個 x  
    const nextY = player.y + dy; // 下一個 y 

    // 這裡我只有要判斷是否有箱子,所以改名稱為 hasBox
    const hasBox = boxes.find(box => box.x === nextX && box.y === nextY);

    // 前面沒有箱子
    if (!hasBox) {
        // 如果前方也是空的
        if (map[nextY][nextX] === ' ') {
            player.x = nextX; // player 的座標移動到下一個位置
            player.y = nextY;
        }
        return;
    }
    
    // 後面兩個箱子的判斷邏輯用不到,直接移除
}

這裡也幫大家複習一下

  1. map (雙重陣列):有所有外牆和遊戲內牆壁的位置,表示方式是 map[y][x]

  2. boxes (陣列包物件):記錄所有箱子的座標,完整會長像下方這樣,因此如果要表示第一個箱子的座標,就會是 boxes[0].x 和 boxes[0].y

    [
      { x: 5, y: 4 },
      { x: 3, y: 1 },
      { x: 13, y: 1 },
      { x: 13, y: 7 },
      { x: 13, y: 5 },
      { x: 11, y: 2 },
      { x: 7, y: 4 },
      { x: 5, y: 3 },
      { x: 12, y: 3 },
      { x: 9, y: 7 }
    ]
    

最後我們印出來看看是不是有成功移動了

day11監聽玩家移動

這邊可以覆蓋到敵人是正常的,因為我們剛剛只寫了牆壁和箱子不可穿越的邏輯。關於遇到敵人應該怎麼處理,我們在後續的章節會再繼續來完成。

儀表板

接著我們來完成儀表板的設計,我預想這個可以放在遊戲畫面的最上方,觀察原始的 render 之後,我們回想起來,實際印出畫面的是這兩句,前面都只是在把畫面拼出來

process.stdout.write(`\x1b${map.length}A`); // 回到開頭
process.stdout.write(frame); // 印出畫面

所以我們在這兩句中間加上儀表板的佔位文字印出來試試看

process.stdout.write(`\x1b[${map.length}A`); // 回到開頭
process.stdout.write(`\x1b[33m這裡要放儀表板\x1b[0m\n`); // 儀表版
process.stdout.write(frame); // 印出畫面

我的預想是地圖尾部會被截斷一行,因為我們只有推出 map.length 的空間,但實際上卻是被完整印出來了

https://ithelp.ithome.com.tw/upload/images/20260923/20182439EmKxMCM5Z9.png

不過我們還是試著移動角色看看會發生什麼事:

https://ithelp.ithome.com.tw/upload/images/20260923/20182439IPuvkKEzS9.png

結果我每次移動角色都會出現新的一行字,這時我們來回顧一下最一開始為什麼要推出 map.length 空間的原因:是因為每次都應該重新渲染完整畫面,以避免重複渲染畫面,而不是因為會被截斷

等我們想起這個理由之後,再重新來看一次這個程式碼,我們應該把儀表板這一行也列入重新渲染的行列

process.stdout.write(`\x1b${map.length +1 }A`); // 加上儀表板這一行也要一起重新渲染
process.stdout.write(`\x1b[33m這裡要放儀表板\x1b[0m\n`); // 儀表版
process.stdout.write(frame); // 印出畫面

day11 儀表板加入重新渲染

這樣就沒問題啦!稍微改一下儀表板的介面,我將 HP 改用 31 的紅色 ,敵人用 35 的桃紅,連帶的在上方的敵人我也一起改成了 35 (這就交給大家自行去調整啦)

process.stdout.write(`\x1b31mHP:3\x1b[35m 敵人: 3\x1b[0m\n`); // 儀表版

執行一下看看:

https://ithelp.ithome.com.tw/upload/images/20260923/20182439tJ2OulUIkR.png

結果我們發現其實從剛剛開始, node 執行的那一行就消失了,只是因為疊上了儀表板,我們才藉由最後面沒被覆蓋掉的 js 這兩個字看出差異

因為我們剛剛改的是回到第一行的程式碼,但我們也要修改一下推出空格的程式碼,才可以確保完整的推出 map.length 行,並且回到推出第一行的行首

//初次渲染先推出固定的行數
process.stdout.write("\n".repeat(map.length +1));
render();

https://ithelp.ithome.com.tw/upload/images/20260923/20182439wpXdAAUxiJ.png

沒問題我們就繼續下一步吧!

制定變數與初始化宣告

在這裡呢,我希望血量值和敵人數量先完成到依據變數數值來渲染結果,在之後的章節會再逐步加上真實的功能,讓我們在遊戲物件的區塊加上這兩個變數:

let HP = 2;//稍微改個數值,才看得出變化
let enemyNum = 1;

接著我們來調整儀表板,讓儀表板吃到參數:

process.stdout.write(`\x1b31mHP:${HP}\x1b[35m 敵人:${enemyNum}\x1b[0m\n`); // 儀表版

https://ithelp.ithome.com.tw/upload/images/20260923/201824392XuUa5glaW.png

那麼我們明天終於可以正式進入到爆爆王的核心主題:炸彈爆炸了!如果可以的話,你可以先試著拆解炸彈爆炸可能會有哪些流程唷!明天見!

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


上一篇
Day10 爆爆王2:從結果反推函式的練習
下一篇
Day12 爆爆王4:物件顯示順序也是遊戲體驗的一環
系列文
重新拿回思考力!一起來用 JavaScript 打造 CLI 好玩遊戲區! 共 12 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言