作者注
应 indienova 邀请,转自本人的知乎专栏,此文为 AI 系列的第二篇。如有任何疑问欢迎各位朋友指出。
前言、AI行为综述
第一讲的内容过于简单,就当是熟悉一下 Unity 开发基础好了。这次正式发车。
作为一个称职的游戏AI,要具有以下自我修养,可以不包含全部:
- 明白自己能干什么——目前所有可以做的行为,即可能性图 Possibility Map。
- 认识到当前状况,对一般的 AI 来说,直接读取游戏数据即可;高级AI有视觉、听觉来感知到当前状况。
- 理解目标,分解目标,决策具体行动并执行。寻路是一种典型的决策问题。
- 了解环境,思考互动策略,比如推箱子、触发机关给玩家制造麻烦。
- 群体交互。理解其他伙伴的信息和队伍的整体策略,做出配合、防止冲突。在复数敌人的游戏中多有体现,在足球游戏中更是体现得淋漓尽致。
AI 的层次有高有低,但是行为层次的高低与编程难度有时并不成正比。比如 AI 的视觉、听觉系统就属于高级行为,但是在 Unity 中实现并不难;而如果现在就写基本的可能性图系统,你会发现 NPC 的基本移动、攻击功能还没实现,最后只能写出伪代码而无法真正实现一个功能。
作为一篇想要尽可能浅显的文章,我们从视觉系统出发,把复杂问题解构,目的是让大家有一种“不过如此”的感觉。( ̄y▽ ̄)~*
一、模拟视觉系统——原理和例子
人类的视觉系统有几个特点,比如:
- 近的看得清,远的看不清。
- 视角大约90度,视线正前方信息丰富(色彩和细节),视线外侧的部分只有轮廓和运动信息。
- 注意力有限,当关注于某个具体的方位或者物体时,其它部分被忽略(比如魔术中的障眼法对绝大多数人有效)。
作为一个 AI,可以模拟这种视觉系统,有助于干掉玩家或者……取悦玩家ㄟ( ▔, ▔ )ㄏ 。
上图是潜入类游戏里程碑式的作品《盟军敢死队》,红圈标出的是一个敌方德国兵。这个游戏是俯视的,玩家具有上帝视角。玩家在游戏中可以随意查看敌人的视野范围(虽然这有点不符合实际),敌人的视野是一个巨大的三角形,视线角度约90度;视野分为两段,近处是亮绿色,远处是暗绿色,在亮绿色范围内一定会引起注意,而暗绿色的部分由于敌人看不太清楚,所以我方的人员只要趴下就不会引起注意。
由于敌人众多,视线错综复杂,这个游戏的难度颇高,后面几关我实在打不过去(╯‵□′)╯︵┻━┻ 。
不说老古董了,举个更有人气的例子:《合金装备》系列。
合金装备2中,室内场景更多一些,敌人视角也更窄,但是从技术面分析,敌人视野的实现方式和《盟军敢死队》并没有什么不同。上图中的主角正在利用墙角进行隐蔽,等待敌人转过去时伺机击杀他。
可以说所有潜入类游戏的AI都要依赖于视觉系统,在 Unity 中实现这个效果并不难,我们来尝试一下。
二、模拟视觉系统——实现
拿出前面一节做的小例子,换位思考一下——我们做的例子里的 Player 就是敌人。
1、先制造一点氛围,把主光源 Directional Light 的强度调低,让场景昏暗下来。
2、给 Player 加上一个探照灯。右键点击 Player,Light > Spotlight。
3、以上两步应该已经能看到效果了。下面调整一下探照灯的远近、角度范围、光线强度。让它和人物的视野大概一致。
4、开始写代码实现视野。我们用射线来模拟视野。先看最终效果,再来解释代码。
如图,我们要发射一系列射线,从角色身上开始,发射到远端,形成扇形分布。使用Debug.DrawLine函数显示的射线只会出现在编辑窗口里,而不出现在Game窗口。像我这样把两个窗口并列排布可以很方便的看到效果。
给 Player 脚本增加两个变量:
public float viewRadius = 8.0f; // 代表视野最远的距离 public float viewAngleStep = 30; // 射线数量,越大就越密集,效果更好但硬件耗费越大。
增加一个函数:void DrawFieldOfView(),并在Update函数的最后面一句调用它。函数内容如下:
void DrawFieldOfView() { // 获得最左边那条射线的向量,相对正前方,角度是-45 Vector3 forward_left = Quaternion.Euler(0, -45, 0) * transform.forward * viewRadius; // 依次处理每一条射线 for (int i = 0; i <= viewAngleStep; i++) { // 每条射线都在forward_left的基础上偏转一点,最后一个正好偏转90度到视线最右侧 Vector3 v = Quaternion.Euler(0, (90.0f/viewAngleStep) * i, 0) * forward_left; // Player位置加v,就是射线终点pos Vector3 pos = transform.position + v; // 从玩家位置到pos画线段,只会在编辑器里看到 Debug.DrawLine(transform.position, pos, Color.red); } }
执行游戏,已经可以看到效果了,截图在上面可以返回去对比一下。
5、上面的射线在遇到盒子后,会传过去。现在处理一下,让视线被物体、敌人阻挡,而不会穿透。
添加两个 Layer,一个是 Enemy 层,一个是 Obstacle 层。将那几个大方块设置为 Obstacle 层也就是障碍物层,敌人物体我们还没做。前面介绍鼠标点击地面的时候已经说明了添加、设置 Layer 的方法,不再赘述。
修改脚本,实际发出 Ray 与障碍物和敌人碰撞。
void DrawFieldOfView() { // 获得最左边那条射线的向量,相对正前方,角度是-45 Vector3 forward_left = Quaternion.Euler(0, -45, 0) * transform.forward * viewRadius; // 依次处理每一条射线 for (int i = 0; i <= viewAngleStep; i++) { // 每条射线都在forward_left的基础上偏转一点,最后一个正好偏转90度到视线最右侧 Vector3 v = Quaternion.Euler(0, (90.0f / viewAngleStep) * i, 0) * forward_left; ; // 创建射线 Ray ray = new Ray(transform.position, v); RaycastHit hitt = new RaycastHit(); // 射线只与两种层碰撞,注意名字和你添加的layer一致,其他层忽略 int mask = LayerMask.GetMask("Obstacle", "Enemy"); Physics.Raycast(ray, out hitt, viewRadius, mask); // Player位置加v,就是射线终点pos Vector3 pos = transform.position + v; if (hitt.transform != null) { // 如果碰撞到什么东西,射线终点就变为碰撞的点了 pos = hitt.point; } // 从玩家位置到pos画线段,只会在编辑器里看到 Debug.DrawLine(transform.position, pos, Color.red); ; // 如果真的碰撞到敌人,进一步处理 if (hitt.transform!=null && hitt.transform.gameObject.layer == LayerMask.NameToLayer("Enemy")) { //OnEnemySpotted(hitt.transform.gameObject); } } }
成功的话应该是如下效果,视线被障碍物挡住了:
到这里效果已经有点像是盟军敢死队了……如果你觉得效果并不好,那只能先忍耐一下了。做游戏就是这样,我们看到的成品游戏都是深入优化的结果,无论程序还是美术都要做到80分才能得到好的结果。这里我们还是继续研究视野问题本身吧。
6、这个例子最后一大步是添加虚拟的敌人。其实我们控制的Player是敌人,准确来说现在要添几个虚拟的玩家,我们要去发现他们。别忘了,角色互换 (♥◠‿◠)ノ ʅ(‾◡◝)
如上图,添加几个敌人,可以在障碍前、障碍物后面都放一个,方便测试。注意圆柱体要有一定高度,也要粗一些。因为我们的射线是有高度的,我一开始放的圆柱体很矮,导致射线打不到它。另外如果圆柱太细,会从射线之间漏过去,也不好。
把这些敌人的 Layer 设置为 Enemy 层,以便和射线碰撞。
播放游戏。如图,确保射线与圆柱体也能碰撞。
7、还没完,我们要做出一种效果,让敌人被看到时才显形,平时没被发现时是隐形的。先给敌人添加脚本,内容如下:
public class Enemy : MonoBehaviour { MeshRenderer meshRenderer; // 代表被发现时的帧数(这里用帧数代表时间) public int spottedFrame = -100; void Start () { meshRenderer = GetComponent(); } void Update () { // 通过设置 spottedFrame,就可以实现隐藏或显现 if (spottedFrame >= Time.frameCount-10) { meshRenderer.enabled = true; } else { meshRenderer.enabled = false; } } }
如上图,敌人只有两个属性,meshRenderer 和 spottedFrame,看注释可以大致理解 spottedFrame 的用途,不理解没关系,我们先做完。修改 Player 的脚本,刚才 Update 最后面射线碰撞到敌人的部分我们注释掉了,把注释去掉并添加函数OnEnemySpotted
// Player.cs的Update函数……省略上面的代码 // 如果真的碰撞到敌人,进一步处理 if (hitt.transform!=null && hitt.transform.gameObject.layer == LayerMask.NameToLayer("Enemy")) { OnEnemySpotted(hitt.transform.gameObject); } } } void OnEnemySpotted(GameObject enemy) { enemy.GetComponent().spottedFrame = Time.frameCount; }
对照这里 spottedFrame 的设置方法,理解一下。当敌人被发现的时候,他会保持显形10帧,一直在视线内就一直显形。一旦它离开视线,10帧之后他就会再次隐形。
8、完成!接下来测试和修正问题吧。
总结
本专栏受到一本书《Practical Game AI Programming》的影响,例子会讲的很浅显易懂。我觉得游戏教程就应该如此,新手能跟着一步一步学习,老手可以看个思路。希望我能和大家一起实践,对游戏AI有更系统更深入的理解,肯定可以超出那本书所讲的范畴。(有一句话偷偷说:AI是国产游戏的明显短板 (/ω\)。)
注意本文最后的控制敌人隐形、显形的算法。AI实现时会有很多游戏特有的小算法,初学者学习时要注意思考哦。这些小算法属于编程的核心能力,而核心能力在AI编程中极其重要,这也是为什么国外的游戏制作团队会非常重视AI设计、重视培养AI设计师的原因。
- 官网
- 游戏开发技术交流群:610475807
- 微信公众号:皮皮关
射线的解决方法, 对于远处的小物件, 只能大幅度提高射线密度来解决吗?
考虑到实际游戏中必须还原3D的视野, 无论是模仿电视的线扫描还是直接用射线填满空间角, 要让AI能够在远处发现一个手雷的射线密度必定非常高, 这部分的性能开销不知道能不能忽视
@byzod:你好我是原作者。真做项目的时候,千万别这么干……确实效率很低的。
正确做法是,先用其它方法找到附近一个矩形区域或者圆形区域内的所有人,然后再判断它们和主角的角度关系。
简单来说是
对方向量 = 对方位置 - 主角位置。
然后判断对方向量与"主角正前方向量"的夹角。
这个扇形视角范围的计算,用向量的点乘来计算方位,再结合距离 来判断诶。
这么密集的射线。。。好可怕。。。