到目前為止我們都在講要如何讓畫面自己動起來,但是遊戲的重點還是要有玩家操作,因此今天我們最主要的目標是要讓玩家可以操作角色動起來,與此同時我們也持續在練習藉由思考來逐步完成專案的方法:
今天我們要練習的是試著用自己的語言來描述邏輯,再來轉換為程式語言。不過在進到那一段之前,我們還是要先補一段關於 Terminal 的重要知識作為我們這個功能的基礎建設,馬上開始吧!
首先先幫大家整理一些關於 Terminal 的筆記:
process是 node 執行程序的一個全域物件,每一個程序開始時就會被自動建立,有點像是管家,管理內部狀態並掌握和外部溝通的方法stdoutStandard Output 標準輸出,將結果印在畫面上,我們至今為止都一直在使用這個功能stdinStandard 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 可以動了! 但是很快的衍伸出兩個問題
為了解決關不掉遊戲的問題,請簡單的在 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;
}
接著來一題小考啦,要如何做到推動箱子呢?
今天要處理的剩下兩件事情
大家有想法了嗎?
先試著用自己的語言寫下來吧
自己的語言:
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", "危險!怪物出現了!");
印出來的畫面會像這樣
console.log 兩個參數,第一個參數是樣式與模板,第二個參數是要帶入的文字內容
\x1b 特殊字元 ESC,目的在告訴 terminal 這段話不要印出來,這段是控制指令
[31m 前景變紅色
%s 文字字串佔位符,會把後面的字串參數帶入進來,當然這邊也可以直接寫文字
[0m 重設樣式,讓後面的文字可以變回原本的顏色,為了驗證這個是不是有效,我們也可以試著加入第二段文字
console.log("\x1b[31m%s\x1b[0m%s", "危險!怪物出現了!","變為原本的顏色");

接著再試試看把 \x1b[0m 這段拿掉,看看顏色是否維持在紅色
console.log("\x1b[31m%s%s", "危險!怪物出現了!","變為原本的顏色");

大致的了解 Terminal 的顏色規則之後,我們來看看有哪些常用的顏色:
因此我們可以試著在 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`
}
這樣就可以看到上完色的遊戲畫面啦
那麼以上就是今天的內容啦,不過你是不是也有發現我們現在不管怎麼打開都是同一個畫面呢?這樣這個遊戲根本就像是一次性的衛生紙而已嘛,用完一次就沒了!完全沒有能將玩家留下來一直持續玩下去的設計,所以明天我們就要來改造這個重要的體驗,你對於這個遊戲有哪些設計上的想法呢?可以試著先列出來,也歡迎留言在下面討論唷。
如果你想要看完整的程式碼,可以參考這裡:Day4 程式碼