今天我們要秉持著打造原型的精神,試著來完成「兩個箱子一個目標點」的玩法,不過由於兩個箱子的做法相較於一個箱子會更複雜一些,所以我想跟大家一起練習「先窮舉後精簡」,也就是說我們先盡可能把所有想到的走法都走過一遍,再來整理有哪些共用的架構、該如何精簡。
不過,開始前一樣用自己的話來描述我要做的事情,先來拆解目標:
我們也可以預先想想可能會發生的問題,就可以事先在程式內去做避免:
那我們就依序開始實作吧!
首先一定會先制定變數,接著我們會遇到需要修改 boxPicker 的問題:如何讓兩個箱子生成時不出現在同一格,且維持原本的生成規則?
我目前想到的做法就是在 boxPicker 內加上要迴避的座標,並且把這個座標帶入到 pickRandomEmptyCell 裡面
// 首先把要避開的座標都一起傳進來
function boxPicker(map,occupied = []) {
// 傳給 pickRandomEmptyCell 的變數改用傳進來的值
let box = pickRandomEmptyCell(map, occupied);
// 這段是確認這個箱子是否無解的邏輯,不動
const { top,bottom,left,right,isDeadlock } = checkBox (map,box)
if (isDeadlock || (top && box.y !== goal.y ) || (bottom && box.y !== goal.y ) || (left && box.x !== goal.x) || (right && box.x !== goal.x)) {
return boxPicker(map,occupied);
}
// 一樣傳回這個箱子的座標
return box;
}
接著我們要來修改 box 的變數,新增為兩個
let box1 = boxPicker(map,[player,goal]); // box 改為 box1,要避開項目的不變
let box2 = boxPicker(map,[player,goal,box1]); // 新增一個 box2 ,避開的項目加上 box1
let button = pickRandomEmptyCell(map, [player, box1 , box2, goal]); //button 要避開的項目從 box 改為 box1 和 box2
然後來檢查一下其他的位置還有沒有殘留的 box 變數,一一來處理。
首先是 render 裡面的這一段印出物件的邏輯,需要加上第二個箱子:
if (player.x === x && player.y === y) {
row += `\x1b[33m@\x1b[0m`
// box 改為 box1
} else if (box1.x === x && box1.y === y) {
row += `\x1b[34m$\x1b[0m`
// 加上 box2 ,一樣要印出 $ 代表箱子
} else if (box2.x === x && box2.y === y) {
row += `\x1b[34m$\x1b[0m`
} else if (goal.x === x && goal.y === y) {
row += `\x1b[31m.\x1b[0m`
} else if (button.x === x && button.y === y) {
row += `\x1b[31mx\x1b[0m`
} else if (cell === '@' || cell === '$' || cell === '.') {
row += ' '
} else {
row += `\x1b[32m${cell}\x1b[0m`
}
接著要來處理 process.stdin.on 監聽器的內部上下左右的邏輯,這邊先以向上移動為例,先觀察目前程式碼寫的是按下方向鍵上之後,如果箱子在玩家的上方且更上方不是牆壁的話,則玩家和箱子都向上移動一格,我們要加上兩個箱子的邏輯:
// 如果 box 存在於玩家的上方這一排,且 x 軸一樣
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
}
接著我們就來練習一下要如何窮舉吧!一樣我會用問題來一個一個問自己:
Q.首先只列出第一個條件的所有可能選項:player 和 box 是否在同一個 x 軸上面呢?
A.全部在同一個 x 軸、只有和其中一個在同一個 x 軸、完全都不在同一個 x 軸
Q.那麼下一個問題就是假設全部都在同一個 x 軸,有哪些情況呢?
A.player 的上方沒有東西、player 的上方只有一個 box、player 的上方有連續的兩個 box
Q.再假設 player 的上方沒有東西的話,他可以直接向上移動嗎?
A.要判斷有沒有牆壁,如果沒有牆壁就可以向上移動。
回答完了之後可以整理出這樣的關係,我把第一個問題的答案拿過來作為第一層的分類,再往下逐步展開第二層、第三層:
那這樣我們就把向上的方向走完了,有點像探索迷宮一樣!!這就是窮舉的方法,當我們窮舉列出所有可能的路線之後,可以試著撰寫程式碼。
寫到這邊,有沒有注意到已經跟原本的邏輯差異很大了,所以我們忘記原本的邏輯,試著重新撰寫一遍
首先來寫第一組分類,為了避免影響到原本的遊戲邏輯,我們先不直接去寫 process.stdin.on,我們在檔案最下方加上這一段邏輯,以便在遊戲一開啟時就可以觀察邏輯是否正確
if (player.x === box1.x && player.x === box2.x) {
console.log('全部在同一個 x 軸上')
} else if (player.x === box1.x || player.x === box2.x) {
console.log('只有一個在同一條 x 軸上')
} else if (player.x !== box1.x && player.x !== box2.x) {
console.log('都不在同一個 x 軸')
} else {
console.log('出錯了')
}

驗證了邏輯正確之後我們接著往下寫第二層判斷,試著寫完都在同一個 x 軸上的邏輯
if (player.x === box1.x && player.x === box2.x) {
console.log('全部在同一個 x 軸上')
if (( player.y-1 === box1.y || player.y-1 === box2.y ) && ( player.y-2 === box1.y || player.y-2 === box2.y )){
console.log('三個連成一排')
}else if ( player.y-1 === box1.y || player.y-1 === box2.y ){
console.log('只有一個在上面')
}else if ( player.y-1 !== box1.y && player.y-1 !== box2.y ){
console.log('都不在上面')
}
}// 其他的部分先不動
為了驗證方便,我們也先把角色座標改成自己寫的座標
// let player = pickRandomEmptyCell(map); // 用來儲存玩家位置
// let goal = pickRandomEmptyCell(map, [player]); // 用來儲存目標點位置
// let box1 = boxPicker([player, goal]); // 用來儲存箱子位置
// let box2 = boxPicker([player, goal, box1]);
// let button = pickRandomEmptyCell(map, [player, box1, box2, goal]); // 用來儲存按鈕位置
let player = {x:3,y:3};
let goal = {x:2,y:3};
let box1 = {x:3,y:2};
let box2 = {x:3,y:1};
let button = {x:5,y:2};
小提醒:避免執行出錯的關係可以先把所有 box 的舊邏輯先註解掉

再接著寫第三層的邏輯
// 在全部都在同一個 x 軸上的判斷中
if (( player.y-1 === box1.y || player.y-1 === box2.y ) && ( player.y-2 === box1.y || player.y-2 === box2.y )){
console.log('三個連成一排')
if(map[player.y - 3][player.x] !== '#') {
console.log('三個都要向上一步')
}else {
console.log('無法移動')
}
}

後面就給大家自行練習了!! 如果說還是覺得很困難的話,也可以從 console.log先寫,就會知道這一段應該要判斷什麼內容
也可以試著思考如何把重複撰寫的邏輯抽成函式來共用
最後提供寫完的版本給大家參考,別忘了把 console.log 換成最終的行動,並把這一段放到 process.stdin.on 監聽器的 if (key === '\x1b[A') 裡面,作為按下上方向鍵之後要做的事情,試試看用上方向鍵可不可以移動成功
if (key === '\x1b[A') {
if (player.x === box1.x && player.x === box2.x) {
if ((player.y - 1 === box1.y || player.y - 1 === box2.y) && (player.y - 2 === box1.y || player.y - 2 === box2.y)) {
if (map[player.y - 3][player.x] === ' ') {
player.y -= 1;
box1.y -=1;
box2.y -=1;
}
} else if (player.y - 1 === box1.y || player.y - 1 === box2.y) {
if (player.y - 1 === box1.y && map[player.y - 2][player.x] === ' ') {
player.y -=1;
box1.y -=1;
} else if (player.y - 1 === box2.y && map[player.y - 2][player.x] === ' ') {
player.y -=1;
box2.y -=1;
}
} else if (player.y - 1 !== box1.y && player.y - 1 !== box2.y) {
if (map[player.y - 1][player.x] === ' ') {
player.y -=1;
}
}
} else if (player.x === box1.x) {
if (player.y - 1 === box1.y && map[player.y - 2][player.x] === ' ') {
player.y -=1;
box1.y -=1;
} else if (map[player.y - 1][player.x] === ' ') {
player.y -=1;
}
} else if (player.x === box2.x) {
if (player.y - 1 === box2.y && map[player.y - 2][player.x] === ' ') {
player.y -=1;
box2.y -=1;
} else if (map[player.y - 1][player.x] === ' ') {
player.y -=1;
}
} else if (player.x !== box1.x && player.x !== box2.x) {
if (map[player.y - 1][player.x] === ' ') {
player.y -=1;
}
}
}
但是如果每個方向都要這麼寫就太累了,所以我在思考要如何精簡的時候,想到如果把它抽成共用的函式就可以在四個方向都去套用這個函式,這個時候因為我們其實已經了解邏輯怎麼做了,可是卻對於如何精簡苦思許久,所以我決定請 AI 產出可以運用於四個方向的共用函式就好,不要讓我們為了苦苦設計如何精簡邏輯而卡在這裡
一起來看一下 AI 產出的函式,我們可以試著用自己的語言補上註解,同時也檢查符不符合我們剛剛列出的邏輯:
function movePlayer(dx, dy) {
// 藉由傳入的 dx 和 dy 來定位下一個座標,這樣我們就知道要往哪邊走
const nextX = player.x + dx; // 下一個 x
const nextY = player.y + dy; // 下一個 y
// 兩個 box 寫成陣列
const boxes = [box1, box2];
// 找到是誰位於下一個位置,記錄下來(這邊用傳參考的方式,所以修改 firstBox 也會修改到原始值)
const firstBox = boxes.find(box => box.x === nextX && box.y === nextY);
// 前面沒有箱子
if (!firstBox) {
// 如果前方也是空的
if (map[nextY][nextX] === ' ') {
player.x = nextX; // player 的座標移動到下一個位置
player.y = nextY;
}
return; // 截斷函式不往下跑
}
// 如果前面有箱子的話就會往下走
// 紀錄箱子的再下一個位置
const boxNextX = firstBox.x + dx;
const boxNextY = firstBox.y + dy;
// 判斷有沒有下一個箱子
const secondBox = boxes.find(
box =>
box !== firstBox && // 如果這個箱子不是第一個箱子,而且位置剛好在第一個箱子的下一個位置
box.x === boxNextX &&
box.y === boxNextY
);
// 前面有兩個箱子,要確認第二個箱子後面是空的
if (secondBox) {
// 紀錄第二個箱子的再下一個位置
const afterSecondBoxX = secondBox.x + dx;
const afterSecondBoxY = secondBox.y + dy;
// 確認這個位置是不是空的,是的話三個都往後一格
if (map[afterSecondBoxY][afterSecondBoxX] === ' ') {
player.x = nextX;
player.y = nextY;
firstBox.x += dx;
firstBox.y += dy;
secondBox.x += dx;
secondBox.y += dy;
}
return;
}
// 前面只有一個箱子,要確認箱子後面是空的
if (map[boxNextY][boxNextX] === ' ') {
// player 和前面的箱子都往前一格
player.x = nextX;
player.y = nextY;
firstBox.x = boxNextX;
firstBox.y = boxNextY;
}
}
// 監聽器內四個方向換上這個邏輯
if (key === '\x1b[A') {
movePlayer(0, -1)
}
if (key === '\x1b[B') {
movePlayer(0, 1)
}
if (key === '\x1b[C') {
movePlayer(1, 0)
}
if (key === '\x1b[D') {
movePlayer(-1, 0)
}
是不是覺得很神奇呢,我們剛剛列出了那麼多複雜的步驟,結果他改成判斷前方有沒有箱子、再判斷更前方有沒有箱子、能不能往前走,然後就結束了這一題。不過也不需要擔心說利用 AI 是不是就沒有練習到,回顧今天我們做的練習,我們從窮舉開始列出每一個可能性,接著做了一點小整理,然後動手實作,最後我們決定要做精簡,而且也有明確的目的要讓 AI 寫一個函式來取代這一大段的邏輯,對於「練習思考」的這個目標,我認為已經達到了,既然達到目的,我們就要學習在適當的時機不讓自己卡住、糾結下去,這件事情我在後面的章節還會再繼續地去做討論。
總而言之,到這邊我們已經可以來嘗試執行 node index.js 來看看是否可以正確地移動箱子吧!
現在已經可以成功移動箱子了!不過你以為這樣就結束了嗎?還沒有!!!往下看,其實還有死亡的判定、勝負判定、將箱子推到按鈕以彈出箱子,這些邏輯都還沒有改成雙箱子的邏輯,我想不妨大家自己先來嘗試看看,明天我們再一起來討論吧!
如果你想要看完整的程式碼,可以參考這裡:Day7 程式碼