【Unity入门教程】 第八章 人工智能【中国大学MOOC游戏引擎原理及应用】
以下均为来自中国大学mooc 游戏引擎原理及应用时的学习笔记,不含商用,仅供学习交流使用,如果侵权请联系作者删除。
8.1 自动寻路


dos操作时代的人工智能

英雄无敌3这样的回合策略制游戏

红警这样的即时战略很考虑人工智能





我们先建立如图所示的物体

并将其的属性设置为静态。因为将来的导航物体需要在这些物体上运动,这些物体只有成为静态物体,才能被烘焙成导航网络。

接下来调出这个窗口

我们可以在导航这里看到有三个是设定好的,分别是可以走,不可走和跳跃,然后右边是开销
也可以添加自定义的区域然后进行设置
这些取区域和agent配合设置可以达到特定的效果
agent里面可以设置区域遮蔽,让角色不能在某些特定的区域中移动,以此来区别对待敌人
object可以对选中的物体进行导航的相关设置


如果点选上navigation static 则非静态物体也会被烘培
接下来我们看看bake
将下面这几个参数调整成合适的,并进行烘培,可以看到效果如图,蓝色部分为可以行走的区域

然后添加一个被导航的物体,我们创建一个胶囊体,并给他添加上被导航的代理体
写入以下脚本
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class navi : MonoBehaviour
{
private NavMeshAgent agent;
// Start is called before the first frame update
void Start()
{
agent = GetComponent<NavMeshAgent>();
}
// Update is called once per frame
void Update()
{
if (Input.GetMouseButton(0))
{
RaycastHit hit;
if(Physics.Raycast(Camera.main.ScreenPointToRay(Input.mousePosition),out hit, 100)){
//这条语句完成了两个事情 一个是发射出一条射线,(如果有射线则进入if)并且把这条语句out 也就是输出给hit,查询多远的距离呢,是100个单位
agent.destination = hit.point;
}
}
}
}
拖拽给物体便可以看到物体会自动走到鼠标点击的地方

有时候平台无法跳上去 我们可以使用jump功能

如图所示我们新建两个空物体,并且将两个空物体置于平台底部和高处
我们给它添加一个组件


然后移动到这上面
这时候再运行游戏场景便可以使得它进行跳跃
接下来介绍一下动态的阻挡物
如果我们直接添加一个物体(非静态) 那么这个物体是不会影响物体自动寻路的


直接通过 无法阻止
但是路过我们给胶囊体设置为刚体 则物体在寻路的时候不会绕开这个物体而寻找其他路线,而是直接遵循两点之间线段最短的路来走,但是此时就会出现这个物体一直撞着这个物体而很难走开,就一直日墙怼墙

为了让这种物体也能参与寻路的影响,我们给这个方块添加一个这样的组件

这时候这个物体就会阻止导航体穿过去,起到阻挡的作用
8.2 巡逻


我们还是用之前的地形 别忘记对可以走路的区域进行烘培
然后创建一个空物体 下面有四个空物体 分别用来表示位置


然后我们创建一个胶囊物体 给他带上nav mesh agent的这个导航组件
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class AIpartrol : MonoBehaviour
{
// Start is called before the first frame update
public float patrolSpeed = 2f;
public float patrolWaitTime = 1f;
public Transform patrolWayPoints;
private NavMeshAgent agent;
private float patrolTimer;//这个用来记录当敌人达到路径点的时候 停留时间是多久
private int wayPointIndex;//记录当前目标点是哪个路径点
void Start()
{
agent = GetComponent<NavMeshAgent>();
}
// Update is called once per frame
void Update()
{
Patrolling();
}
void Patrolling()
{
agent.isStopped = false;
agent.speed = patrolSpeed;
if (agent.remainingDistance<=agent.stoppingDistance)//由于可能这个物体并不是一分不差的到目的点的,我们可以用用一个范围表示冗余
//当到达目的点时开始计算时间
{
patrolTimer += Time.deltaTime;
if (patrolTimer >= patrolWaitTime)//如果等待时间已满,则进入下一个点
{
if (wayPointIndex == patrolWayPoints.childCount - 1)
{
wayPointIndex = 0;
}
else
{
wayPointIndex++;
}
patrolTimer = 0;//由于切换完成 所以我们将这个time归零计算
}
//这里没有else 是因为如果没=等待时间没满则什么都不会做 只是单单的增加事件
}
else
{
patrolTimer = 0;
}
agent.destination = patrolWayPoints.GetChild(wayPointIndex).position;//Getchild方法可以获得这个东西的子物体
//括号里面跟的是下标
}
}
8.3 视野
以前两节的内容为基础,我们给Enemy添加一个子物体空物体,来表示其碰撞体积的组件


然后注意要给这个box collider添加上trigger的组件
然后写下以下代码 将这个代码挂接到这个子物体而不是这个Enemy上
因为发生OnTrigger函数的应该是那个碰撞的空物体,如果挂接到母物体上,虽然会进入OnTrigger函数但是无法检测到if (other.gameObject == Player)这条语句
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class View : MonoBehaviour
{
public float fieldOfViewAngle = 120f;//视角范围
public bool playerInSight;//
public Vector3 personalLastSight;//用来表示最后主角所在的位置
public static Vector3 resetPos = Vector3.back;
BoxCollider col;//这个组件上的碰撞检测器组件
public GameObject Player;
// Start is called before the first frame update
void Start()
{
col = GetComponent<BoxCollider>();
//player = GameObject.FindGameObjectWithTag("Player");//除了可以用这个方法还可以使用public在游戏界面,然后将物体拖拽进来的方法
//if(player.tag=="Player") Debug.Log("There is a player");
personalLastSight = resetPos;
//Debug.Log("Start");//为了看游戏执行的情况设置的调试语句
}
// Update is called once per frame
void Update()
{
}
private void OnTriggerStay(Collider other)
{
Debug.Log("OnTriggerStart");
if (other.gameObject == Player)
{
Debug.Log("PlayerInTrigger");
playerInSight = false;
Vector3 direction = other.transform.position - transform.position;//建立一个位置差的向量
float angle = Vector3.Angle(direction, transform.forward);//以敌人的正前方和那个位置
//差向量来获得角度
//Debug.Log(angle);
if (angle < fieldOfViewAngle * 0.5f)//如果说这个角度是在敌人开阔视野120度以内的话 说明进入视野范围内
{
Debug.Log("InSightStart");
RaycastHit hit;//但是在视野范围内不代表能看到 因为可能会被障碍物挡住,我们需要
//发出一条射线,然后判断射线能否打到玩家身上,如果可以说明能被看见
if (Physics.Raycast(transform.position,direction.normalized,out hit,col.size.z))
//以transform.positon代表发射的起点,direction代表发射的方向,normalized代表单位化,out hit代表将这条射线输出到hit,col.size.z代表长度
{
if (hit.collider.gameObject == Player)
{
Debug.Log("I saw the player!");
playerInSight = true;
personalLastSight = Player.transform.position;
}
}
}
}
}
private void OnTriggerExit(Collider other)//如果玩家出去了那个碰撞体就代表无法被看见
{
if (other.gameObject == Player)
{
playerInSight = false;
}
}
}
然后当player进入敌人的视野的时候,就会在console提示“I saw the player!”

8.4 自动攻击
我们还是以上面的场景为基础,然后我们新建一个子弹的预制体,并给它添加上几个组件

然后给球添加一个恒力

接下来我们要给子弹写一个脚本 当子弹碰到玩家的时候 销毁子弹和玩家
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class AIEnemyBullet : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
}
private void OnTriggerEnter(Collider other)
{
if (other.gameObject.tag == "Player")
{
Destroy(other.gameObject);
Destroy(gameObject);
}
}
}
接下来我们需要给敌人的巡逻脚本中添加如下的函数
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class patrol : MonoBehaviour
{
// Start is called before the first frame update
public float patrolSpeed = 2f;
public float patrolWaitTime = 1f;
public Transform patrolWayPoints;
private NavMeshAgent agent;
private float patrolTimer;//这个用来记录当敌人达到路径点的时候 停留时间是多久
private int wayPointIndex;//记录当前目标点是哪个路径点
//接下来这里是和射击有关的函数
public float shootRotSpeed = 5f;
public float shootFreeTime = 2f;//枪的冷却时间
public float shootTimer = 0f;//时间计数器
private View enemySight;//这个是之前那个视野的组件,用它来得到当前玩家是否在视野中
public Rigidbody bullet;//我们需要子弹这个预制体 在发射的时候实时生成出来 因此需要获得这个预制体
private Transform player;
bool chase;//追踪状态
void Start()
{
agent = GetComponent<NavMeshAgent>();
enemySight = transform.Find("ViewRange").GetComponent<View>();//获得viewrange这个组件上的View这个脚本
//ViewRange是敌人的子物体 就是那个空物体然后具有trigger和体积 表示视野范围的物体,而view是这个子物体的脚本
player = GameObject.FindGameObjectWithTag("Player").transform;//获得物体
}
// Update is called once per frame
void Update()
{
if (enemySight.playerInSight)
{
Shooting();
}
else if (chase)
{
//追踪
}
else Patrolling();
}
void Shooting()
{
Vector3 lookPos = player.position;//玩家的位置保存下来
lookPos.y = transform.position.y;//将射出枪的位置改为和玩家一样的高度
//避免在射击的时候还有抬头和点头的情况出现
Vector3 targetDir = lookPos - transform.position;//发射的方向就是目标减去当前的位置
//但是发射的时候 我们需要先转到我们看的方向
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(targetDir),
Mathf.Min(1,Time.deltaTime*shootRotSpeed));//球面线性插值
//Min函数是在两者间去最小的 一个是1 另外一个就是旋转的速度乘以时间
//到1的时候就完全转过去了,否则它就是慢慢的过渡过去
agent.isStopped = true;//因为我们要射击 所以此时要停下来
//接下来判断此时转过去的角度是否达到了适合的角度
//如果说
if (Vector3.Angle(transform.forward, targetDir) < 2)
{
if (shootTimer>shootFreeTime)//判断时间间隔是否大于冷却时间了
{
Instantiate(bullet, transform.position, Quaternion.LookRotation(player.position - transform.position));
//上面的第二个参数应该使用枪的位置而不是人的位置会更准确一些
//使用四元数的方法 lookrotation 把一个向量转变为四元数,那么这个向量就是朝向的向量
shootTimer = 0;
}
shootTimer += Time.deltaTime;
}
}
void Chasing()
{
}
void Patrolling()
{
agent.isStopped = false;
agent.speed = patrolSpeed;
if (agent.remainingDistance <= agent.stoppingDistance)//由于可能这个物体并不是一分不差的到目的点的,我们可以用用一个范围表示冗余
//当到达目的点时开始计算时间
{
patrolTimer += Time.deltaTime;
if (patrolTimer >= patrolWaitTime)//如果等待时间已满,则进入下一个点
{
if (wayPointIndex == patrolWayPoints.childCount - 1)
{
wayPointIndex = 0;
}
else
{
wayPointIndex++;
}
patrolTimer = 0;//由于切换完成 所以我们将这个time归零计算
}
//这里没有else 是因为如果没=等待时间没满则什么都不会做 只是单单的增加事件
}
else
{
patrolTimer = 0;
}
agent.destination = patrolWayPoints.GetChild(wayPointIndex).position;//Getchild方法可以获得这个东西的子物体
//括号里面跟的是下标
}
}
然后便可以看到敌人会向玩家进行射击

//但是上面有几个缺陷
首先射出子弹的位置应该是枪的位置而不是人的位置
还有射枪的冷却时间不应该是在敌人准备射击中才增加的 应该是在时时刻刻都增加 因此应该把shootTimer += Time.deltaTime;这条语句放到update函数当中
代码中的remaining distance解释:
在《Unity官方案例精讲》里面提到可以用remainingDistance来判断移动是否结束,remainingDistance也就是(距离终点)剩余移动距离,让它跟stopingDistance(结束距离)进行比较
8.5 追踪

(下面会出现的东西
Vector3.sqrMagnitude 是指长度的平方,也就是Vector3.magnitude的平方
计算向量大小的平方会比计算向量的大小要快很多,因为向量的大小由勾股定理得出,所以有开方操作,如果只是单纯的比较两个向量的大小,可以使用sqrMagnitude会快很多。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class patrol : MonoBehaviour
{
// Start is called before the first frame update
public float patrolSpeed = 2f;
public float patrolWaitTime = 1f;
public Transform patrolWayPoints;
private NavMeshAgent agent;
private float patrolTimer;//这个用来记录当敌人达到路径点的时候 停留时间是多久
private int wayPointIndex;//记录当前目标点是哪个路径点
//接下来这里是和射击有关的函数
public float shootRotSpeed = 5f;
public float shootFreeTime = 3f;//枪的冷却时间
public float shootTimer = 0f;//时间计数器
private View enemySight;//这个是之前那个视野的组件,用它来得到当前玩家是否在视野中
public Rigidbody bullet;//我们需要子弹这个预制体 在发射的时候实时生成出来 因此需要获得这个预制体
private Transform player;
bool chase;//追踪状态
public float chaseSpeed = 5f;//追踪的速度
public float chaseTimer = 0;//等待时间的计数器
public float chaseWaitTime = 3.5f;//追踪完后等待的时间
private float sqrPlayerDist = 2f;//用平方表示距离 追踪到敌人附近的时候
//会保持一定距离 也就是彼此间对峙的距离 而不是就直接贴脸追到脸上
void Start()
{
agent = GetComponent<NavMeshAgent>();
enemySight = transform.Find("ViewRange").GetComponent<View>();//获得viewrange这个组件上的View这个脚本
//ViewRange是敌人的子物体 就是那个空物体然后具有trigger和体积 表示视野范围的物体,而view是这个子物体的脚本
player = GameObject.FindGameObjectWithTag("Player").transform;//获得物体
}
// Update is called once per frame
void Update()
{
shootTimer += Time.deltaTime;
if (enemySight.playerInSight)
{
Shooting();
//Chasing();如果想要在进入视野的时候不仅是停下来射击,而且还会继续追逐的话,我们则取消注释这个函数
chase = true;//如果曾经进入了视野,则将追踪设置为true
}
else if (chase)//如果下次没有进入到上面到insight 说明目标丢失,此时则会进入巡逻状态,
//然后追踪设置为false,但是如果巡逻的时候再次进入视野,则会重新触发上面那个函数
{
Chasing();
}
else Patrolling();
//在这里我们可以看到 通过在update中设置三种状态,一种是进入视野的状态,然后如果进入视野的话
//则会开启追踪状态的选择,如果下次没进入第一个状态则会进入第二个状态
//在第二个状态:追踪状态的时候,如果追踪失败,则会将这个状态置false
//那么如果上面两种状态都没进去,则会自动进入巡逻状态
//要注意的是Update的随着时间会不断重新执行的,因此我们只需要设置进入状态的条件即可,然后在执行update
//的时候便会重新进入新的新的状态了
}
void Shooting()
{
Vector3 lookPos = player.position;//玩家的位置保存下来
lookPos.y = transform.position.y;//将射出枪的位置改为和玩家一样的高度
//避免在射击的时候还有抬头和点头的情况出现
Vector3 targetDir = lookPos - transform.position;//发射的方向就是目标减去当前的位置
//但是发射的时候 我们需要先转到我们看的方向
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(targetDir),
Mathf.Min(1,Time.deltaTime*shootRotSpeed));//球面线性插值
//Min函数是在两者间去最小的 一个是1 另外一个就是旋转的速度乘以时间
//到1的时候就完全转过去了,否则它就是慢慢的过渡过去
agent.isStopped = true;//因为我们要射击 所以此时要停下来
//接下来判断此时转过去的角度是否达到了适合的角度
//如果说
if (Vector3.Angle(transform.forward, targetDir) < 2)
{
if (shootTimer>shootFreeTime)//判断时间间隔是否大于冷却时间了
{
//Instantiate(bullet, transform.position, Quaternion.LookRotation(player.position - transform.position));
//上面的第二个参数应该使用枪的位置而不是人的位置会更准确一些
//使用四元数的方法 lookrotation 把一个向量转变为四元数,那么这个向量就是朝向的向量
shootTimer = 0;
}
}
}
void Chasing()
{
agent.isStopped = false;
Vector3 sightingDeltaPos = enemySight.personalLastSight - transform.position;//获得巡逻的物体应该走的方向向量
if (sightingDeltaPos.sqrMagnitude > sqrPlayerDist)//如果这个距离大于设定好的阈值的话则需要继续追踪
//为什么要设置这个if如果这个距离大于设定好的阈值的话则需要继续追踪呢?是因为在这里Update是一个一帧一次的函数
//整个追击的过程是由很多帧 也就是执行了很多次Chasing函数才实现的,而不是一次函数就能完成,因此需要每一帧都不断重新判定是否
//其次 这个阈值是什么呢 就是追踪结束时候,玩家与敌人设置的距离,如果达到了这个距离 则不会再往前走
//可以理解为拿着手枪追杀敌人的时候,就算是对峙的时候,也会需要说离开一段距离然后开枪进行对峙,而不是直接贴脸
//(当阈值设置很小的时候就等于贴脸了)
//但是实际执行起来发现不是这样子 为什么呢?是因为如果进入视野的时候,我们不会执行追逐而是执行停下来射击的函数,
//所以说还没达到对峙距离的阈值则会进入视野
{
agent.destination = enemySight.personalLastSight;//设定目的地为最后一次出现的地方
}
agent.speed = chaseSpeed;//速度由巡逻速度切换到追踪速度
if (agent.remainingDistance <= agent.stoppingDistance)//最终到达目的地时
//reaminingDistance是导航体自带的函数,是计算与目的地的距离的值
//为了防止比如说旁边有个东西卡住了目的地的左边,这时候则会出现物体永远无法到达目的地的情况,我们设置了与目的地相比 允许的误差冗余
{
chaseTimer += Time.deltaTime;
if (chaseTimer >= chaseWaitTime)
{
chase = false;//如果超过等待时间,则自动结束追踪状态
chaseTimer = 0f;
}
}
else
{
chaseTimer = 0;
}
}
void Patrolling()
{
agent.isStopped = false;
agent.speed = patrolSpeed;
if (agent.remainingDistance <= agent.stoppingDistance)//由于可能这个物体并不是一分不差的到目的点的,我们可以用用一个范围表示冗余
//当到达目的点时开始计算时间
{
patrolTimer += Time.deltaTime;
if (patrolTimer >= patrolWaitTime)//如果等待时间已满,则进入下一个点
{
if (wayPointIndex == patrolWayPoints.childCount - 1)
{
wayPointIndex = 0;
}
else
{
wayPointIndex++;
}
patrolTimer = 0;//由于切换完成 所以我们将这个time归零计算
}
//这里没有else 是因为如果没=等待时间没满则什么都不会做 只是单单的增加事件
}
else
{
patrolTimer = 0;
}
agent.destination = patrolWayPoints.GetChild(wayPointIndex).position;//Getchild方法可以获得这个东西的子物体
//括号里面跟的是下标
}
}
如果想要在玩家进入视野的时候不仅是停下来射击还有追逐的话 可以在update的进入视野的选项中添加Chasing函数
发现意外有趣的一点是对于追踪函数,如果稍作修改,便可以模拟出使得其变成小鸭子找妈妈的跟屁虫的感觉
首先将产生子弹预制体的函数取消
然后设置进入可以穿过障碍物来发射射线(这是为了防止跟在玩家后面的第一个物体会挡住后面物体的视线导致无法跟踪到的情况,应该还会有更好的办法进行优化设置的




一个小tips 如果暂时不想使菜单栏中的物体上场但又不想消除的话可以这样:取消选中该物体

又是一个总结的小tips
在游戏运行到一半的时候暂停 然后实时的修改代码的话 那会对游戏产生影响吗?
实际测试是没有产生影响 这和修改游戏内物体的位置大小还有参数都不太一样,实时修改位置大小和参数即时产生影响
还有一个小tips
想要通过修改参数看游戏游戏的变化的时候 可以直接给这个变量public然后就可以放到旁边通过实时修改值来判断游戏的变化
还有一点我们可以发现的是,当敌人追逐到这个这个空盒子(表示视野范围)的界限的时候则不会再继续往前走了

但是以上的追踪函数都有一个很大的缺陷,就是当敌人追逐到玩家最后出现的位置的时候,敌人不会进行自动转向,(也就是摇头查看周围的环境情况),只会看头所朝向的那个方向,如果玩家突然转变方向,虽然此时还在敌人的附近,但是不在敌人的forward方向的话,则敌人无法探测到(明明只需要敌人稍微转头就能看到了)这个方法的话有待以后优化吧。
还有个缺陷就是当人物走到敌人身后甚至贴上去的时候 按理来说也应该被发现了 然后此时敌人应该要转身追逐
但是由于本函数只追逐眼前的敌人,因此之后可能需要通过设置当玩家在敌人附近的时候就会转身追逐的函数吧
又发现了一个缺陷:当物体第一次离开视野时候,Chase Timer也就是等待时间的计数器会不断增加,可是当物体再次进入视野的时候,Chase
Timer不会自动清零,意味着下次继续计算等待时间的计数器的时候会再次从上一次开始,比如我原来等待了3秒,等待时间上限是5秒,那么下次我再次遇见然后再次玩家逃开的时候,等待时间计数器会从3秒开始计算,那么再等待两秒它就会自动返回巡逻了

说实话这里还是有点没搞懂,红框里是原来我自己写的解析,我以为那个对峙距离,但是实际测试后发现对峙距离是敌人的碰撞检测的体积的那个距离,所以这个
if (sightingDeltaPos.sqrMagnitude > sqrPlayerDist)
这个语句到底代表什么意思我还是有点没搞清楚,有待日后回来修正吧

8.6 机器学习





通过下载插件MLagent,还可以通过Ptyhon api对agent或者智能体进行训练
机器学习功能unity封装的比较好,学习起来不是很难
但是需要几个准备工作
首先下载这个安装包



需要把这些克隆下来到本地
然后编辑环境变量



建立好虚拟环境后输入这行命令查看配置

输入这行命令,查看前面是否有ml-agents 有的话说明激活了

把路径设置到克隆下来的路径里

下载第三方的安装包

注意要在子文件夹上进行安装

然后检查一下是否都安装好了

使用unity进入github示例工程 运行
可以看到这些盒子正在学习如何平衡小球

可以看到命令提示符这里

最后一行可以看到学习后的文件的存放地址:

其实这个unity包中里也有自带学习后的文件,将其放入

也可以将我们自己刚才学习好的文件放到指定目录下,然后使用这个文件
接下来可以可以自己新建一个模型并对它训练
创建一个新场景 然后创建空物体 为其挂接这个脚本

创建一个立方体和小球

并把正方体改名叫agent

为小球挂接刚体组件
为立方体挂接这三个脚本

观察脚本这里可以看到有8个浮点数

所以这个参数需要设置为8
并且修改这几个参数

这里需要将小球拖拽过来

进入cmd窗口,进入指定目录并且进入训练的环境

注意这里的名字并不需要与unity里面任何物体的名字相同
按下回车后看到unitylogo则代表进入环境成功,然后可以开始运行游戏


游戏运行时可以看到命令行窗口如图 它会显示当前训练的结果如何,小球的得分怎么样

Unity结束运行时,它便会把文件保存下来
训练完毕后便可以在cube的这个组件下挂接好学习好了的模型

接下来运行就可以看到方块按照学习好的模型,努力的平衡小球了。

但是由于实际不够长,学习的模型也不够多所以成果可能并不太好
更多推荐



所有评论(0)