最美情侣中文字幕电影,在线麻豆精品传媒,在线网站高清黄,久久黄色视频

歡迎光臨散文網(wǎng) 會員登陸 & 注冊

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

2017-09-13 18:21 作者:皮皮關(guān)做游戲  | 我要投稿

前一節(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)地址:levelpp.com/

我們的游戲開發(fā)技術(shù)交流群:610475807

我們的微信公眾號:皮皮關(guān)


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

分享到微博請遵守國家法律
房山区| 彰化县| 黄梅县| 错那县| 南昌市| 贵德县| 苍梧县| 师宗县| 孝昌县| 灯塔市| 长武县| 百色市| 邵阳市| 舒城县| 陈巴尔虎旗| 上栗县| 隆化县| 库车县| 巩留县| 科尔| 手机| 利川市| 张北县| 铜梁县| 高平市| 武隆县| 乌审旗| 云阳县| 黄平县| 巴彦县| 信宜市| 天镇县| 东宁县| 习水县| 通化县| 保靖县| 隆德县| 平昌县| 佛学| 濉溪县| 六枝特区|