到目前為止,我們已經做好了ㄧ個村莊的地圖,今天要做到是使用者可以藉由 % 這個入口從村莊移動到森林,也可以從森林移動回村莊。
那就如同我們在第 23 天討論到的,這個 RPG 遊戲的主軸是練習狀態機,所以我們就先來試著畫出地圖切換的狀態機吧!

在這個狀態機的流程當中,我希望當玩家抵達 % 的時候觸發轉換,離開狀態的時候要清除該地圖,轉換時要顯示「地圖轉換中」的文字,接著要印出目標地圖,並將使用者顯示於 % 的位置。
那麼我們就按照這個狀態機來拆解步驟:
在這裡關鍵會在新的地圖怎麼產生,這邊我比較沒有概念,不過也沒關係,總之就先寫看看!
首先我找到 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`);
}
試著執行看看:
可以耶!!那麼再來就是要讓這個轉換中的文字持續個一秒鐘,再切換到新的地圖。
// 顯示地圖轉換中
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 就可以了,執行看看:
這樣我們就做到將地圖切換出去再切換回來啦,不過到這裡為止都還沒有真正切換地圖!
寫到這邊其實狀態機內的 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 #',
'##############################',
]
我們就會需要
此時一樣,我們先用地圖計算出所有物件的位置好了,這個比較容易一點。
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)
}
// 後面一樣
執行看看:
前面我們雖然可以轉換地圖了,可是我發現了幾個問題:
再重看一次程式碼,發現 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)
現在來試玩看看:
這樣玩家出現在指定的位置了!!沒錯,這樣才會有從右邊離開村莊,並且進入到森林的左邊的感覺嘛,這樣方向才對。
接著檢查 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`
}
測試一下:
可以了!!現在我們可以成功轉換地圖啦。
至於「重複多按幾次之後會破版」的問題,我也在多次的實驗中發現,原來是因為監聽器的最後都一定會接上一個 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;
}
}
最後再玩一次:
完成了!!!那麼明天我們要來做 NPC 對話!你也可以試著畫畫看對話的狀態機,我們明天見!
如果你想要瀏覽完整的程式碼,可以參考這裡:第 25 天程式碼