給貓看的游戲AI實戰(zhàn)(三)基于狀態(tài)機的AI系統(tǒng)

前一節(jié)舉了一個視覺感知的例子,貓們可能感覺和AI的關(guān)系并不很大,今天盡量把上節(jié)課學(xué)的東西利用起來,然后再讓AI能夠根據(jù)情況分解問題,讓敵人看起來有點智能,不要太弱了 ╮(╯▽╰)╭。
前言、狀態(tài)機與AI
有限狀態(tài)機(FASM)是長久以來AI編程最基本的方法,就像拼UI界面要用坐標(biāo)、層級一樣,非常基本。而且這種方法的適應(yīng)性非常好,只要開動腦筋仔細設(shè)計觸發(fā)條件、執(zhí)行動作,總能達到想要的效果。
(從反面講,觸發(fā)條件和狀態(tài)轉(zhuǎn)移的設(shè)置一不小心就會沖突造成奇怪的BUG,一定要有充分心理準備。) ( ̄~ ̄;)
AI狀態(tài)機設(shè)計舉例:

上圖就是一個簡單的狀態(tài)機設(shè)計圖,應(yīng)該非常通俗易懂。某些講解AI的書籍會把類似的思想換一個角度來講:AI在每一個時刻都有1~N種選擇,換句話說游戲進行過程中,每種情況下下AI都有一些可以選擇的行為,把這些可能性和AI的各種狀態(tài)組織起來,就形成了可能性圖。
可能性圖(Possibility Map)舉例:

上圖就是一個可能性圖,簡單地把AI可以做的各種行為列舉出來即可。但是這個圖只是簡化過的,嚴格地說,如果玩家(也就是AI的敵人)沒有出現(xiàn),那么“攻擊”、“攻擊并前進”這兩個行為就沒有目標(biāo),這兩個選擇也就不存在了;而如果AI已經(jīng)呆在原地了,那么“退回崗位”的行為也就不存在了。也就是說在不同狀態(tài)下AI能選擇的行為是受限制的,AI只能在有限行為中選擇合適的,這就是可能性圖的真正含義。
補充一句,其實玩家行為也是受限制的,設(shè)計AI的方法有很多地方和設(shè)計游戲玩法是通用的,畢竟玩家只是一種特殊的AI而已 ㄟ( ▔, ▔ )ㄏ
如果僅僅作為一個程序?qū)崿F(xiàn)者,搞清楚狀態(tài)轉(zhuǎn)移圖已經(jīng)可以很好地實現(xiàn)功能了。但是如果你想自己設(shè)計游戲,就要考慮AI和玩家到底什么時間應(yīng)該做什么,就應(yīng)當(dāng)畫一個完整的可能性圖來幫助你思考了。
1、制作狀態(tài)機AI的準備工作
本節(jié)內(nèi)容要新建一個Unity工程,依然可以借用前兩節(jié)里面的一些腳本和Prefab,在上面修改。新建工程而不在上節(jié)的工程中修改,是為了避免混亂,畢竟腳本細節(jié)還是有很多不同的。這是我們第一次做真正的AI,抓緊坐穩(wěn)了啊。????
1、新建工程,再單獨開一個Unity窗口打開原來的工程。把前兩節(jié)課做的敵人、玩家都保存成Prefab,然后把場景、材質(zhì)(Material)、Prefab都拷貝到新工程里(可以在Unity外面直接拷貝Assets目錄里的文件)。腳本就不用拷貝了,這次變化會很大。(這步可以幫助你熟悉Unity文件操作,如果不太會整,可以新建一個,也不麻煩)。
2、如圖用Box做一個倉庫的樣子,這節(jié)課可能沒有實際作用,但是看起來會好一些,也會給你下一步改進的靈感。

3、上節(jié)的腳本都不要直接復(fù)制過來。新建一個Player腳本掛在白色的玩家身上,新建一個Enemy腳本掛在紅色的敵人身上,然后把之前寫過的代碼部分地粘貼過來。玩家要有移動功能(第一節(jié)講的),敵人有虛擬視野(第二節(jié)講的)。
Player.cs 代碼如下,還原出移動的功能即可:
// Player.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Player : MonoBehaviour {
public float moveSpeed = 6;
Rigidbody myRigidbody;
void Start()
{
myRigidbody = GetComponent<Rigidbody>();
}
void Update()
{
if (hp > 0)
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hitt = new RaycastHit();
Physics.Raycast(ray, out hitt, 100, LayerMask.GetMask("Ground"));
if (hitt.transform != null)
{
transform.LookAt(new Vector3(hitt.point.x, transform.position.y, hitt.point.z));
}
myRigidbody.velocity = new Vector3(Input.GetAxisRaw("Horizontal"), 0, Input.GetAxisRaw("Vertical")).normalized * moveSpeed;
}
}
Enemy.cs代碼如下,還原出虛擬視野的功能即可:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Enemy : MonoBehaviour {
public float viewRadius = 8.0f;
public float viewAngleStep = 40;
Vector3 basePosition; // 原始位置
Quaternion baseDirection; // 原始方向
void Start () {
basePosition = transform.position;
baseDirection = transform.rotation;
}
void Update() {
DrawFieldOfView();
}
void DrawFieldOfView()
{
// 獲得最左邊那條射線的向量,相對正前方,角度是-45
Vector3 forward_left = Quaternion.Euler(0, -45, 0) * transform.forward * viewRadius;
// 依次處理每一條射線
for (int i = 0; i <= viewAngleStep; i++)
{
// 每條射線都在forward_left的基礎(chǔ)上偏轉(zhuǎn)一點,最后一個正好偏轉(zhuǎn)90度到視線最右側(cè)
Vector3 v = Quaternion.Euler(0, (90.0f / viewAngleStep) * i, 0) * forward_left; ;
// 創(chuàng)建射線
Ray ray = new Ray(transform.position, v);
RaycastHit hitt = new RaycastHit();
// 射線只與兩種層碰撞,注意名字和你添加的layer一致,其他層忽略
int mask = LayerMask.GetMask("Obstacle", "Player");
Physics.Raycast(ray, out hitt, viewRadius, mask);
// Player位置加v,就是射線終點pos
Vector3 pos = transform.position + v;
if (hitt.transform != null)
{
// 如果碰撞到什么東西,射線終點就變?yōu)榕鲎驳狞c了
pos = hitt.point;
}
// 從玩家位置到pos畫線段,只會在編輯器里看到
Debug.DrawLine(transform.position, pos, Color.red); ;
// 如果真的碰撞到敵人,進一步處理
if (hitt.transform!=null && hitt.transform.gameObject.layer == LayerMask.NameToLayer("Player"))
{
OnEnemySpotted(hitt.transform.gameObject);
}
}
}
void OnEnemySpotted(GameObject enemy)
{ Debug.Log("Player Spotted");
}
}
4、測試寫好的部分,要做到:玩家Player可以按WASD鍵走動,用鼠標(biāo)控制方向,敵人Enemy可以發(fā)射射線,在射線碰到玩家時控制臺會打印“Player Spotted”。注意腳本錯誤、Layer設(shè)置錯誤等問題。
Unity做這些基本的東西比較考驗?zāi)托?,有問題都可以在之前的章節(jié)里找到說明。
5、添加射擊的功能。先給玩家添加射擊功能,相對簡單一些,先加上以下變量,用于控制開火的:
public GameObject bullet; // 子彈Prefab,用它來生成更多子彈
public float bulletSpeed = 30.0f; // 子彈速度
public float fireInterval = 0.3f; // 射擊最小間隔
float fireCd = 0; // 記錄CD時間,用來控制子彈射擊頻率
添加了bullet變量以后,在編輯器里做一個球體的Prefab,要帶有剛體屬性設(shè)置和玩家自己一樣,調(diào)好顏色,把它拖到變量上,如下圖。Unity里動態(tài)生成對象經(jīng)常用到這個方法:

添加一個Fire函數(shù):
void Fire()
{
if (fireCd > Time.time)
{
return;
}
var b = Instantiate(bullet, transform.position, Quaternion.identity, transform);
var rigid = b.GetComponent<Rigidbody>();
rigid.velocity = transform.forward * bulletSpeed;
fireCd = Time.time + fireInterval;
}
然后在Player.cs Update函數(shù)最后面加上如下代碼,鼠標(biāo)左鍵就可以開火:
if (Input.GetMouseButtonDown(0))
{
Fire();
}
簡單講解一下。開火原理:生成一個子彈并給它一個初速度。CD控制原理:把下一次可以開火的時間記錄到fireCd變量里。下次只有時間過了fireCd記錄的時間,才能開火。
6、測試一下Player的開火功能,如果沒有問題了,就再做Enemy的開火功能。方法和Player一模一樣,給Enemy也加上開火用的變量并設(shè)置好Prefab(Prefab要另外作一個子彈,不要哦和玩家用同一個),加一個同樣的Fire()函數(shù)。測試的時候可以同樣做成鼠標(biāo)點擊時候Enemy開火,測試OK以后就刪掉測試代碼。
效果如下圖:

7、準備工作基本完成。本節(jié)代碼較多,難免有疏漏。本文最后會放上工程下載地址,對照著做一遍可以解決99%的問題。
2、設(shè)計AI狀態(tài)機
作為一個教學(xué)用的例子,還是先看看最終效果,否則可能不知道我在說什么(??ω?)?? 。

可以看到,敵人AI具有的功能:
1、隨時探測,“看見”玩家。
2、發(fā)現(xiàn)玩家后,射擊,如果玩家遠離就追擊。
3、離開原始范圍一定范圍后,就回到守門的位置。
這個例子是已經(jīng)看到效果的,其實設(shè)計的時候還是要仔細想想才能做,利用完整的可能性圖(Possibility Map)來幫我們設(shè)計:

可以看到我們隨時隨地都可能有不止一個選擇(這讓我想起了存在主義 ( ̄. ̄))。去掉一些絕無可能的選擇(比如發(fā)現(xiàn)了敵人,我還待機不動),剩下一些就可能都是有道理的。通過多次過濾,從僅有的幾種選擇里挑出最合適的,離成功就近了一半。這個例子比較簡單,相信大家看看就明白了每種情況下最好的選擇只有一種。而當(dāng)游戲比較復(fù)雜的時候,可以玩花樣的地方就多了,嗯(?(?)?)。
最終我們得到了一個簡單的狀態(tài)與狀態(tài)轉(zhuǎn)移設(shè)計圖,也就是狀態(tài)機圖:

3、實現(xiàn)狀態(tài)機AI
以下講解不再是手把手教的方式了,因為代碼量比較大,希望讀者著重理解過程。具體代碼可以打開工程參考。以下代碼均寫在Enemy.cs里面
1、如何定義狀態(tài)。使用C# enum枚舉可以方便地定義狀態(tài)。
public enum State
{
Idle, // 待命狀態(tài)
Attack, // 進攻敵方
Back, // 回歸原位
Dead, // 死亡
}
public State state = State.Idle; // AI當(dāng)前狀態(tài)
GameObject invader = null; // 入侵者GameObject
我們定義了4種狀態(tài),順便用一個State類型的變量state表示當(dāng)前狀態(tài);另外進攻狀態(tài)一定和入侵者有關(guān),要在發(fā)現(xiàn)入侵者時,把入侵者的GameObject保存下來。
2、一系列工具函數(shù),對理解游戲中的3D運算非常有幫助,可以仔細看看。后面用到再回來參考。
// 是否正在面對入侵者,即已經(jīng)正確瞄準
bool IsFacingInvader()
{
if (invader == null)
{
return false;
}
Vector3 v1 = invader.transform.position - transform.position;
v1.y = 0;
// Vector3.Angle獲得的是一個0~180度的角度,和參數(shù)兩個向量順序無關(guān)
if (Vector3.Angle(transform.forward, v1) < 1)
{
return true;
}
return false;
}
// 轉(zhuǎn)向入侵者方向,每次只轉(zhuǎn)一點,速度受turnSpeed控制
void RotateToInvader()
{
if (invader == null)
{
return;
}
Vector3 v1 = invader.transform.position - transform.position;
v1.y = 0;
// 結(jié)合叉積和Rotate函數(shù)進行旋轉(zhuǎn),很簡潔很好用,建議掌握
// 使用Mathf.Min(turnSpeed, Mathf.Abs(angle))是為了嚴謹,避免旋轉(zhuǎn)過度導(dǎo)致的抖動
Vector3 cross = Vector3.Cross(transform.forward, v1);
float angle = Vector3.Angle(transform.forward, v1);
transform.Rotate(cross, Mathf.Min(turnSpeed, Mathf.Abs(angle)));
}
// 轉(zhuǎn)向參數(shù)指定的方向,每次只轉(zhuǎn)一點,速度受turnSpeed控制。這里有點不夠嚴謹,參考上面的方法
void RotateToDirection(Quaternion rot)
{
Quaternion.RotateTowards(transform.rotation, rot, turnSpeed);
}
// 是否正位于某個點, 注意float比較時絕不能采用 == 判斷
bool IsInPosition(Vector3 pos)
{
Vector3 v = pos - transform.position;
v.y = 0;
return v.magnitude < 0.05f;
}
// 移動到某個點,每次只移動一點。也不嚴謹,有可能超過目標(biāo)一點點
void MoveToPosition(Vector3 pos)
{
Vector3 v = pos - transform.position;
v.y = 0;
transform.position += v.normalized * moveSpeed * Time.deltaTime;
}
注意其中的v.y=0這句話,因為敵人高度可能和Player高度不一致,導(dǎo)致向量的Y軸方向不是0,特地處理一下,這個問題會導(dǎo)致計算失誤,干擾了我很久 (#`皿′)。
另外可以看到注釋里已經(jīng)指出了可能有問題的點,讀者閱讀時要思考應(yīng)該怎么改才能更好,關(guān)鍵是要利用Mathf.Min防止轉(zhuǎn)動太多或移動太多。
3、嚴格按照設(shè)計,在Update函數(shù)中,針對當(dāng)前的每種狀態(tài),實現(xiàn)相應(yīng)效果。注意在我的設(shè)計中,與敵人距離過遠或者離開原始位置過遠都要回家:
void Update() {
if (state == State.Dead)
{
return;
}
if (state == State.Idle)
{
// 方向不對的話,轉(zhuǎn)一下
transform.rotation = Quaternion.RotateTowards(transform.rotation, baseDirection, turnSpeed);
}
else if (state == State.Attack)
{
if (invader != null)
{
if (Vector3.Distance(invader.transform.position, transform.position) > maxChaseDist)
{
// 與敵人距離過大,追丟的情況
state = State.Back;
return;
}
if (Vector3.Distance(basePosition, transform.position) > maxLeaveDist)
{
// 離開原始位置過遠的情況
state = State.Back;
return;
}
if (Vector3.Distance(invader.transform.position, transform.position) > maxChaseDist/2)
{
// 追擊敵人
MoveToPosition(invader.transform.position);
}
// 轉(zhuǎn)向敵人
if (!IsFacingInvader())
{
RotateToInvader();
}
else
{// 開火
Fire();
}
}
}
else if (state == State.Back)
{
if (IsInPosition(basePosition))
{
state = State.Idle;
return;
}
MoveToPosition(basePosition);
}
DrawFieldOfView();
}
第一次讀這段代碼,要關(guān)注整體,看清楚每種狀態(tài)之間是如何實現(xiàn)轉(zhuǎn)移的。還有一部分轉(zhuǎn)移漏了,在視野射線發(fā)現(xiàn)Player的地方:
void OnEnemySpotted(GameObject enemy)
{
invader = enemy;
state = State.Attack; // 發(fā)現(xiàn)玩家,進入攻擊狀態(tài)
}
讀這些代碼的時候,一要看每種狀態(tài)下,應(yīng)當(dāng)做什么事;二要看一種狀態(tài)在什么時候轉(zhuǎn)換到另一種狀態(tài)。我在寫這些代碼時,BUG往往發(fā)生在state == State.Attack這種情況下,因為攻擊狀態(tài)實際上有幾種子情況,根據(jù)maxChaseDist和maxLeaveDist來判斷是否要繼續(xù)追擊還是回去,而一旦轉(zhuǎn)換狀態(tài)就return,這一幀立即結(jié)束,這樣可以簡化代碼避免BUG。在同一幀內(nèi)多次轉(zhuǎn)換狀態(tài)其實也可以做到,但是非常燒腦 ( _ _)ノ|。
4、補充一些漏掉的變量。另外敵人需要一開始記錄好自己的出生位置,以便回去。
public float moveSpeed = 1.0f; // 移動速度
public float turnSpeed = 3.0f; // 轉(zhuǎn)身速度
public float maxChaseDist = 11.0f; // 最大追擊距離
public float maxLeaveDist = 2.0f; // 最大離開原位距離
Vector3 basePosition; // 原始位置
Quaternion baseDirection; // 原始方向
初始化時記錄出生位置和面對方向
void Start () {
basePosition = transform.position;
baseDirection = transform.rotation;
}
5、多測試一下吧,如果有問題請參考下載的工程。
4、總結(jié)
本節(jié)在寫作時,明顯感覺到由于難度提升,很難一步一步描述清楚整個操作過程,需要讀者動手實踐,遇到問題并解決后才能理解。
本章的例子編寫難度也較大,本人在編寫時在狀態(tài)判斷的細節(jié)方面發(fā)現(xiàn)了很多問題,大部分都解決了。某些情況,比如后退時又發(fā)現(xiàn)了玩家這種情況,就比較難處理。如果處理好代碼量會繼續(xù)膨脹,好在后果并不嚴重,不影響介紹狀態(tài)機的使用。下節(jié)講增強AI時必定會仔細處理這些問題(因為不處理不行,會影響效果 _(:3 」∠)_)。
本章示例工程下載:
https://github.com/mayao11/PracticalGameAI/tree/master/AI_Enemy1
如果你討厭一個程序員,就讓他去做AI,因為那會讓他抓狂。
如果你喜歡一個程序員,就讓他去做AI,那會讓他飛速成長。
如果你不信,那么咱們就下期見。
————————————————————————————————————
對游戲開發(fā)感興趣的同學(xué),歡迎圍觀我們:【皮皮關(guān)游戲開發(fā)教育】 ,會定期更新各種教程干貨,更有別具一格的線下小班教育。在你學(xué)習(xí)進步的路上,有皮皮關(guān)陪你!~
我們的官網(wǎng)地址:http://levelpp.com/
我們的游戲開發(fā)技術(shù)交流群:610475807
我們的微信公眾號:皮皮關(guān)