人工智能算法在游戏中被用于决定计算机控制的实体的行动。常用的游戏人工智能算法包括行为状态机、寻路算法、两个玩家轮流博弈中常用的游戏树。
状态机
对于非常简单的游戏,比如之前编写的 Pong 游戏,游戏 AI 都有相同的行为。在整个游戏中,行为都没有发生什么改变。
稍微复杂一点的游戏,不同时间里,不同场景地点中,游戏 AI 的行为也会发生变化。比如在经典的吃豆人 Pac-Man 中,每个幽灵就有三种行为,追赶玩家、散开(一开始的时候)、逃离玩家。行为之间的变化的经典实现就是状态机。
自动贩卖机也是一种状态机。另外,状态机的非常有名,图灵的计算机模型就是基于状态机的。状态机图灵完备,理论上可以实现一切可计算的功能。
状态机设计
定义状态、进入状态时候的操作和退出状态转换操作最终构成状态机。实现游戏 AI 最重要的问题是状态之间如何转换。例如,NPC 在预定义的路径上巡逻,发现了玩家,就会开始攻击玩家。巡逻是一个状态,发现玩家后,就转换到了攻击状态。如果玩家可以发动攻击,NPC 有可能被杀死。死亡本身也是一种状态。
我们可以设计一个 AIComponent
来实现上述的状态行为。首先定义三个状态,巡逻(Patrol
)、死亡(Death
)、攻击(Attack
):
enum class AIState
{
Patrol,
Death,
Attack
};
然后就可以创建一个 AICompoent
类拥有 AIState
这个成员数据,还可以分开定义每个状态的更新函数。
最简单的实现大概类似这样:
void AIComponent::Update(float deltaTime)
{
switch (mState) {
case AIState::Patrol
UpdatePatrol(deltaTime);
break;
case AIState::Death:
UpdateDeath(deltaTime);
break;
case AIState::Attack:
UpdateDeath(deltaTime);
break;
default:
break;
}
}
为了实现状态的转换,可以实现一个函数 ChangeState
,大体思路就是传入一个新状态,然后函数先退出原有状态,设置成新状态,进入新状态:
void AIComponent::ChangeState(AIState newState)
{
// 退出当前状态(调用该状态的 Exit 函数)
mState = newState;
// 进入新的状态
}
上面的这种实现非常简单,但有问题。第一,不具备伸缩性。添加更多的状态同时降低 Update
和 ChangeState
的可读性。另外,为每一个状态设置 Update
、Enter
、Exit
函数也会让代码难以追踪。
有可能多个 AI 共享同一个状态,例如巡逻 Patrol
,但上面这种处理显然很难进行复用。因此,我们可能不该把数据和操作分离开,而应该使用一种面向对象的思维将状态设计成类。
使用类实现状态
为了实现状态的可扩展,将状态本身抽象成为一个基类:
class AIState
{
public:
AIState(class AIComponent* owner): mOwner(owner) {
}
// 特定状态行为
virtual void Update(float deltaTime) = 0;
virtual void OnEnter() = 0;
virtual void OnExit() = 0;
// 获取状态的名字
virtual const char* GetName() const = 0;
protected:
class AIComponent* mOwner;
};
通过 mOwner
成员变量,可以将 AIState
联系到特定的 AIComponent
上。下面,定义 AIComponent
:
class AIComponent : public Component
{
public:
AIComponent(class Actor* owner);
void Update(float deltaTime) override;
void ChangeState(const std::string& name);
// 添加新状态
void RegisterState(class AIState* state);
private:
// 映射状态名到 AIState 实例
std::unordered_map<std::string, class AIState*> mStateMap;
// 当前状态
class AIState* mCurrentState;
};
注意,AIComponent
有一个哈希表映射名字到 AIState
实例的指针,同时也保持有一个指向当前状态的指针。RegisterState
函数将 AIState
的指针添加到映射上:
void AIComponent::RegisterState(class AIState* state)
{
mStateMap.emplace(state->GetName(), state);
}
AIComponent::Update
非常直截了当,调用当前状态的 Update
函数就可以了:
void AIComponent::Update(float deltaTime)
{
if (mCurrentState)
{
mCurrentState->Update(deltaTime);
}
}
ChangeState
稍微要做的事情多一些,首先要调用 OnExit
函数退出当前状态,然后在哈希表中找到新状态,并调用 OnEnter
。
void AIComponent::ChangeState(const std::string& name)
{
// 退出当前的状态
if (mCurrentState)
{
mCurrentState->OnExit();
}
// 在映射中找到新状态
auto iter = mStateMap.find(name);
if (iter != mStateMap.end())
{
mCurrentState = iter->second;
// 进入状态
mCurrentState->OnEnter();
}
else
{
SDL_Log("无法在映射中找到AIState %s", name.c_str());
mCurrentState = nullptr;
}
}
具体的状态,像巡逻 Patrol
可以通过继承自 AIState
实现:
class AIPatrol : public AIState
{
public:
AIPatrol(class AIComponent* owner)
:AIState(owner)
{
}
// 重写行为
void Update(float deltaTime) override;
void OnEnter() override;
void OnExit() override;
const char* GetName() const override
{
return "Patrol"; }
};
就可以实现特定的 Update
、OnEnter
和 OnExit
。
void AIPatrol::Update(float deltaTime)
{
SDL_Log("更新%s状态", GetName());
// 可以做一些操作
bool dead = true; // 这里只是简单示例,实际当然得弄清楚是不是死了
if (dead)
{
mOwner->ChangeState("Death");
}
}
实际使用的时候需要把状态注册到 AIComponent
上。
寻路
寻路算法用于找出两个之间的路径,并且在路径中绕开障碍物。这个问题的复杂性在于可能两点之间存在大量的路径,但是这些路径中只有少数路径是最短的。
在求解寻路问题之前,我们需要找到一个表示游戏世界里 AI 路径的方式。显然,最经典的选择就是 数据结构:图的实现。这里假定读者对基本图数据结构有所了解。
我们这里使用的是邻接表实现的图结构。不同游戏也会用不同方式表示游戏世界,例如将世界划分为正方形是最简单的方法。这种实现方式在基于回合的策略游戏中非常普遍,例如:文明和X-COM。然而,对于其他很多类型的游戏,这种实现可能是不可行的。
struct GraphNode
{
// 邻接表
std::vector<GraphNode*> mAdjacent;
};
struct Graph
{
std::vector<GraphNode*> mNodes;