iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
JavaScript

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

Day16 爆爆王8:移動一格背後的秘密,從設計師角度看AI 敵人設計

  • 分享至 

  • xImage
  •  

今天終於要進入到我最期待的環節,也就是AI 敵人的實作!

回到昨天最後的提問:大家對於 AI 敵人有哪些期待呢?你會不會想說其實只要敵人可以自己動起來,好像就 OK 了?但是是否沒有考慮到背後這些敵人應該用怎樣的邏輯來決定自己的下一步呢?

今天想要邀請大家一起從設計師的角度來思考,AI 敵人這個表面上或許只是每秒移動一格,如果碰到玩家就扣血,這麼簡單的事情,實際上我們可以有哪些不同的變化。

先來給大家看一下我畫的這張圖:

https://ithelp.ithome.com.tw/upload/images/20260929/20182439HyR76YK5Dn.png

所以今天就是要跟大家一起腦力激盪一下中間的這個 {用什麼方法} 填空題。

決策規則發想

從最基本的開始,我們一定會做的事情就是:篩選出可以移動的格子,那麼很自然的反應,一定會想說那就讓它隨機移動一格好了。

很好!這樣我們就有了第一個 AI 敵人的邏輯了。但如果三個都隨機移動一格很無聊嘛,有沒有更好的做法?

作為一個遊戲設計師,試著放感情進入這個敵人的內心世界(?),你一定會希望可以趕快解決掉敵人,對吧?那麼第二個敵人我們就做讓敵人從可以移動的格子中,選擇距離玩家最近的那個格子好了,這樣第二個敵人我們也決定好了!

第三個敵人呢?再閉上眼睛仔細的思考,如果把敵人換成一個真實的玩家,你除了遊戲的目標要殺掉玩家之外,還有什麼需要注意的?對於我而言最大的問題應該是炸彈會導致我的滅亡,玩家還有三個 HP 耶,我只有一條命,應該要珍惜一下,所以第三個玩家我也決定好了!

這就是我目前想到的三個不同決策策略的 AI,其實就是每一個 AI 都加上一個新的資訊來決策:

  1. 隨機往可移動的方向移動一格
  2. 距離使用者最近的可移動方向前進一格
  3. 如果附近有炸彈或爆炸範圍,就優先逃離危險區。如果沒有危險,就慢慢靠近玩家。

當然你還可以有更多想法,比如說三個 AI 應該要一起移動還是他們會有不同的速度?但我想我們先實作看看每秒一起移動一次的形式,如果各位讀者有興趣可以進階實作看看你們的各種想法!

那麼我們就趕快進入到實作吧。

從共用邏輯開始

從前面我畫的圖可以看出來,這三個機器人要換掉的只有中間如何計算出下一步的函式,其他的步驟都是一樣的,所以我想把其他的步驟也包成一個函式。

那因為是每秒觸發,而我們在第二天就有討論到每秒觸發會使用 setInterval ,所以我打算用 setInterval 把函式包起來觸發每秒執行。

// 敵人移動
setInterval(()=>{
    // 改變敵人座標
    moveEnemy(map,boxes,enemy1);
    moveEnemy(map,boxes,enemy2);
    moveEnemy(map,boxes,enemy3);

    // 渲染
    render();
    },1000);

雖然還沒有去撰寫 moveEnemy 這個函式,不過發揮前面以終為始的精神,先將我會如何使用 moveEnemy 給定義出來。考慮到總共有三個敵人但外層函式當然不想寫三份呀,因此我打算函式內再依據傳入的敵人是哪一個來執行不同的計算。

再來就來撰寫 moveEnemy :

function moveEnemy(enemy){
    // 計算並重新賦予座標
    if(enemy === enemy1){
        enemy1 = pickNextEnemy1(enemy1); // 預期產出的格式 {x:num,y:num,live:true}
    }

    if(enemy === enemy2){
        enemy2 = pickNextEnemy2(enemy2);
    }

    if(enemy === enemy3){
        enemy3 = pickNextEnemy3(enemy3);
    }

    // 渲染
    render();
}

一樣我先用註解寫下步驟,再逐一去填寫內容,這邊我一樣以終為始先把 pickNextEnemy 1 到 3 都先寫出來,我預期這三個函式的目的就只是拿到最新的座標(傳入敵人座標),接著計算之後回傳新的座標,所以在 moveEnemy 這裡需要將回傳的座標存回到該 enemy 裡面。

第一個敵人的計算邏輯

再來就要進到第一個敵人的計算啦,一樣我們就練習先寫下步驟:

  1. 傳入座標 enemy1 ,他的格式會像這樣 { x : 2 , y : 1 , live : true };
  2. 依據這個傳入的數值選擇一格可以前進的位置,在敵人的規則中有兩種情況不能前進:遇到牆壁和箱子必須要去扣除
  3. 隨機選擇其中一個位置
  4. 回傳

寫完步驟啊,有沒有發現我們還缺了哪些資料?沒錯,就是帶有牆壁資訊的 map 和帶有箱子資訊的 boxes ,所以我趕緊先回頭修改剛剛擬好的函式:

// 敵人移動
setInterval(()=>{
    moveEnemy(map,boxes,enemy1);
    moveEnemy(map,boxes,enemy2);
    moveEnemy(map,boxes,enemy3);
    },1000);
function moveEnemy(map,boxes,enemy){
// 計算並重新賦予座標
    if(enemy === enemy1){
        enemy1 = pickNextEnemy1(map,boxes,enemy1); // 預期產出的格式 {x:num,y:num,live:true}
    }

    if(enemy === enemy2){
        enemy2 = pickNextEnemy2(map,boxes,enemy2);
    }

    if(enemy === enemy3){
        enemy3 = pickNextEnemy3(map,boxes,enemy3);
    }

    // 渲染
    render();
}

接著再來寫 pickNextEnemy ,突然想到可以複用之前寫好的邏輯,先複製過來驗證可不可行,哪一天覺得說可以抽成共用函式再來抽就好了

記得這邊要把炸彈原本的邏輯改成敵人的邏輯:

function pickNextEnemy1(map,boxes,enemy1){
    // 用來記錄 enemy 的座標值
    const enemyX = enemy1.x;
    const enemyY = enemy1.y;
    // 用來記錄允許的路線
    let allowCell = [];
    // 找出周圍的座標
    const surroundingCells = [
        // 改成用 enemy 來計算
        {x:enemyX, y:enemyY},
        {x:enemyX - 1, y:enemyY},
        {x:enemyX + 1, y:enemyY},
        {x:enemyX, y:enemyY - 1},
        {x:enemyX, y:enemyY + 1},
    ]

    // 扣除牆壁
    for(let i = 0 ; i < surroundingCells.length; i++){
        const thisX = surroundingCells[i].x;
        const thisY = surroundingCells[i].y;

        if(map[thisY][thisX] !== '#'){
            allowCell.push(surroundingCells[i])
        }
    }
}

這樣就完成牆壁了,接著要完成扣除箱子,先把判斷是否為牆壁和判斷是否為箱子拆成兩行來寫,最後再用一句判斷式一起判斷是否可以存入允許值:

function pickNextEnemy1(map,boxes,enemy1){
    const enemyX = enemy1.x;
    const enemyY = enemy1.y;
    let allowCell = [];
    // 找出周圍的座標
    const surroundingCells = [
        {x:enemyX, y:enemyY},
        {x:enemyX - 1, y:enemyY},
        {x:enemyX + 1, y:enemyY},
        {x:enemyX, y:enemyY - 1},
        {x:enemyX, y:enemyY + 1},
    ]

    // 扣除牆壁
    for(let i = 0 ; i < surroundingCells.length; i++){
        const thisX = surroundingCells[i].x;
        const thisY = surroundingCells[i].y;
        // 這兩行來做判斷
        const isWall = map[thisY][thisX] === '#'; // 這裡要記得改成 ===
        const isBox = boxes.find(box => box.x === thisX && box.y === thisY);

        // 兩個都不是的話才可以存入允許值
        if( !isWall && !isBox){
            allowCell.push(surroundingCells[i])
        }
    }

}

最後加上隨機選取一格,這個一樣我去搬 pickRandomEmptyCell 函式最後兩句的寫法,但改成這邊用的 allowCell 陣列。

    const randomIndex = Math.floor(Math.random() * allowCell.length);
    return allowCell[randomIndex];

先來實測看看是否可以動起來,我簡單修改一下 moveEnemy 讓所有的敵人都先照現有的邏輯來走,比較好觀察

function moveEnemy(map,boxes,enemy){
    // 計算並重新賦予座標
    if(enemy === enemy1){
        enemy1 = pickNextEnemy1(map,boxes,enemy1); // 預期產出的格式 {x:num,y:num,live:true}
    }

    if(enemy === enemy2){
        enemy2 = pickNextEnemy1(map,boxes,enemy2);
    }

    if(enemy === enemy3){
        enemy3 = pickNextEnemy1(map,boxes,enemy3);
    }
}

day16 敵人消失了

我們發現一秒後敵人都消失了,原來是因為忘記把 live 寫回去,所以來修改這一句,將live補回去:

return {...allowCell[randomIndex],live:enemy.live};

接著又發現有時候敵人會停在原地,原來是因為我們讓他選擇的格子裡面有包含自己的格子,所以要把這裡自己的格子那一行給拿掉

const surroundingCells = [
        // {x:enemyX, y:enemyY}, 這個要拿掉
        {x:enemyX - 1, y:enemyY},
        {x:enemyX + 1, y:enemyY},
        {x:enemyX, y:enemyY - 1},
        {x:enemyX, y:enemyY + 1},
    ]

最後再實測一次:

day16第一個敵人邏輯成功

這樣就可以看到所有的敵人都會隨機移動一格了!那麼明天我們要繼續來完成另外兩個 AI 敵人的移動策略,你也可以試著先拆解看看唷!明天見。

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


上一篇
Day15 爆爆王7:來個中場休息小回顧
下一篇
Day17 爆爆王9:從土法煉鋼到站在巨人肩膀上,演算法的學習
系列文
重新拿回思考力!一起來用 JavaScript 打造 CLI 好玩遊戲區! 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言