npc的AI是如何运作的? 从程序到策划深入谈游戏AI

作者:猴与花果山 2020-03-25
在我们玩的大多游戏中,总有这么一些角色默默无闻的陪伴着我们,这就是游戏中的npc。npc本意是non-player character,即游戏中非玩家操作的角色都是npc,包括怪物、村民等等元素都是npc。而“操作”着这些npc来陪我们进行游戏的,正是游戏AI。因此游戏AI的设计开发,是大多游戏中极为重要的一环。

(王者荣耀中的小兵,就是由游戏AI操作着的npc角色)

01 npc的AI是如何运作的?

通常我们都认为“行为树”、“状态机”组成了游戏AI,也有把GOAP(目标导向型行动计划)当做是游戏AI设计的,其实这样的概念是不对的。npc的AI是这样运行的:


就像现实中的我们思考问题一样,在游戏的每一帧(游戏运行的最小时间单位),npc们都会这么思考——首先,“我”(这个npc)有什么事情要做吗?拿出“小本子”看看工作计划,如果有事情要做,就会明确一个todoThing(现在要干什么)。有了todoThing就会仔细想一想,这个todoThing能做的成吗?因为“计划赶不上变化”,有些原本计划好可以做的事情,现在可能因为环境(circumstance)发生了变化,以至于无法进行了:


当无法进行或者原本就没有todoThing的时候,npc就会开始思考“我现在要干啥”。这里就是我们常见的“行为树”发挥作用的地方,但是不论是“行为树”、“蓝图”,实际上返回的都是一段数据,并且被记录到“小本子”里作为工作计划,然后重新开始看是否可以执行这个计划,最终开始执行具体行动。

所以“把想要做什么写进小本子”,才是AI的核心,而“行为树”只是写入“写入小本子”的“写法”之一,包括脚本代码(如Lua)、UE4的蓝图等都是“写小本子的写法”。“写小本子”这件事情得有思路,不管是蓝图还是行为树都是把设计师的思路“写进去”的过程,而GOAP提供的是一种设计游戏AI内容的思路。行为树和GOAP,只是游戏AI中最核心的一环的数据录入的思路和方式之一,因此行为树和GOAP都不等于游戏AI。

下面是代码时间

首先我们需要有一个关于角色状态的东西,它也会返回当前角色是否可以执行AI(本文所有图片的伪代码使用TypeScript):
  1. export class CharacterState{

  2.     public value : number = 0;



  3.     //这里用于返回是否能执行AI

  4.     public CanRunAI():boolean{

  5.         return (1 & this.value) == 0; //可以设计更多状态不能执行AI

  6.     }



  7.     public static STATE_STUN = 1;

  8.     public static STATE_POISONED = 1 << 1;

  9.     public static STATE_CONFUSED = 1 << 2;

  10.     //...这里可以根据游戏设计来定义更多状态

  11. }
复制代码

接着就是角色对象,在角色对象中,有一些内容是设计师需要设计的:

  1. export class Character{

  2.     //npc必然是属于角色类的,只是某些属性的值和玩家的不同而已

  3.     //此处省略AI无关的其他数据



  4.     private todoThing:Object; //这就是“小本子”

  5.     private state:CharacterState;



  6.     private teamId:number = 0;



  7.     //这里正是策划设计的重要部分,也是AI的大脑

  8.     private WhatToDo():Object{

  9.         let res = {}    //Unity推荐的做法在这里是行为树



  10.         //这里开始则是这个角色的AI执行内容

  11.         //如果你大多是if else,那就跟行为树没区别了,顶多执行效率高些



  12.         return res;

  13.     }



  14.     //这个函数是图中的“3T.想一想执行细节”

  15.     private CanDoBehave():boolean{

  16.         if (!this.todoThing) return false;

  17.         return (

  18.             //这里正是策划需要设计规则的地方

  19.             //这里的内容多和少都不坏,取决于游戏规则的复杂度

  20.             //但是如果这里依赖了其他对象,依赖的越多,设计越蹩脚。

  21.             false

  22.         );

  23.     }



  24.     //这正是AI的核心流程,也是每一帧执行的内容

  25.     public FixedUpdate(){

  26.         if (true == this.state.CanRunAI()){

  27.             if (

  28.                 !this.todoThing ||   //2F.如果“小本子”没东西

  29.                 false == this.CanDoBehave() //3T.如果“小本子”的事情做不了

  30.             ){

  31.                 this.todoThing = this.WhatToDo();   //执行AI

  32.             }

  33.             //接下来当然是具体怎么执行AI的问题了。

  34.         }

  35.     }

  36. }
复制代码

可见,“行为树”在整个AI中,是完全可以被其他方法取代的内容。

02 “群体AI”是究竟怎么回事儿?

当一个游戏AI进行“思考”,也就是准备小本子的时候,除了会参考一些自身状态,还会参考一块“小黑板”内的信息。这块“小黑板”的信息,游戏的其他系统也会写入一些必要的数据,以帮助游戏AI更好的明确自己该做什么。


这小黑板上的数据,包括两种类型的:

  • 游戏全局的一些状态:比如游戏如果有天气,那么现在是什么天气?可能NPC会需要因为下雨天,所以找房子躲起来,或者撑起伞。这些数据都是随着游戏推进,数据发生变化的时候会改写的数据,对于游戏AI而言,是只读数据。当然尽管我们举例只说了天气,但是通常情况下,游戏进程中的所有变量都应该被写在小黑板里,供NPC的AI获取信息。
  • 一些“命令”:在这个小黑板当中,也会记录一些由其他系统,比如玩家操作系统带来的命令。比如“1组去A区”就是一种命令,当NPC在思考AI的时候,会发现有一条“1组去A区”的命令,此时如果这个NPC发现自己是1组的,他就会去A区。当然,信息只是用来参考的,“不听话”的1组队员,完全是可以无视这条指令的。



我们注意到了,在“小黑板”上有一些“命令”,这个“命令”正是很多游戏中“群体AI”的核心关键所在。比如在即时战略中,玩家操作一个小队的角色移动到某个地方,就是一个“群体AI”;

(即时战略中,玩家控制一个小队保持队形驶向某处,就是一个“群体AI”)

除了玩家操作的,还有游戏中场景里刷了多个敌人,敌人与敌人之间像小组一样的协作作战;还有足球类游戏中球员之间的跑位、配合等,都是典型的“群体AI”。

(在FIFA20等足球游戏中,球员有组织的行动也是一种“群体AI”)

而“群体AI”的发起者,未必来自于一个更高级的系统(通常被认为是“游戏AI系统”),因为这个“命令”对于执行游戏AI的npc来说不是只读的数据,所以也可以由一个npc的AI发起。


但是不论是谁发起的,都应该是在列表里新增一条,而不是轻易修改已经存在的数据,当游戏运行了一定帧数之后,自动清除掉,因此每一条“命令”必不可少的数据是“持续时间”。

(即使已经有的条目,也不能“修改”而只能“新增”,因为采纳不采纳的判断,是由游戏AI根据这些信息“思考”出来的,我们只提供信息,不提供解决方案)

因此,当我们看到游戏中角色有序的群体执行某件事情的时候,通常是通过这种方式来发起“群体AI”,然后每个npc独自行动的时候恰好形成了步调一致的结果,实际上他们之间互相并没有任何“沟通”。

下面是代码时间

先是本章的主角——小黑板:
  1. //这是小黑板上的command的结构

  2. export class BlackBoardCommand{

  3.     public executorKeys:Array<string> = []; //期望这个指令的执行者的key

  4.     public tick:number = 1; //持续多少帧



  5.     //这是要执行的内容的具体数据约定,所以是灵活的东西,但他必须是一个数据结构,而不能是any

  6.     public command:Object = {};  

  7. }



  8. //小黑板

  9. export class Blackboard{

  10.     //游戏运行时候暴露给AI的一些数据,这些数据对于AI只读,本文省去get set

  11.     private runtimeData:Object;



  12.     //这里是接收到的命令,外部只能新增,所以也用private,本文省去get set

  13.     private commands:Array<BlackBoardCommand>;



  14.     //根据执行这关键字,过滤出所有相关的命令

  15.     public GetCommandsByExecutorKey(executorKey:string):Array<Object>{

  16.         let res = new Array<Object>();



  17.         if (this.commands){

  18.             this.commands.forEach((cmd, index)=>{

  19.                 if (cmd.executorKeys.indexOf(executorKey) >= 0){

  20.                     res.push(cmd);

  21.                 }

  22.             });

  23.         }



  24.         return res;

  25.     }



  26.     //每一帧都会执行这个,来管理commands

  27.     public FixedUpdate(){

  28.         if (this.commands && this.commands.length > 0){

  29.             let i = 0;

  30.             while (i < this.commands.length){

  31.                 if (

  32.                     --this.commands[i].tick < 0

  33.                 ){

  34.                     this.commands.splice(i, 1);

  35.                 }else{

  36.                     i++;

  37.                 }

  38.             }

  39.         }

  40.     }

  41. }



  42. //整个游戏只有一个

  43. var GameBlackBoard:Blackboard = new Blackboard();

  44. 一个听话的NPC会这么执行“群体AI”,在Character对象中,我们进行了一些小小的变化:

  45. //首先我们加入了一个小队id,这当然不是所有游戏都需要的,看设计需求

  46.     private teamId:number = 0;



  47.     //这里正是策划设计的重要部分,也是AI的大脑

  48.     private WhatToDo():Object{

  49.         let res = {}    //Unity推荐的做法在这里是行为树



  50.         //这里开始则是这个角色的AI执行内容

  51.         //如果你大多是if else,那就跟行为树没区别了,顶多执行效率高些



  52.         //我是绝对服从命令的好孩子,组织让我去那儿我去哪儿

  53.         //所以在最后我会判断是否有command要我移动

  54.         //按照约定,应该是带有"teamX"的是要我做的事情

  55.         let commandMoves = GameBlackBoard.GetCommandsByExecutorKey("team" + this.teamId);

  56.         if (commandMoves.length > 0){

  57.             //这里就是根据命令来重新定义结果了,这是需要设计师设计的,包括数据结构和选择方式

  58.             //在这里,我们假设选取第一条的command就直接可以做为结果

  59.             res = commandMoves[0].command;  

  60.         }



  61.         return res;

  62.     }
复制代码

“小黑板”的数据,“引导”着每一个npc做出了自己的行为,恰好能够形成一种“有组织”的错觉。

03 打断事件,从“A过去”和“走过去”说起

在PC上的一些即时战略(RTS)和即时制的对战游戏(MOBA)比如《星际争霸2》、《英雄联盟》中,玩家有一个“微操”,就是在“A过去”和“走过去”之间做一个选择:

  • 所谓“A过去”:通常是玩家按键盘上的A键(通常是默认A键),然后点选某个目标地点,角色会移动过去,但是路上一旦发现敌人、一旦遭受攻击等,角色将会暂时放弃移动,转而和敌人交火。
  • 所谓“走过去”:通常是直接鼠标右键点击某个地点,或者按M(大多游戏默认)然后点击某个地点,此时角色会移动过去,但是路上无论有什么情况发生,即便角色挨打,也会继续向目标走去,直到走到为止。


(“A过去”和“走过去”是完全不同的操作,比如在魔兽争霸3中,如果“走过去”打建筑物,就会盯着建筑物打,而不顾周围的情况,即使挨打也不会停下手里的事情;如果用“A过去”点地面,则会各自寻找要打的建筑物,而当敌方有士兵出现的时候,也会优先攻击士兵)

当然,这里我们并不是要讨论在玩游戏的时候玩家如何选择“A过去”还是“走过去”,我们的思考是——这两种移动模式,其实在游戏AI的设计中,都是可能被用到的模式。比如当我设计一个正在巡逻的士兵的时候,因为他是高度警惕的,所以这个士兵在多个点之间的移动,应该始终是“A过去”的,一旦移动中发现情况,就要做出行动;而如果我们在设计一个被正在被追杀的难民,他的逃跑过程应该是向逃离点“走过去”的;同样的如果我们设计了一场在危险的山道上的战斗,山上随时会有泥石流,这时候所有的敌人的移动是“A过去”的,一旦遇到敌情就能立即反应,而当预感到泥石流出现的时候(比如屏幕开始震动,地上出现泥石流的阴影表示泥石流的阶段要到了),这些敌人都会找到最近的安全区域(泥石流无法击中)“走过去”,这时候不应该会因为在这段移动中遭遇了玩家角色就不顾泥石流的危险去和玩家的角色战斗。

所以这里我们引出了第一个问题:“A过去”和“走过去”的打断问题,一定只针对移动吗?仔细一想并不是,而是只要符合:

  • 需要一定时间来完成。
  • 这事情可以随时被打断。


那么这个事情就跟“移动”是一样的,就有打断问题。

(比如吃东西这个事情,对于大多人来说是“A过去”的,但是我们依然可以利用“走过去”塑造出淡定哥)

接着第二个问题是:如果是“A过去”的,什么时候打断行动?我们可以简单的归纳出一些时间点,比如:

  • 有敌人进入以自身为半径的圆形范围内(可以称之为“警戒范围”)的时候会打断。当然这个警戒范围不一定是正圆的,根据游戏还可以设计多个扇形范围,正面的半径大一些,背后的半径小一些之类的。
  • 当受到了来自敌人的攻击的时候会打断,因为自身的“警戒范围”未必比敌人的射程短,所以需要这样打断,想一下如果一个喝醉酒的士兵正在歪歪斜斜的走向休息处,他应该是迷迷糊糊的,所以“警戒范围”非常小,而此时他突然被人殴打,就应该“酒醒了”。
  • 自身Buff发生变化时:因为有时候攻击并不是直接的,他可能是给角色添加了一个buff,比如让角色中毒了,没有直接伤害,但是也算是有攻击性的。当然对于Buff的理解也不该如此狭隘,比如我们做一个类似GTA这样的开放世界游戏,在一个平静的小村庄里,npc正在悠闲地战斗,而好事的玩家逮住了最近的npc就打,此时这个npc会通过创建一个“求救”的AoE(当然嗓门越大的npc这个AoE范围就越大)给附近所有其他npc添加一个buff,这个buff是“打架了”,而其他原本在散步的npc,收到了这个“打架了”的buff,有些会转变为惊慌地逃跑,有些路见不平的npc则会加入到战斗中来。


我们可以看到,1、2两个点的归纳是系统级的——即是由游戏的其他系统来决定的,并不是所有游戏都有“敌人”的概念,也并不是所有游戏都有“攻击”“伤害”之类的概念,因此1和2并不适合所有的游戏,比如我们现在来做一个类似《开罗拉面店》的游戏,这是一个“和平时代”的游戏,所以根本不存在“敌人”“攻击”的说法;

(开罗拉面店中的雇员、客人都是由游戏AI操作着的npc,但是游戏中并不存在“进入警戒范围”、“受到攻击”等情况,和平年代不需要战斗)

由此我们进行重新抽象,但是游戏的类型各式各样,并且他们都需要游戏AI,所以我们没法很好的归纳出“游戏AI需要被打断当前执行的事件的时间点”来。此时我们需要把思维逆转一下——AI的行动从来不是被外部打断的,也就是外部从来不打断在执行“小本子”里内容的npc,而是npc经常会清空小本子。


在游戏AI每一帧运作的开端,我们都会判断“小本子”里是否有内容,利用的是这个特性——用移动来举例子:


在这样一张地图下,我们的角色要移动到右上方的学校里,清晰可见的是:学校和角色的距离并不是一下就能走到的,角色需要几十上百帧不断地移动,才能到达学校。即当如果“小本子”里的事情是“走到学校”的话,没有其他因素打断的时候,这个行为要执行很久。具体要多久,也是没法计算的,因为我们不能保证过程中角色不会因为“摔倒”而暂停几个回合、因为“崴脚”而减速几个回合……有非常多的动态原因会影响这个行为的执行,但是只要最终不发生比如“学校没了”、“角色昏迷了”之类的特殊情况,“小本子”里的任务就始终是这个,这就实现了“走过去”的效果。

而如何做到“A过去”呢?事实上我们真正的需求是一个“在小本子的事情没做完的时候清除掉小本子事情的方案”,而这个方案可以简单到——就给这件事情限个时:
  1. //原本的“小本子”内的数据

  2. {"type":"move", "x":30, "y": 10}



  3. //现在的小本子内的数据

  4. {"type":"move", "x":30, "y":10, "tick":1}
复制代码

我们主要到现在的数据中多了一个"tick",这个"tick"就是执行多少帧逻辑后,如果事情还在,就把它从“小本子”里抹掉。而上面的例子里,就是“在向(30,10)移动了1帧之后或者到达目的地后(注意这是或关系的两个条件),清除掉这个事情”,由此当npc第一帧向着目标移动之后,事情就没有了,就会重新思考一次“todoThing=???”的问题,在“3F.思考要做什么”一环里,会重新根据当前情况去看是否“该去战斗”了。由此实现了“A过去”的“警惕性”,而且这还是一个带“警惕程度”的方案,即“tick”值越大,npc越不警惕。当我们用多了"tick"这个条件以后,会发现一个现象——为了确保角色的灵活性,我们总是会去设计这个“tick”,那为什么不默认就是有每1帧运算一次呢?是因为担心效率吗?其实并不是,还是为一些特殊的、需要坚持的事情留余地。

而从编程的角度出发,我们更不应该选择其他的对象或者事件,来打断正在执行的AI事件,因为这意味着需要执行打断AI事件的对象,将依赖于有AI的对象,这是一个依赖关系错误问题。

所以,关于AI的行为被打断这件事,并不应该有任何特殊处理去打断一个执行中的AI(即主动抹除“小本子”上的事情),而应该由AI的设计师通过设置“tick”的方式来自行决定某个AI的“敏感度”。

04 好的AI设计,是“可拼装”的

在实际的游戏AI制作工作过程中,可维护性和可执行性的问题就会冒出来。假如我们现在做一个餐馆经营类游戏,现在来设计里面的服务员的AI,那他大致应该是这样的:


当我们初次完成这个行为树的时候,乍一看他非常美好,只要这么循环,就能做出一个送餐服务员所有的工作了。但是,假如这时候制作组引入了新的设计:

  • 偶尔会有英雄级顾客来访,比如马拉多纳、巴菲特、约翰尼德普等。服务员本身是有倾向性的,比如球迷服务员会优先服务马拉多纳;财经谜会优先服务巴菲特……而服务员本身还有崇拜人,比如同样是球星,服务员可能是贝克汉姆的粉丝,所以当贝克汉姆、马拉多纳和贝利同时呼叫的时候,这个服务员会优先去贝克汉姆这里。
  • 普通的服务员只能一次端一份菜送给顾客,而SR以上的服务员可以一次端2份菜送给顾客,并且会优先从准备好的菜里选择两份目的地更接近的;更有SSR的服务员可以一次送3份,并且在前往服务台之前,可以记录2位顾客的需求。
  • 当服务员“待机中”的时候,会有小概率做一下小动作,而不是始终呆板的站在那里发呆。如果有超过2个服务员在“待机中”,他们可能会“聊天”。


这样的设计即合理又丰富了游戏内容,并且完全不影响npc行为的和谐性,基于这两个需求,我们可以得出服务员和顾客2个对象(本文只设计相关数据,无关数据全部省略):
  1. //服务员,Maid因为是二次元感更强烈些

  2. export class Maid extends Character{

  3.     //喜欢的顾客类型,因为只需要优先级,所以越早的越喜欢就好

  4.     //喜欢的顾客类型甚至可以是游戏中还没设计的,所以用字符串,可以预填写数据

  5.     private favouriteCustomerTypes:Array<string>;



  6.     //喜欢的顾客,同样是越早的越喜欢

  7.     //喜欢的顾客甚至可以是一个“不存在”的客户,比如"mayun"而游戏数据中并没有id:"mayun"的顾客

  8.     private favouriteCustomerIds:Array<string>;



  9.     //稀有度,SR是4,SSR是5

  10.     private rank:number;



  11.     //同时可以端的盘子数,这是策划设计的,应当可以随时维护这个规则

  12.     private DishCarriage():number{

  13.         return Math.max(this.rank - 3, 0) + 1;

  14.     }



  15.     //同时可以服务的客户数量

  16.     private MaxReception():number{

  17.         return Math.max(this.rank - 4, 0) + 1;

  18.     }

  19. }



  20. //顾客

  21. export class Customer extends Character{

  22.     //是否是一个英雄级

  23.     private isHero:boolean;



  24.     //客户的id,比如巴菲特等都是因为这个id而是巴菲特的

  25.     //当然外观等属性会有所不同,但是外观等属性在本文中不敷述了

  26.     private id:string;



  27.     //客户的类型

  28.     private customerType:string;

  29. }
复制代码

如果我们用常见的行为树方式设计,要改变之前的行为树以符合这个需求,可就十分困难了,假如原本这只是一个服务员的AI,不同服务员还有不同的AI,那就难上加难了。这根本的问题在于2点:

  • 行为树的条件仅仅支持“如果是……否则……”(if (xxx()==true) {} else if (...))的结构,但实际上很多时候我们要判断甚至要使用的并不是一个布尔结果。比如上述的需求中,我们要求“优先接待贝克汉姆”,假如我们把它理解为“呼叫者为贝克汉姆”并且“我最喜欢的是贝克汉姆”,看起来只是2条布尔判断(if (xxx()==true))都满足,并没有问题,但是如果贝克汉姆已经被别人接待了,我要接待第二喜欢的,也许第二喜欢的也被别人接待了,我要优先接待第三喜欢的……因此这并不是一个“目标是谁”+“目标是我第几喜欢的人”的问题,而是“通过排序我该找谁”,有了这个“谁”就有了我要去的目标,这个目标也包括其他客人,但是如果这个“谁”并不存在,那么说明现在没有客人召唤服务员。因此,在这样精确判断或者“状态数量多到几乎无限”的情况下,行为树几乎是无法支持的。
  • 好的AI应该是“可拼接”的,这具体表现在AI函数(即WhatToDo函数)本身应该可以被赋值,以及所赋的值可以是类似concate()拼接出来的。这个问题的导火索是追加的需求3,即待机中的服务员做小动作等。假如只有一种行为的服务员,即所有的服务员使用的都是上面的行为树做的AI,那么这个问题并不会被发现,但如果游戏中有多种服务员,有些是只负责接待顾客的、有些是只负责上菜的、有些即负责上菜又负责接待顾客,按照传统的行为树的做法,就要有3种行为树,满足这3种不同服务员的AI,而这3种服务员的数据不同,仅仅只有外观等本文不讨论的“表现用属性”以及AI不同。


(只要颜色不一样,我们就能认可她们的行为不一样。所以外观属性和所使用的AI不同,就足以形成多种服务员了)

而基于这两个问题的思考,我们不得不重新去审视,怎样设计AI的结构是好的。

第1个问题其实仅仅只是一个数据输入的问题,我们只要不用行为树而改用脚本,就立即解决了。

而第2个问题的本质是:目前我们所使用的包括GOAP在内的几乎所有的游戏AI的架构思路都是“反人类”的——这些思路要求设计师先宏观的想好了一个NPC应该会做的一切事情,然后一条条细节追逐下去,正如本段开始的那个“行为树”,我们必须规划好了一个“服务员”所有的行为,然后把这些行为“进行中”的阶段当做一种状态,然后去“深入分析”这个状态到底做了什么。假如把这个思路用在传统工业,比如服装制造等拥有上百年流水线生产经验、且今后几百年制作流程和内容不会有变化(顶多制作手法和工具发生变化)的工作时,我们可以使用有限状态机。但是,设计师的思维与此是相反的——设计师对于设计的思维正如他们的灵感一样,是从一个点上迸发出来的。

(设计师的设计往往就像火山爆发一样,从一个点突然就冒出许许多多有趣且非常有价值的设计来,如果我们的程序设计采用类似GOAP这样的“向内包裹”的思路,就仿佛用一个袋子套住火山口,不让它喷发——这并不符合好的设计的特点,好的设计,应该是让喷发出的每一点子都能闪光)

所以我们放下“你现在的需求我都能实现”的“自信”,来看一下我们如何为“将来”做出准备。先从一个设计师的正常思维出发,就拿我们在这一段的“服务员”设计来看这个AI的思维过程:

列出大纲:首先我们列出了一个简单的大纲,即这个“服务员”到底会干什么,乍一看列出了几乎所有的可能性,但事实上这只能算是“头脑风暴”,也许结果上看起来至少80%的内容都有了,但实际上这里产生的设计,很可能是“误导开发”的。行为树和GOAP都在这里为设计师的设计画上了句号,即使能维护,也认为“今后会加的东西不多了,目前结构已经非常清晰了”——但事实上,目前结构几乎不清晰,正如我所说:设计师在这里仅仅只是做了“头脑风暴”。

(列出大纲的步骤非常自然,根据生活经验抽象出会干些什么,同时由此可以获得最基础的“行为树”,但这个“行为树”却错误地被当做了大多游戏的核心AI)

发散思考:这并不是一个“唯一”的过程,因为在游戏开发过程中:每次交流中、美术参考资源收集整理中、实际生活再度仔细考察体验中……等等各种对项目细节的有意识的、无意识的研究中,都可能刺激以产生突发灵感。

(设计师可能从细节出发开脑洞)

(设计师还会从玩法功能角度大开脑洞)

(还有基于“养成”等游戏特性展开的脑洞)

深耕灵感:每一个灵感被深耕的时候,又可能萌发出很多好的设计,这些设计往往并不复杂,但是却可以为游戏带来更丰富的内容,以及更突出游戏主题的内容。


在了解了设计师的设计过程、以及项目发展过程中的各种idea的变化、迭代、进化过程之后,我们可以对于AI的实现方法重新进行构思:

首先我们抽象一下游戏设计师的设计思路:在游戏设计师的理解下,游戏AI就是很多很多个“一件事”的组合,而这个“一件事”往往是一些列“事情”的顺序过程或者组合。因为可能是一个过程,所以在“一件事”中,有些条件被满足了,就会发生另一件事。比如:“服务生从A点走到B点(不管是否端着盘子),都可能因为顾客丢在地上的杂物摔倒”,这个“一件事”是指“服务生移动”,同时“端着盘子”或者“没有端盘子”;而引发的另“一件事”,就是因为“顾客丢在地上的杂物(被服务生踩到)”(条件),所以产生了新的“一件事”:“摔倒”。

(设计师心中的AI行为的变化其实更接近于这样一个模式)

我们之前在第3段的时候说过,由于每一个AI事件的运作,都是若干个tick以后重新思考的,所以实际上对于设计师来说,并不存在“什么时间点打断”的问题,而是只要想清楚“什么事情会打断”。

(包括“一件事”结束,都是一个打断,打断的结果就是转向另“一件事”)

而实际上在这个“一件事”的整个过程中,所有的情况都可以是“条件”,包括“一件事”走完以后。因此,我们完全可以把每一个“一件事”都看成一个“管理器函数”——这个函数决定了是否这一帧要跳转到另一个函数,如果不要就照计划行事。

在代码上利用好角色思考函数本身可以被赋值的特性:既然这是个可以被赋值的属性,那么改变它的值就不是不可能的事,而在比如Unity的Behaviour Designer等插件中,行为树的运用本身也是可以被重新赋值的(BehaviorTree下就有ExternalBehavior可以被赋值),所以利用这个性质对于角色的WhatToDo函数重新赋值来实现AI的变化是好主意。

下面是代码时间

首先,我们要小小的改造一下Character中的WhatToDo,这样他的值就可以是写在其他脚本里的函数:
  1. //这里正是策划设计的重要部分,也是AI的大脑

  2. private WhatToDo(character:Character):Object{

  3.     let res = {}    //Unity推荐的做法在这里是行为树



  4.     //首先我们从小黑板获得一些跟我们相关的事情,省去脚本端的麻烦

  5.     let commandMoves = GameBlackBoard.GetCommandsByExecutorKey("team" + this.teamId);

  6.     //然后我们调用约定好的脚本

  7.     if (this.AIScript)

  8.         res = this.AIScript(character, this.sameAIScriptRunned++, commandMoves, this.runningAIParam);



  9.     return res;

  10. }



  11. //脚本端通过脚本接口改变这个值,实现了“一件事”之间的跳转

  12. private AIScript : (character:Character, runned:number, commandMoves:Array<Object>, eventParam:Object)=>Object;



  13. //同一个脚本已经执行了多少次,这是个很“甜”的东西

  14. private sameAIScriptRunned:number = 0;

  15. private runningAIParam:Object;



  16. //而实际上扩展性好的跳转,跳转到的“状态”,不应该是固定的

  17. //比如当“跳舞完成”以后,“舞娘”应该继续跳下一只舞,而其他人可能就下台继续喝酒了。

  18. //尽管“跳舞完成”以后都会进入同一状态,但是我们不一定非得用if else,可以用“afterDance”

  19. //即这个Object的结构是:key=跳转的key“afterDance”等,而value是一个AIScript类型的函数

  20. //{"function key": (Character, number, Array<Object>, Object)=>Object}

  21. private aiWarpFunc = {

  22.     "default":StandStill    //设置一个默认值,以避免跳转的函数并不存在

  23. };  



  24. public AIScriptWarp(scriptKey:string, eventParam:Object){

  25.     if (this.aiWarpFunc){

  26.         if (this.aiWarpFunc[scriptKey]){

  27.             this.AIScript = this.aiWarpFunc[scriptKey];

  28.             this.sameAIScriptRunned = 0;

  29.         }else if (this.aiWarpFunc["default"]){

  30.             this.AIScript = this.aiWarpFunc["default"];

  31.             this.sameAIScriptRunned = 0;

  32.         }

  33.     }

  34.     if (eventParam)

  35.         this.runningAIParam = eventParam;

  36.     //实在没有就不跳转,保持现在的

  37. }

  38. 而设计师则通过脚本接口来写脚本完成整个AI的运作:

  39. //程序提供的脚本接口

  40. var MaidAIWarp = function(maid:Character, scriptKey:string, eventParam:Object){

  41.     if (maid){

  42.         maid.AIScriptWarp(scriptKey, eventParam);

  43.     }

  44. }



  45. //判断是否有客人呼叫,有就返回结构

  46. var CustomerCalls = function():Object{

  47.     if (true) { //这当然不能是true的,具体游戏具体实现

  48.         //这个数据也是不对的,应该是返回一个呼叫的客户的列表,当然这里只是举例,所以没法实现

  49.         return {

  50.             customers:[

  51.                 {

  52.                     "x":10, //为脚本选好合适的站位

  53.                     "y":10,

  54.                     "caller":new Customer() //这个客人是谁

  55.                 }

  56.             ]

  57.         }

  58.     }

  59. }



  60. //判断是否到位了,这里就假设是的

  61. var MaidArriveAtPosition = function(maid:Character, x:number, y:number):boolean{

  62.     //尽管这个函数在脚本层也可以实现,但是提供一下也不坏

  63.     return true; //假设是true,本文中就不做详细设计了,只是说明用

  64. }



  65. //往下都是设计师设计的脚本

  66. var StandStill = function(character:Character, runned:number, commands:Array<Object>, eventParam:Object):Object{

  67.        return {"behave":"stay", "tick":1};

  68. }



  69. //服务员的待机

  70. var MaidWaiting = function(maid:Character, runned:number, commands:Array<Object>, eventParam:Object):Object{

  71.     let callers = CustomerCalls();

  72.     if (callers && callers["customers"] && callers["customers"].length > 0){

  73.         //假设就走向第一个客户

  74.         let targetCustomer = callers["custmoers"][0];



  75.         //跳转到角色的“move”下的函数,当然那个函数未必是MaidWalkTo,但我们按照约定做就好了

  76.         //第二和第三个参数也是设计师之间的约定,是根据游戏设计来的

  77.         MaidAIWarp(maid, "move", {"x":targetCustomer["x"], "y":targetCustomer["y"], "event":"CustomerCalls"});



  78.         return; //不return是要出事的,这是个缺陷。

  79.     }



  80.     return {"behave":"stay", "tick":1}; //这是“小本子”的内容,由每个项目单独设计规划

  81. }



  82. //假如是走路怎么办,注意,

  83. var MaidWalkTo = function(maid:Character, runned:number, commands:Array<Object>, eventParam:Object):Object{

  84.     //甚至可以写一个别的脚本函数单独处理,而那个脚本函数也未必只有这个函数调用

  85.     if (DealWithMaidTouchThing(maid) == true){

  86.         return; //跳转到别的事件了

  87.     }

  88.     //此处因篇幅省去异常判断

  89.     if (MaidArriveAtPosition(maid, eventParam["x"], eventParam["y"]) == false){

  90.         return {"behave":"move", "x":10, "y":10, "tick":1}  //只为演示一下,所以坐标的取法不对

  91.     }else{

  92.         if (eventParam["event"] == "CustomerCalls"){

  93.             MaidAIWarp(maid, "service", {}); //...

  94.             return;

  95.         }

  96.     }

  97. }



  98. var DealWithMaidTouchThing = function(maid:Character, thing?:any):boolean{

  99.     //假如角色移动中碰到了什么东西,也可以单独写一个处理的函数

  100.     //返回boolean告诉调用者是否应该MaidAIWarp

  101.     return false;

  102. }
复制代码

从结构上来看,是不是感觉aiWarpFunc这个动系有些多余?如果单看流程的话,其实没有这个东西,我们直接在脚本里调用对应的接口是很好的。首先如我们上面说的,每个角色的“afterDance”不一定是一样的事情,我们大可不必去(if (character...));其次是为了如果我们的设计师恰好都不会写脚本的时候,当我们要建立excel表去做一个灵活性很高的AI,这里就可以是“角色属性”的一环。

这样,AI就实现了“可拼接”的灵活结构,并不是说“行为树”做不到这样的效果,毕竟行为树本质是if else,没有if else实现不了的功能,只是方便不方便的问题。

05 未来游戏AI会都用Deeplearning?

事实上是不会的,也许人工智能会在游戏领域被运用,但是淘汰不了游戏AI,因为他们本质就是不同的东西。游戏AI和我们通常理解中的人工智能最接近的一点功能是“陪玩”。因为在游戏当中,有那么一些元素(大多是角色),他们不属于玩家可以控制的范围——他们可能是玩家的对手、可能是拖累玩家的合作伙伴、也可能是玩家的强力队友。

(即使是在FIFA这样的足球类游戏中,AI会做什么,对于玩熟悉了的玩家依然是了然于心的)

对于这样的一些角色来说,玩家不应该可以直接控制他们,因为对于玩家来说他们的行为应该是一个不那么确定的因素,这样才有策略性可言。假如我们十分了解这些“机器人”的行为,或者对于这些机器人会做什么几乎“一无所知”甚至他们的行为“出人意外”,那游戏就会变得并不那么好玩了。

AlphaGo生来是为了证明当有大量的数据,人们通过算法分析就可以得出相对最好的解决方案。所以AlphaGo生来就不是为了成为“游戏陪玩”的,而是为了击败围棋达人才存在的。而“游戏AI”与我们通常理解的“人工智能”最大的区别也在于——“游戏AI”是为了让游戏中的这些角色元素变得更有活力,而不是为了让游戏中的对手变得让玩家无法击败。

(AlphaGo存在的意义就是证明,以大数据收集、分析为核心的Deeplearning可以为人类在解决问题的时候带来极好的解决方案)

但是如果在游戏中,我们因为对手的套路“太诡异”、“藏得太深”等因素,无法总结、或者分析出一些合适的对策,这并不有趣。能让玩家凭借判断等技巧,结合经验对抗得了的才是好的游戏设计。设想一下,如果《怪物猎人》中一个非常厉害的AI操作要狩猎的龙,玩家几乎战胜不了,因为不知道龙会想出什么鬼点子,做出什么诡异的招式,这样还能好玩吗?

(假如《怪物猎人》的AI是一个像AlphaGo一样聪明的家伙,那么他会选择让飞龙在安全的地方睡觉,一旦有玩家进入场地,立即飞走,去另一个更远的、安全的地方睡觉,以此拖满50分钟时间,这几乎是必定可以战胜玩家的方法,但如果是这样的AI“陪玩”,玩家并不会开心,甚至会摔手柄)

“更聪明的AI”并不是游戏设计需要的AI,游戏设计需要的AI,至少是规则可以琢磨的,由此玩家才能想出对策来获得游戏的乐趣。比如在回合制游戏中,某一种敌人的作战方式就是“只攻击血量数字最高的角色”,那么当玩家找到一个攻击力不高的角色,使劲给他堆血量的时候,就会发现自己做对了——因为那种怪物总是打那个攻击力不高的角色,而那个角色只要牺牲不高的攻击力来防御,为其他高攻击力角色提供输出机会,就是很好的策略——玩家通过对AI的了解得出了一个合理的策略获得了游戏的优势,从而非常快乐,这就是AI“陪玩”的意义。所以DeepLearning不会是“游戏AI”的未来,因为“游戏AI”要解决的问题和DeepLearning解决的问题不一样。

总结

所以,当我们在说设计游戏AI的时候,实际上是在设计:基于游戏玩法规则而产生出的一套让npc运作的规则,这套规则中npc会根据游戏进行的情况来进行一些决策,做出“不那么机械化”的行为。只要玩家足够有分析能力、有足够的游戏经验,总是可以摸清AI规律(能摸清但摸不透)来对抗的——这才是“游戏AI”。


微信公众号千猴马的游戏设计之道(ID:baima21th)授权转载


作者:猴与花果山

最新评论
暂无评论
参与评论

商务合作 查看更多

编辑推荐 查看更多