第13章 游戏对象的行为

本章将介绍一些游戏对象行为控制的基本知识和框架。这个框架可以让你更好的规划和控制人物的行为,帮助ai进行行为策划等。

游戏对象的行为

游戏对象的行为,我们主要分成几种,比如玩家自主控制的人物行为,游戏对象固定模式行为,游戏对象AI行为等。

状态机

我们在写游戏的时候,经常面对这个情况:人物在不动的时候播放idle动画,人物跑起来要播放run动画,而人物跳起来的时候,不能走,只能等到人物落地后才可以进行其他动作。也就是说,人物总处于不同的状态下,而在不同的状态下,会有不同的行为。这种对于不同状态下不同行为的处理,我们笼统的叫做状态机。

最简单的状态机原形

由于lua没有case语法,因此我们最简单的状态机实际上就是一系列的if elseif组成的状态判断。比如下面代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
local cat = {
x = 0,
vx = 5,
}
cat.state = "idle"
function cat:runTest()
if love.keyboard.isDown("left") then
self.x = self.x + self.vx
self.state = "run"
--play run anim
end
end
function cat:idleTest()
if self.ox = self.x then
self.state = "idle"
--play idle anim
end
end
function cat:move()
self.x = self. x
self.ox = self.x
end
function cat:fireTest()
--暂不加入
end
function cat:update()
if self.state == "idle" then
self:runTest()
self:fireTest()
self:idleTest()
--can fire
elseif self.state == "run" then
self:runTest()
self:idleTest()
-- can not fire
end
self:move()
end

这段代码有下面几个核心需要解释:
在update中,首先对state进行分类,当状态为idle的时候,它将进行移动测试,开火测试和待机测试。移动测试意义是当有按键按下的时候,移动,播放移动动画,并且进入移动状态。开火实际上跟移动差不多这里没写,然后待机测试是当人物不移动时进入待机模式。在移动状态下,只有移动和待机测试,而没有开火测试,于是,在移动状态下就是不能开火的。
通过上面,我们可以理解到,idle可以通向run,fire 而 run只能通向run,这样的模式就可以画出一个行为树来。谁在什么条件下能通向谁。这个就是状态机的核心了。我们在精炼一下状态机的要素:

  1. 状态,状态机肯定要有状态标签啦,它标志着当前的状态名称。
  2. 状态选择器 有了它就可以在不同的状态下做出各自的单独的行为。
  3. 行为 在进入这个状态时,以及在这个状态下,我们进行的动作,退出这个状态时我们进行的动作。
  4. 状态条件 在这个状态下,在何种条件下,我们退出当前的状态而进入其他状态。

改良版状态机

上面由if else以及行为与状态条件混在一起的状态机,往往会造成代码冗长,而且不太容易看得出各个状态之间的关系。因此我们对上面的代码进行改良:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
cat.stateSystem = {
idle = function()
self:runTest()
self:fireTest()
self:idleTest()
end,
run = function()
self:runTest()
self:idleTest()
end
}
function cat:update()
cat.stateSystem[cat.state](cat)
end

这样,用表来代替选择,看起来更整洁了。但是仅仅是把状态判断改成了表的键值读取,我们仍然有大量的代码重复,以及无法识别状态间关系。

进一步改良的状态机系统

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
cat.stateSystem = {
idle = {
friend = {"run","idle","fire"}, --开火暂时还没加入
condition = function(self) if self.ox = self.x then return true end,
enter = function(self) --play idle anim end,
},
run = {
friend = {"run","idle"},
condition = function(self) love.keyboard.isDown("left") then
self.x = self.x + self.vx
return true
end,
enter = function(self) --play run anim end,
},
}
cat.stateSystem.current = cat.stateSystem[cat.state]
for i,name in ipairs(cat.stateSystem.current) do
if cat.stateSystem[name].condition(cat) then
cat.state = name
cat.stateSystem.current = cat.stateSystem[cat.state]
cat.stateSystem.current.enter(cat)
end
end

这样是把状态机以数据的形式单独列出来。有很多好处,首先,我们能够通过friend字段看到我们的行为网络;然后,将进入状态的条件和friend结合起来,方便条件的复用。state的配置文件也可以单独放在其他位置,使代码更加整洁。

状态机系统

接下来我们就再一次扩充我们的stateSystem,让它更加完整。具体实现这里就不写了,可以看下我们的状态机系统都有哪些接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
stateSystem = {}
stateSystem.states = {}
stateSystem.reg = function() end
stateSystem.switch = function() end
stateSystem.set = function() end
stateSystem.update = function() end
state = {
friend = {},
condition = function() end,
enter = function() end,
update = function() end,
exit = function() end,
}

首先,我们来说一下状态机中的状态state。它包含:
friend成员,是这个状态下可以通过条件检测同向其他状态的名称。其排布必须按照执行的优先级来进行。
condition方法,是个函数,如果函数返回true则切换到该条件的state中。比如按下移动键。
enter方法,当人物状态从其他进入到本状态时,一次性激活这个方法。
update方法,当人物在此状态所特有的行为。
exit方法,当人物从本状态跳出时执行的收尾行为,一次性。
然后我们再看一下系统。
states表,来存放所有的状态,以状态名为键值。
reg方法,注册单独的状态于状态系统中。
switch方法,从一个状态跳到另一个状态,并激活exit和enter方法。
set方法,直接强制设置某个状态,不激活exit,只激活enter方法。
update,遍历本状态下所有的friend状态的条件,如果条件成立则跳转状态,否则执行本状态下的update。

使用这种状态机的优点在于,状态机数据比较清晰,某个状态的进入条件,更新方法,相邻状态等都在一个表中体现,很容易理清逻辑。在游戏单位那一端,仅需要写好相应的行为,然后更新状态机即可。所以游戏单位的代码十分简洁。

游戏对象的自动行为

游戏对象的自动行为,实际上就是除了我们玩家手动控制的游戏对象外,其他游戏对象的行为。
我们首先来看一些比较刻板的行为,他们一般不受或者比较少的收到环境的影响,比较稳定的执行自己的行为模式。
最简单的比如,超级马里奥中的敌人,他们只会在碰到障碍时转向,或者仅仅简简单单的取玩家的位置,指向或移动向玩家的方向。大多数横版卷轴,弹幕游戏都是这种行为模式。他们在设计起来比较简单,玩家可以多少预判出敌人的行为模式从而采取对应的措施。
有时候,即时是这种固定模式,也有一些不同的变化。一个游戏对象的行为有若干种,他们之间的调度,往往通过顺序或随机的方式进行,并且行为之间会有一个惯性。比如开火–闲置–躲闪–闲置,他们的切换需要一个定时器的参与。比较典型的比如:雷电中的boss飞机,他们一般并不会根据玩家的行为而改变行为模式,(改变的仅仅是移动方向,开炮的方向等),而是按照自己的套路来。再比如一些NPC的无意识走动(经常被吐槽的),走走停停,走的时候一般是随机方向,然后按这个方向走一段时间/距离后,可以选择等待换方向等。

游戏对象的条件行为/行为树

当然,我们并不满足于让所有的npc都那么傻。不然,游戏就没有什么挑战了。于是我们希望由某些条件来控制游戏对象的行为。这时,就需要借助条件队列或者行为树的帮助了。
最简单的条件队列如下:

1
2
3
4
5
6
7
8
9
10
11
function ai()
if cond1 then
action1()
elseif cond2 then
action2()
elseif cond3 then
action3()
else
action0()
end
end

很简单是吧,我们拿一个例子来套进去,假设这个是敌人的行为队列。
cond1 如果自身生命小于10% action1 离开玩家
cond2 如果玩家在攻击范围 action2 攻击
cond3 如果玩家在视野范围 action3 移动向玩家
action0 无意识走动
我们可以在思维中模拟,一个npc,正在无意识走动,玩家过来了,进入到视野范围,于是他走向玩家,当进入到攻击范围时,他会自动攻击,当玩家远离他时,由于脱离攻击范围,条件又变成在视野内,于是他有接近玩家。。。当濒死时逃跑直到生命恢复。
这是最简单的队列形式。如果在某一个条件下还有子条件呢? 它就变成了行为树。
比如在濒死条件下,如果脱离玩家攻击范围,则喝药,否则跑离玩家。
当然,这里是正向条件,还有反向条件,没有某种条件,游戏对象将保持某种行为。这里的所有行为都是串发的,还有并发条件的,他们的返回结果也可以是以加权形式的,比如对方兵力,对方资源,己方兵力,己方资源等等条件。这里就不一一介绍了。

a星寻路

寻路算法在游戏是十分重要的,对于自动移动来讲,只要有障碍的移动,就需要用到寻路算法。我们最常用的是a星寻路了,因为它实现很简单,用起来也很简单。而且可以和tile碰撞配合使用。它是一种,将游戏中的单位和障碍抽象成矩阵的算法,具体算法原理及实现请自行百度,我没记错的话,甚至还有一个love2d的a星寻路实践。a星寻路还有很多变体,主要就是对深度优先和广度优先进行调整。
love2d最常用的寻路库是jumper,它内部包含了好多种寻路算法,按照自己的需求使用即可。

一些有意思的算法

这些算法也许并不能帮你写ai,但是他们可以给你对对象随机行为上给出一些思路,这些算法请自行百度。
元胞自动机
元胞自动机的特点是,在十分简单的规则下,产生复杂的行为表现。
蚁群效应
蚁群算法的特点是,我们对行为趋向的描述不一定非得在某个个体上,而是留在公共的环境,让所有个体来对环境进行感知和改变,从而达到优化的目的。
鱼群效应
鱼群算法也是一种针对群体的算法,他可以体现出简单规则下,对某些随机行为的放大,从而形成比较复杂的一致性。
遗传算法
遗传算法的特点是,对某些倾向进行保留和放大,从而得到趋近的结果。