决策(Decision Making)
游戏中的决策:角色决定去做什么的能力。然后执行决定(移动,播放动画或其他)。
现实中,大多数游戏使用简单的决策系统:状态机,决策树。基于规则的系统很少,但是很重要。
最近几年也出现了一些更加复杂有趣的决策工具,例如模糊逻辑和神经网络。然而开发者并没有快速的拥抱它们,目前让这些技术在游戏里边正确工作还很困难。
决策在我们的AI模型中居于中间部分,但是我们仍然会提到一些战术和策略AI。
5.1 决策预览
角色处理一系列信息来生成他想要执行的行为。决策系统的输入是角色处理的知识,输出是请求的行为。这些知识可以再被分为内部和外部知识。外部知识是角色了解的他周围游戏环境:其他角色的位置,关卡的布局,一个开关是否被开启,噪音的来源等等。内部指示是角色内部的状态或者思考:他的健康,最终目标,他几秒前在做什么等等。
行为(Actions),有两部分:他们可以请求的能使角色改变外部状态的行为(例如开开关,移动到一个房间)或者只影响到内部状态的行为(5.2节可以看到)。改变内部状态在游戏中可能更加不明显但是在一些决策算法中更加重要。
信息的格式和数量依赖于游戏的请求。信息的生成本质上和大多数角色算法相关。
5.2 决策树(Decision Trees)
决策树是最快速,容易实现,并且容易理解的技术,但是这个基本算法的扩充版也可以变得非常复杂。在游戏中,他可以用来控制角色也可以做一切其它决策,比如动画控制。
决策树有容易模块化和容易创建的优点,可以看到它被用在各个方面,从动画控制到战术和策略AI。
虽然很少见,但是决策树也可以被学习,学习树(the learned tree)相对有权重的神经网络更易于理解,第七章会提到。
5.2.1 解决的问题
给予一系列信息,我们要从一组可能的行为里选出一个正确的行为。
5.2.2 算法
一棵决策树由相连的决策点组成。树有一个开始点,即根节点。对于每一次决策,从根节点开始,往下扩散执行。
每一次选择都基于角色的信息,因为决策树通常是一个很简单和快速的决策机制,角色通常期望直接获得游戏状态而不是只有他们自己知道的信息。
继续执行直到树的叶子结点,这时已经不需要再做决定,通常叶子结点绑定为一个行为,当到达这个节点,行为会立马执行。
大部分决策树节点做一些很简单的决定,如下图所示是敌人的位置:
下图所示代表这个决策树的一个完整执行,高亮的线是本次执行的逻辑:
决策的组合
每一个决策只做一次测试,当有组合的布尔测试需求时,如下所示:
这种简单的决策树来建造任意逻辑组合测试的方法也用在其他一些决策系统上。
决策的复杂度
因为决策是一个树状结构,需要做决定的数量通常少于决策树中的决策节点数量。下图所示的树中有15个决策点和16个可能的行为,一次算法运行,可以看到只执行了四次决策。
分支
一般来说,决策在两个选项中做选择,这被称作二分决策树。当然你可以建造有任意数量选择的决策树,你也可以对决策有多个数量的分支。
二分决策树是相当常见的选择,它更容易做优化,一些需要决策树的学习算法也需要它是二分的。
5.2.3 伪代码
一个决策树由decision tree node组成,它可以使决策(Decision)也可以是行为(Action)。在面向对象语言中,它们都是它的子类。
class DecisionTreeNode:
def makeDecision() # 迭代遍历这棵树
Action包含行为的执行细节,这里makeDecision函数仅仅返回它自己
class Action(DecisionTreeNode):
def makeDecision():
return this
class Decision(DecisionTreeNode):
trueNode
falseNode
testValue
def getBranch() # 计算出结果分支
def makeDecision(): # 迭代走遍这棵子树
branch = getBranch()
return branch.makeDecision()
treeNode和falseNode指向树的另外两个子节点,testValue作为参与做判断的角色信息指针,getBranch函数执行判断,返回trueNode或者falseNode。如下所示为一个判断float指的决策节点:
class FloatDecision(Decision):
minValue
maxValue
def getBranch():
if maxValue >= testValue >= minValue:
return trueNode
else:
return falseNode
多分支
我们也可以很简单的实现一个多分支的决策树,它的效果如下:
class MultiDecision(DecisionTreeNode):
daughterNodes
testValue
def getBranch():
return daughterNodes[testValue]
def makeDecision(): # 迭代走遍这棵子树
branch = getBranch()
return branch.makeDecision()
5.2.5 信息生成(Knowledge Representation)
决策树可以直接使用原生数据类型,决策可以基于整数,浮点数,布尔值或者其他游戏类型数据。决策树的一个优点是他不需要信息的格式转换。
相应的,决策树大部分直接获取游戏的状态,比如想知道玩家距离敌人的距离,可以直接回去玩家和敌人的位置。不过缺少转换也可能出现问题,游戏中很少使用的节点,不出现问题很难发现。如果使用的某个接口数据类型改变,走到这个节点就会报错。
为了解决这个问题,选择隔离所有获取游戏状态的实现。在第十章世界接口提供这一层的保护。
5.2.8 平衡决策树(Balance The Tree)
当决策树是平衡的时候预期是运行最快速的,一棵平衡树在每一个分支上都有相同数量的叶子节点。如下图所示,第二个是平衡的
平均上平衡树有更好的查询速度,但是在期望达到不同的节点时,执行效率会表现的不一样。
因为决策树是相当快速的,所以没必要极端优化来压榨任何一点速度。使用下面一条方针:让树尽量平衡,让经常使用的分支尽量的短并且将花费更多(时间)的的树放后边。
5.2.9 Beyond The Tree
我们也可以将多个分支指向同一个节点,如下图所示,在原本的算法上也是支持的,只要不同节点的falseNode或trueNode指向同一个节点即可
同时需要注意不要出现环路。
5.2.10 随机决策树
通常,我们不希望行为变得完全的可预测,这时可以加入随机行为选择节点。
因为决策树运行的十分平凡,如果每次随机节点都返回一个不同的值,就会出现问题,如下图所示,角色会在巡逻和站立状态不断切换。
我们期望在初始随机出结果之后,如果下一帧仍然执行该随机节点,我们就使用上一帧随机的结果,否则就重新随机。同时如果该节点一直在执行,那么角色也不会表现出随机性,这时候我们可以再加入一个有效结果时间,即使一直执行该节点,超出该事件,也会重新随机。
struct RandomDecisionWithTimeOut(Decision):
lastFrame = -1
firstFrame = -1
lastDecision = false
timeOut = 1000 # 有效帧数
def test():
if frame() > lastFrame + 1 or
frame() > firstFrame + timeOut:
lastDecision = randomBoolean()
firstFrame = frame()
lastFrame = frame()
return lastDecision
5.3 状态机(State Machines)
状态机是游戏中最常用的制定决策的技术,协同脚本一起。
在一个状态机中,每个角色当前只有一个状态。通常行动和行为和每一个状态相关,当角色处于这个状态中时,他持续执行里边的行为。状态由过渡(transitions)连接。每个过渡指向另一个状态:目标状态。并且关联一系列条件,当条件触发时,即触发变到目标状态。
下图所示,共有三个状态:警戒,战斗,逃跑。
在决策树中,同样的决策经常被使用,树上的每一个行为都有可能触发。在状态机上,只有连接当前状态的过渡需要考虑,所以不是每个行为都有可能抵达。
有限状态机(Finite State Machines)
游戏中这种AI技术被称为有限状态机(FSM)。同时在非游戏开发中,也常用于解析文本。
有限状态机的实现并没有固定原则,所以在游戏中有限状态机常常有各种实现。
5.3.3 伪代码
class StateMachine:
states
initialState
currentState = initialState
# 返回需要执行的行为
def update():
triggeredTransition = None
for transition in currentState.getTransitions():
if transition.isTriggered():
triggeredTransition = transition
break
if triggeredTransition:
targetState = triggeredTransition.getTargetState()
actions = currentState.getExitAction()
actions += triggeredTransition.getAction()
actions += targetState.getEntryAction()
currentState = targetState
return Action
else:
return currentState.getAction()
class State:
def getAction() # 处于更新中要执行的操作
def getEntryAction() # 进入状态要执行的操作
def getExitAction() # 离开状态要执行的操作
def getTransitions()
class Transition:
actions
targetState
condition
def isTriggered():
return condition.test()
def getTargetState():
return targetState
def getAction():
return actions
class Condition:
def test()
一些条件的具体实现如下:
class FloatCondition(Condition):
minValue
maxValue
def test():
return minValue <= testValue <= maxValue
class AndCondition(Condition):
conditionA
conditionB
def test():
return conditionA.test() and conditionB.test()
弱点
这种实现方式很灵活,但是会有很多函数调用。如果每帧都有很多角色在调用就不适合了。
5.3.8 硬编码FSM
class MyFSM:
enum State:
PATROL
DEFEND
SLEEP
myState
def update():
if myState == PATROL:
if canSeePlayer():
myState = DEFEND
if tried():
myState = SLEEP
elif myState == DEFEND:
if not canSeePlayer():
myState = PATROL
elif myState == SLEEP:
if not tried():
myState = PATROL
def notifyNoiseHeard(volume):
if myState == SLEEP and volume > 10:
myState = DEFEND
def getAction():
if myState == PATROL:
return PatrolAction
if myState == DEFEND:
return DefendAction
if myState == SLEEP:
return SleepAction
容易编写但很难维护。
5.3.9 分层状态机(Hierarchical State Machines)
想象有一个负责打扫卫生的机器人,有一个状态机负责整个工作,如下图所示:
不幸的是,机器人可能会缺电,这时候不论在哪做什么都要到最近的充电点充电。当充满后再继续之前的工作。这是一个预警机制:一些重要的事会打断当前行为。在行为机里处理或产生双倍的状态数量。
再如果我们想机器人在走廊发生战斗时进入躲避状态,等躲避结束再回到充电状态,那么就有两层警戒,这时候会有16个状态。
为了解决这个问题,我们可以不把它们放在一个状态机中,让每一个警戒都有各自的状态机。它们分层排布。
下图展示了一个报警机制和充电状态转换:
我们将一些状态嵌入到另外一个状态里作为一个分层状态机,如下图所示,实心黑点作为初始状态。当第一次进入一个组合状态时,带圆圈的H*指向应该进入的子状态。
如果之前已经进入过组合状态,那么进入上一次离开时的子状态,H*因此被称为"历史状态"。
事实上,我们可能同时处在多个状态上,我们可能即在预警状态机的“充电”,又在清洁状态机上的“捡物品”。因为是分层状态,所以一直选择层级最高的状态控制角色。
另外状态也有可能在不同层级上转移:如果一个机器人当前找不到垃圾拾取的话,他可能需要直接去充电而不是原地站着,如下图示
伪代码
class HSMBase:
struct UpdateResuolt:
actions
transition
level
def getAction(): return []
def update():
result.actions = getAction()
result.transition = None
result.level = 0
return result
def getStates()
class HierarchicalStateMachine(HSMBase):
states
initialStates
currentState = initialState
def getStates():
if currentState:
return currentState.getStates()
else:
return []
def update():
if not currentState:
currentState = initialState
return currentState.getEntryAction()
triggeredTransition = None
for transition in currentState.getTransitions():
if transition.isTriggered():
triggeredTransition = transition
break
if triggeredTransition:
result = UpdateResult()
result.actions = []
result.transition = triggeredTransition
result.level = triggeredTransition.getLevel()
else:
result = currentState.update()
if result.transition:
if result.level == 0:
targetState = result.transition.getTargetState()
result.actions += currentState.getExitAction()
result.actions += result.transition.getAction()
result.actions += targetState.getEntryAction()
currentState = targetState
result.actions += getActions()
result.transition = None
elif result.level > 0:
result.actions += currentState.getExitAction()
currentState = 0
result.level -= 1
else:
targetState = result.transition.getTargetState()
targetMachine = targetState.parent
result.actions += result.transition.getAction()
result.actions += targetMachine.updateDown(targetState,
-result.level)
result.transition = None
else:
result.action += getAction()
return result
def updateDown(state, level):
if level > 0:
actions = parent.updateDown(this, level - 1)
else:
actions = []
if currentState:
actions += currentState.getExitAction()
currentState = state
actions += state.getEntryAction()
return actions
class State(HSMBase):
def getStates():
return [this]
def getAction()
def getEntryAction()
def getExitAction()
def getTransitions()
class Transition:
# 返回从开始状态到目标状态的层级差
def getLevel()
def isTriggered()
def getTargetState()
def getAction()
class SubMachineState(State, HierarchicalStateMachine):
def getAction():
return State::getAction()
def update():
return HierarchicalStateMachine::update()
def getStates():
if currentState:
return [this] + currentState.getStates()
else:
return [this]
5.3.10 组合决策树和状态机
我们可以使用条件节点替换状态机的过渡来组合状态机和决策树。
如下图所示,叶子结点不再是决策树的节点而是状态:
伪代码
class TargetState(DecisionTreeNode):
getAction()
getTargetState()
def makeDecision(node):
if not node or node is_instance_of TargetState:
return Node
else:
if node.test():
return makeDecision(node.trueNode)
else:
return makeDecision(node.falseNode)
class DecisionTreeTransition(Transition):
# 当制定决定后,存储决策树最终的目标状态
targetState = None
decisionTreeRoot
def getAction():
if targetState:
return targetState.getAction()
else:
return None
def getTargetState():
if targetState:
return targetState.getTargetState()
else:
return None
def isTriggered():
targetState = makeDecision(decisionTreeRoot)
return targetState != None
5.4 行为树(Behavior Tree)
行为树已经成为了一个非常受欢迎的制作Ai角色的工具。它和分层状态机有很多相似的地方,但是不是用状态,而是用任务(Task)来组成行为树。一个任务可以做一些简单的事例如获取一个游戏状态变量,或者播放一个动画。任务也可以组成一棵子树来代表一个更加复杂的行为。这些复杂的行为再用来组合成更加高等级的行为,这就是行为树的力量。
任务的类型
行为树的任务都有相同的结构,他们花费一些CPU时间执行并返回一个成功或失败的状态,一些复杂的实现可能还会返回一个错误码,甚至一个持续性的任务返回运行中状态。
虽然任务可以包含任何复杂的代码,但是为了灵活度会把每个任务分成可组合的部分,这样针对有图形界面的树编辑器,任何人都可以用它们设计出复杂的AI行为。
暂时我们简单的行为树有三类任务:条件(Conditions),动作(Actions),混合(Composites)。
在行为树中条件和动作很类似,它们都作为树的叶子节点,而大部分分支都是混合节点,它控制子节点的执行。混合节点的类型一般都很少,只需要常用的几种就可以实现很复杂的行为。
最常用的混合节点有Sequence:它顺序执行子节点,一旦有子节点返回False,该节点就结束并返回False,所有子节点都成功完成,就返回True,如下图示,依次执行攻击,嘲讽,注视行为,中间任何行为失败就结束:
Selector:它顺序执行子节点,一旦有子节点返回True,该节点就结束并返回True,否则继续顺序执行子节点,执行完则返回False,如下图示:
如下图一个行为树示例:
代表意义如下
if is_locked(door):
move_to(door)
open(door)
move_to(room)
else:
move_to(room)
5.4.1 行为树实现
行为树是由独立的任务组成,每一个都有它自己的算法和节点。他们都有一个基本的接口来让他人调用而不用考虑内部实现,以下是一个简单的行为树实现
5.4.2 伪代码
class Task:
children
def run() # 返回True或者False
一些Task示例:
class EnemyNear(Task):
def run():
if distanceToEnemy < 10:
return True
return False
class PlayAnimation(Task):
animation_id
speed
def Attack(animation_id, loop=False, speed=1.0):
this.animation = animation_id
this.speed = speed
def run():
if animationEngine.ready():
animationEngine.play(animation, speed)
return True
return False
Selector和Sequence Task的实现:
class Selector(Task):
def run():
for c in children:
if c.run():
return True
return False
class Sequence(Task):
def run():
for c in children:
if not c.run():
return False
return True
非确定性组合任务
使用上边的任务节点组合成的树,每一个节点执行的顺序是固定可预测的,为了让表现更加多样化,可以加一些不确定性的组合变种任务,如下示:
class RandomSelector(Task):
children
def run():
while True:
child = random.choice(children)
result = child.run()
if result:
return True
class NonDeterministicSelector(Task):
children
def run():
result = False
shuffled = random.shuffle(chlidren)
for child in shuffled:
if child.run():
break
return result
def shuffle(original):
list = original.copy()
n = list.length
while n > 1:
k = random.integer_less_than(n)
n--
list[n], list[k] = list[k], list[n]
return list
5.4.3 装饰器(Decorators)
装饰器任务是行为树的第四类任务节点,它是对子节点操作的一种包装。和组合节点有些类似,但是装饰器任务只有一个子节点,类型也更多样化。
如限制子节点只执行几次:
class Limit(Decorator):
runLimit
runSoFar = 0
def run():
if runSoFar >= runLimit:
return False
runSoFar++
return child.run()
执行一个子节点知道它返回失败:
class UntilFail(Decorator):
def run():
while True:
result = child.run()
if not result:
break
return True
一个行为树示例如下所示:
ex = Selector(Sequence(Visible,
UntilFail(Sequence(Conscious, Hit, Pause, Hit)),
Restrain),
Selector(Sequence(Audible, Creep),
Move))
图表结构如下:
使用装饰者保护资源(guarding resources with Decorators)
通常,行为树可能会需要访问一些限制数量的资源,比如一个角色同时只能播放一个动画,或者同时寻路的寻路者数量也有上限。那么通常在调用这些资源时就要先判断一下是否可用,如下:
一般的步骤如下:
- 在行为树中编写硬编码测试,例如上边的PlayAnimation
- 创建一个条件任务测试是否可用,用Sequence连接
- 使用装饰器任务保护资源
这种装饰器的机制被称作信号量(semaphore). 伪代码如下:
class Semaphore:
# 创建一个有用户上限的semaphore
def Semaphore(maxinum_users)
# 请求资源
def acquire()
# 释放资源
def release()
装饰器任务如下:
class SemaphoreGuard(Decorator):
semaphore
def SemaphoreGuard(semaphore):
this.semaphore = semaphore
def run():
if semaphore.acquire():
result = child.run()
semaphore.release()
return result
else:
return False
# 创建sephore的工厂方法
semaphore_hashtable = {}
def getSemaphore(name, maxinum_users):
if not semaphore_hashtable.has(name):
semaphore_hashtable[name] = Semaphore(maxinum_users)
return semaphore_hashtable.get(name)
5.4.4 并行和计时(Concurrency And Timing)
目前为止我们一直避免同时运行多个行为树。但是在我们行为树中的一些动作可能会发费一些时间才结束。移动到门口,播放开门动画等都要花费一段时间。在AI的之后几帧中,如何知道接下来做什么?我们通常不希望再重新从根节点重新执行,而是继续上边的状态。
一种方案是行为树使用多线程,每颗行为树都在它自己的线程中执行。当某个行为要花费几秒时间时,行为树线程就sleep直到Action返回结果。
一种更复杂的方案是使用一种协同多任务和更新算法来合并行为树(第九章会看到)。事实上这样同时执行非常多线程会非常耗费。这也是一种更加常见的实现。本书源码有附带这样一个实现示例。
伪代码中我们假设已经有了多线程实现。
等待
在上边我们提到的暂停任务允许Action操作角色时等待一会,简单实现如下:
class Wait(Task):
duration
def run():
sleep(duration)
return result
并行任务(the parallel task)
在我们的并行世界,我们需要使用第三个组合任务:并行。
并行和Sequence类型,任何一个子节点失败就失败,所有子节点都返回True才返回True。区别时并行的所有子节点都同时运行,可以认为对每一个子节点都创建了一个线程,最后同时停止他们。
当失败的时候,要把所有子任务打断结束,这就需要每一个任务都有一个打断接口。如下示:
class Task:
def run()
def terminate()
在一个完全并行的系统中,terminate函数只是设置一个flag,在run函数中对它进行判断决定是否停止。
class Parallel(Task):
children
runningChildren
result
def run():
result = undefined
for child in children:
thread = new thread()
thread.start(runChild, child)
while result == undifined:
sleep()
return result
def runChild(child):
runningChildren.add(child)
returned = child.run()
runningChildren.remove(child)
if returned == False:
terminate()
result = False
elif runningChildren.length = 0:
result = True
def terminate():
for child in runningChildren:
child.terminate()
使用并行节点
并行任务通常用来同时执行多个Action,比如使用并行角色可以边说话边切换武器同时移动到射击点。这三个行为并不冲突,这是一个低层级的使用示例。
更高层级的,我们可以通过组行为树控制一队角色的组行为,同时每个角色有他自己的行为树控制自己。这些组行为放在并行任务下边,一旦某个角色无法执行并返回失败,就通过Selector切到下一个子节点,如下图所示:
使用条件节点做检查的并行任务
另一个常用的用法是使用并行任务持续检查一个条件是否符合来持续执行某个Action,如果条件不符合就结束Action的执行,和Sequence的区别就是是否持续性检查,如下图示:
一个用行为树实现的状态机:
内部任务的行为(Intra-Task Behavior)
上边的并行示例,如果玩家一直未离开位置UntilFail会一直执行不返回结果导致玩家操作完计算机仍然在这个并行节点等待。解决方法是需要两个新任务节点,一个是类似并行的装饰器:可被打断子节点执行,以期望结果返回,代码如下:
class Interrupter(Decorator):
isRunning
result
def run():
result = undefined
thread = new thread()
thread.start(runChild, child)
while result == undefined:
sleep()
return result
def runChild(child):
isRunning = True
result = child.run()
isRunning = False
def terminate():
if isRunning:
child.terminate()
def setResult(desiredResult):
result = desiredResult
一个执行打断操作的任务:
class PerformInterruption(Task):
interrupter
desiredResult
def run():
interrupter.setResult(desiredResult)
return True
组合起来如下示:
5.4.5 在行为树中添加数据
为了在行为树内部共享数据,我们需要加入一个blackboard的存储结构,不同的任务可以设置或者读取内部存储的值。
伪代码如下:
class MoveTo(Task):
blackboard
def run():
target = blackboard.get('target')
if target:
character = blackboard.get('character')
steering.arrive(character, target)
return True
else:
return False
class SelectTarget(Task):
blackboard
def run():
character = blackboard.get('character')
candidates = enemies_visible_to(character)
if candidates.length > 0:
target = biggest_threat(candidates, character)
blackboard.set('target', target)
return True
else:
return False
一些实现是将blackboard指定给特定子树使用而不是在整棵树内共享数据,这就需要有特定的装饰器来操作:
class BlackboardManager(Decorator):
blackboard = None
def run():
blackboard = new Blackboard()
result = child.run()
delete blackboard
return result
class Blackboard:
parent
data
def get(name):
if name in data:
return data[name]
elif parent:
return parent.get(name)
else:
return None
添加blackboard的Task新结构如下:
class Task:
def run(blackboard)
def terminate()
class BlackboardManager(Decorator):
def run(blackboard):
new_bb = new Blackboard()
new_bb.parent = blackboard
result = child.run()
free new_bb
return result
5.4.6 重用树
重用整棵树
Enemy Character(goon):
model = 'enemy34.model'
texture = 'enemy34-urban.tex'
weapon = pistol-4
behavior = goon-behavior
def createBehaviorTree(type):
archetype = behavior_tree_library[type]
return archetype.clone()
重用子树
子树本质也是一颗树,不过是会被作为另外一棵树的某棵节点,一些常见的子树如下示:
使用一个装饰器节点负责具体创建子树:
class SubtreeLookupDecorator(Decorator):
subtree_name
def SubtreeLookupDecorator(subtree_name):
this.subtree_name = subtree_name
this.child = None
def run():
if child == None:
child = createBehaviorTree(subtree_name)
return child.run()
5.4.7 行为树的限制
行为树在代表基于状态的行为树时显得很笨拙,如果想让角色基于Action执行返回结果来决定行为的转移,行为树很适合。如果让角色能够响应外部的事件-然后打断当前行为做其他事情:比如说角色在发现弹药量少时更改行为策略用行为树实现会很困难。
我们可以建造一个混合的系统,角色可以有多个行为树和一个状态机,状态机来决定当前角色执行哪一棵行为树。