終於來到了第九天,前面八天我們藉由推箱子小遊戲了解了 Terminal 是一個怎樣的環境,並且不斷地重複練習拆解問題、小步嘗試、問出更多的問題、再繼續拆解問題的迴圈裡面,接下來我們終於要進入到下一個遊戲:也就是改編大家應該都很熟悉的爆爆王,做成 CLI 版本!(大家應該都跟我差不多年紀吧!差不多吧!)
為了一些沒有玩過 比較年輕 的夥伴,還是來介紹一下玩法,同時為了方便開發,在這邊也順便精簡一下玩法
在爆爆王遊戲中,我也希望可以給自己一個練習的目標:找到方法來處理複雜問題。
推箱子小遊戲從一個箱子提升到兩個箱子時就變得稍微複雜,所以我們用先窮舉再精簡的方法來解決這個問題,但是到了爆爆王我們會遇到更多更複雜的問題,到時候我該如何思考、如何透過前面提到的方法或是外部的工具等等來幫助自己,這就是我藉由爆爆王主要想要練習的目標。
另外一樣為了避免我想到太多想做的東西而停不下來,我也要給自己一個開發邊界:爆爆王執行到能玩得起來並且嘗試做出 AI 敵人,當我開發到這個部分就需要回想起自己練習的目標,然後接受現況先停在這裡。
第一天或許還是會感覺跟前面幾天很像,也是從拆解問題開始,但當問題越來越複雜,我們就會需要更多的工具或思考的方式來輔助自己找到能繼續往下的辦法!那我們開始吧!
那由於我們前面已經有推箱子製作地圖的經驗,我們這次要直接從雙重陣列的地圖開始製作,一樣我們就先來拆解一下步驟
那我們今天的目標就先做到印出具備玩家和敵人的隨機地圖!至於箱子,因為我們需要產出多個箱子,所以我們會放到明天再來詳細的討論。
在繼續往下閱讀之前,大家也可以先停下來試著動手做看看是不是可以做得出來唷!
哎呀,第一件事情要先開一個新的檔案,你們沒忘記吧
請在專案內另外開一個 bomb.js 用來放這支爆爆王遊戲

這邊其實就是去複製前面寫好的內容,由於爆爆王需要比較多的畫面,我們先把 map 改成 15 * 9 的地圖
//地圖初始化,這邊不用動
function createMap(width, height) {
const map = [];
for (let y = 0; y < height; y++) {
const row = [];
for (let x = 0; x < width; x++) {
const isBoder = y === 0 || y === height - 1 || x === 0 || x === width - 1;
if (isBoder) {
row.push('#')
} else {
row.push(' ')
}
}
map.push(row)
}
return map
}
// 改成 15 * 9
const map = createMap(15, 9);
//渲染畫面
function render() {
let frame = ''
for (let y = 0; y < map.length; y++) {
let row = ''
for (let x = 0; x < map[y].length; x++) {
const cell = map[y][x]
// 這裡因為只有要印外框,所以先把判斷式拿掉,單純印出原本的內容就可以
row += `\x1b[32m${cell}\x1b[0m`
}
frame += row + '\n'
}
process.stdout.write(`\x1b[${map.length}A`); // 回到開頭
process.stdout.write(frame); // 印出畫面
}
//初次渲染先推出固定的行數
process.stdout.write("\n".repeat(map.length));
render()
我們接下來想要印出像這樣的畫面,為了方便觀察規律,我先加上了數字索引(因為空間不夠的關係,x 軸第二個0是10的意思喔!)
012345678901234
0 ###############
1 # #
2 # # # # # # # #
3 # #
4 # # # # # # # #
5 # #
6 # # # # # # # #
7 # #
8 ###############
那在這裡面我們試著列出邊界以外有牆壁的格子是怎樣的座標
{2,2},
{4,2},
{6,2},
{8,2},
{10,2},
{12,2},
{4,2},
{4,4},
.
.
.
我們就可以整理出一個規律,只要是 x 和 y 都是偶數的格子,都要是牆壁。所以我們直接回到 creatMap 裡面去加上這一段
for (let y = 0; y < height; y++) {
const row = [];
for (let x = 0; x < width; x++) {
const isBorder = y === 0 || y === height - 1 || x === 0 || x === width - 1;
// 加上判斷 x 和 y 是不是都是偶數
const isWall = y % 2 === 0 && x % 2 === 0;
// 如果是邊緣或為牆壁都把 # 寫進 map
if (isBorder || isWall) {
row.push('#')
} else {
row.push(' ')
}
}
map.push(row)
}
這時候可以執行一下 node bomb.js 來看看

接著我們要來設定一下玩家、箱子、敵人的位置,回頭複習一下推箱子遊戲的隨機生成函式
// occupied 預設是一個空陣列,這樣也可以避免萬一沒傳入變數的情況,不會報錯
function pickRandomEmptyCell(map, occupied = []) {
// 用來儲存空白格
const emptyCell = [];
for (let y = 0; y < map.length; y++) {
for (let x = 0; x < map[y].length; x++) {
// 判斷是不是已被占用
const isOccupied = occupied.some(cell => cell.x === x && cell.y === y);
// 如果該位置是空的而且尚未被占用才將這個數值儲存到 emptyCell
if (map[y][x] === " " && !isOccupied) {
emptyCell.push({ x, y })
}
}
}
const randomIndex = Math.floor(Math.random() * emptyCell.length);
return emptyCell[randomIndex]
}
唉,不要說我偷懶,既然沒看出什麼明顯行不通的邏輯,我們就貼進去 bomb.js 吧XD
let player = pickRandomEmptyCell(map);
let enemy1 = pickRandomEmptyCell(map,[player]);
let enemy2 = pickRandomEmptyCell(map,[player,enemy1]);
let enemy3 = pickRandomEmptyCell(map,[player,enemy1,enemy2]);
記得 render 裡面要把 player 加進去唷,所以我們可以把這個判斷式補回來
if (player.x === x && player.y === y) {
row += `\x1b33m@\x1b[0m`
}else if (enemy1.x === x && enemy1.y === y) {
row += `\x1b[31m&\x1b[0m` // 顏色的部分我挑了 31 號紅色以及 & 來表示敵人位置
}else if (enemy2.x === x && enemy2.y === y) {
row += `\x1b[31m&\x1b[0m`
}else if (enemy3.x === x && enemy3.y === y) {
row += `\x1b[31m&\x1b[0m`
} else {
row += `\x1b[32m${cell}\x1b[0m`
}
一樣執行看看

這樣我們就生成了玩家和敵人了!
由於 AI 敵人我們之後有可能會給他們不同的行為模式,所以我們維持拆開來生成並且用 enemy1 、enemy2、enemy3 來表示,但是箱子不管有幾個都是一樣的,所以明天我們可以來想想看如何一次生成 10 個箱子,如果你有時間也可以自己先試著做看看唷!
如果你想要瀏覽完整的程式碼,可以參考這裡:第九天程式碼