跳轉到

第 5 步:鬼會自己追

朋友回家了?沒關係,這一步讓鬼自己動腦追小精靈。你會寫出遊戲裡的第一個「AI」!

第 5 步的畫面:紅色的鬼正在迷宮裡朝小精靈靠近

完整程式

main.cpp
// 第 5 步:鬼會自己追——小精靈快逃!
#include <cstdlib>

#include "GridPlusPlus.h"

using gridpp::GameEngine;
using gridpp::GridObject;
using gridpp::Maze;
using gridpp::Overlay;

const int kCols = 15;
const int kRows = 11;
const char* kMap[kRows] = {
    "###############",
    "#P............#",
    "#.#.###.###.#.#",
    "#.............#",
    "#.#.#.#.#.#.#.#",
    "#.............#",
    "#.#.#.#.#.#.#.#",
    "#.............#",
    "#.#.###.###.#.#",
    "#............G#",
    "###############",
};  // '#' 是牆,'P' 是小精靈的起點,'G' 是鬼的起點

Maze maze;
GridObject pacman;
Overlay message;
bool game_over = false;

// 讓 who 往 (dx, dy) 走一格;前面是牆就不動。
void Walk(GridObject who, int dx, int dy) {
    if (maze.isWall(who.x() + dx, who.y() + dy)) return;
    who.move(dx, dy);
}

void GameOver() {
    game_over = true;
    message.setText("GAME OVER");
    message.show();
}

// 小精靈和別的東西撞在同一格時,Engine 會呼叫這個函式。
void PacmanHit(GameEngine, GridObject, GridObject other) {
    if (other.image() == "ghost") GameOver();
}

void MovePacman(GameEngine game, GridObject self) {
    if (game_over) return;

    if (game.keyPressed(KEY_RIGHT)) {
        self.setDirection(0);
        Walk(self, 1, 0);
    }
    if (game.keyPressed(KEY_UP)) {
        self.setDirection(1);
        Walk(self, 0, -1);
    }
    if (game.keyPressed(KEY_LEFT)) {
        self.setDirection(2);
        Walk(self, -1, 0);
    }
    if (game.keyPressed(KEY_DOWN)) {
        self.setDirection(3);
        Walk(self, 0, 1);
    }
}

const int kDX[4] = {1, 0, -1, 0};  // 方向 0 右、1 上、2 左、3 下
const int kDY[4] = {0, -1, 0, 1};
int ghost_direction = 2;
double ghost_next_move = 0;

// 鬼是不是和小精靈在同一格?
bool Caught(GridObject ghost) { return ghost.x() == pacman.x() && ghost.y() == pacman.y(); }

void ChasePacman(GameEngine game, GridObject self) {
    if (game_over) return;
    if (game.time() < ghost_next_move) return;  // 還沒輪到鬼走
    ghost_next_move = game.time() + 0.3;

    // 走之前先看一眼:小精靈是不是自己撞上來了?
    if (Caught(self)) {
        GameOver();
        return;
    }

    // 看看四個方向,選一條離小精靈最近、而且不用回頭的路。
    int back = (ghost_direction + 2) % 4;
    int best = back;  // 四面都走不通(死路)的時候,只好回頭
    int best_distance = 9999;
    for (int d = 0; d < 4; ++d) {
        if (d == back) continue;
        int x = self.x() + kDX[d];
        int y = self.y() + kDY[d];
        if (maze.isWall(x, y)) continue;
        int distance = std::abs(x - pacman.x()) + std::abs(y - pacman.y());
        if (distance < best_distance) {
            best = d;
            best_distance = distance;
        }
    }
    ghost_direction = best;
    self.move(kDX[best], kDY[best]);

    // 走完再看一眼:是不是撲到小精靈身上了?
    if (Caught(self)) GameOver();
}

int main() {
    GameEngine game(kCols, kRows, 48);
    game.loadAssets("pacman.db");
    game.setBackgroundColor(BLACK);

    maze = game.addMaze(kCols, kRows);
    maze.setWallImages("wall_iso", "wall_end", "wall_straight", "wall_corner", "wall_tee", "wall_cross");
    for (int y = 0; y < kRows; ++y) {
        for (int x = 0; x < kCols; ++x) {
            if (kMap[y][x] == '#') maze.setWall(x, y);
        }
    }

    pacman = game.addObject("pacman", nullptr, MovePacman, PacmanHit);
    pacman.setPosition(1, 1);

    GridObject ghost = game.addObject("ghost", nullptr, ChasePacman);
    ghost.setPosition(13, 9);
    ghost.setColor(RED);

    message = game.addTextOverlay("GAME OVER", 195, 230, 60, RED);
    message.hide();

    game.run();
    return 0;
}

在專案資料夾的終端機編譯並執行(按 Up 鍵可以叫出上次的指令):

g++ -std=c++17 main.cpp -o game -lraylib -lGL -lm -lpthread -ldl -lrt -lX11 && ./game
g++ -std=c++17 main.cpp -o game $(pkg-config --cflags --libs raylib) && ./game

撐得過 30 秒嗎?

程式怎麼運作

鬼多久走一步

if (game.time() < ghost_next_move) return;  // 還沒輪到鬼走
ghost_next_move = game.time() + 0.3;

還記得第 1 步的 game.time() 嗎?ghost_next_move 記住「鬼下一次可以走的時間」。時間還沒到就 return;時間到了,就把下一次訂在 0.3 秒後,然後走一步。所以鬼每 0.3 秒走一格。

鬼的大腦

鬼每次要走的時候,會這樣想:

  1. 看看上、下、左、右四個方向。
  2. 跳過牆壁,也不要往回走。
  3. 算算看走過去之後離小精靈多遠,選最近的那個方向。
int distance = std::abs(x - pacman.x()) + std::abs(y - pacman.y());

「多遠」是用橫向距離加上縱向距離來算的(std::abs 是絕對值)。kDX、kDY 兩個陣列記下每個方向要怎麼移動,例如方向 0(右)就是 kDX[0] = 1、kDY[0] = 0。

為什麼不能往回走?試試看把 if (d == back) continue; 刪掉,鬼會常常卡在牆角左右抖動。只有走進死路的時候,鬼才會回頭。

咦?鬼穿過去了?

你可能注意到,ChasePacman 在移動前和移動後都用 Caught 檢查了一次。明明第 4 步的碰撞函式就會抓到同一格的情況,為什麼還要自己檢查?

因為有一種情況,碰撞函式抓不到:小精靈和鬼面對面,在同一幀一起往對方走一格。

走之前:  . P G .      P 在第 1 格,G 在第 2 格
走之後:  . G P .      兩個交換了位置!

Grid++ 是在所有東西都走完之後才檢查碰撞。這時候兩者剛好交換位置,從來沒有站在同一格過,於是鬼就「穿過」小精靈了。

所以鬼要自己多看兩眼:

  • 走之前:如果小精靈已經自己撞進鬼的格子,就是抓到了。
  • 走之後:如果鬼撲進了小精靈的格子,也是抓到了。這時遊戲已經結束,小精靈就沒機會再逃走。

這樣不管小精靈和鬼誰先移動,都不會再穿過去。

親眼看看這個問題

把兩段 if (Caught(self)) ... 都刪掉,然後在走廊上正面迎向鬼,多試幾次。偶爾會看到鬼直接穿過小精靈!看完記得改回來。

試試看

  • 把 0.3 改成 0.15,鬼會變得多可怕?
  • 讓鬼越來越快:每走一步,就把等待時間縮短一點點。
  • 挑戰:再加一隻鬼,讓它隨機選方向,而不是追著小精靈。(提示:game.random(0, 3) 會隨機給你 0 到 3 的整數。第二隻鬼需要自己的 ghost_direction 和 ghost_next_move。)
跑不起來,或結果和圖片不一樣?

先確認整份程式都有貼上、而且有存檔。還是不行的話,可以直接執行這一步的參考答案,看看哪裡不同:專案的 steps/ 資料夾裡有每一步的完整程式。

想知道更多?

完整版 Pac-Man 有四隻各有個性的鬼,每隻鬼都用 set() 和 get() 記住自己的方向和計時器。請看多隻鬼的獨立狀態。

第 6 步:吃豆子