顾名思义,类比状态机每个节点是一个状态,行为机每个节点是描述一种行为。行为机每个节点之间是互斥的,并且节点相互之间完全不用关心是怎么切换的。这里就不讲状态机跟行为树是怎么做ai的了,这里只讲用行为机怎么做一个ai。举个例子 mmo中的小怪策划案,大致会这么写:
小怪在出生点周围巡逻。发现周围有玩家则选择一个玩家做目标,追击该目标玩家,追到目标玩家则攻击目标玩家,发现距离出生点太远则返回,返回到出生点则继续巡逻
我们首先定义好怪物有哪些行为。很简单,我们直接根据策划案中的字面意思,怪物大致有这么几种行为:
a.巡逻
b.选择一个玩家追击并且攻击
e.返回出生点。
注意很多状态机会把移动作为一种状态,这在行为机中是不对的,因为巡逻,追击,返回都会有移动,移动只是玩家行为节点中的一个部分,移动跟巡逻,追击,返回并不是互斥的。
节点不要拆的太细,因为每个行为是个协程,我们可以在行为节点中写十分复杂的逻辑,比如有些同学可能会把 选择一个玩家追击并且攻击 这一个节点拆成 a 选择目标 b 追击目标 c 攻击目标.
甚至还有人会把巡逻拆的更细,拆成a.寻找一个点 b.移动 c.等待一定时间。这都是状态机跟行为树的思维,因为状态机跟行为树可能希望移动节点可以共用。这都是增加了麻烦,不合理,不要用状态机跟行为树的思维去想行为机。行为机中,节点只是描述一种行为,并不需要共用,共用的永远是各种函数。
我们把每个行为确定好对应的条件,一旦条件满足则会进入该行为,取消上一个行为(协程)
a. 巡逻的条件:身上没有玩家目标,周围没有玩家,距离出生点 < 10米
b. 选择一个玩家追击并且攻击: 周围有玩家,距离出生点 < 10米
c. 返回出生点: 距离出生点 > 20米
其实条件一旦列出,那么节点中的Check方法自然也就实现了
a. 巡逻的伪代码:
while (true)
{
pos = 出生点周围找一个点
bool ret = await MoveToAsync(pos,cancelToken);
if (!ret) // false表示协程取消, 则需要return,停止整个协程
{
return;
}
// 移动到了,随机等待2-4秒
randomTime = RandomHelper.Random(2000, 4000);
bool ret = await TimeComponent.Instance.Wait(randomTime, cancelToken)
if (!ret) // false表示协程取消, 则需要return,停止整个协程
{
return;
}
}
这样,如果b c条件不满足的话,怪物就永远在巡逻节点协程中,不停的找一个点移动,等待,移动,等待
b. 选择目标追击并且攻击目标节点的伪代码:
while (true)
{
target = SelectTarget()
while (true)
{
while (true)
{
// 追击目标
pos = 计算离目标0.2米的一个点
// 这里不能以目标作为移动目标,因为怪物要距离玩家稍远一点
await MoveToAsync(pos, cancelToken);
if (!ret) // false表示协程取消, 则需要return,停止整个协程
{
return;
}
// 距离玩家 < 0.5米,表示追到了玩家,就不需要追了
if (距离玩家<0.5米)
{
break;
}
}
// 追击到了,攻击玩家
while (true)
{
//
spellId = SelectSpell();
if (spellId == 0) // 可能技能在cd,等待500ms再试
{
bool ret = await TimeComponent.Instance.Wait(500, cancelToken)
if (!ret) // false表示协程取消, 则需要return,停止整个协程
{
return;
}
continue;
}
await CastSpell(target);
// 攻击完成后停止一段时间
bool ret = await TimeComponent.Instance.Wait(1000, cancelToken)
if (!ret) // false表示协程取消, 则需要return,停止整个协程
{
return;
}
// 距离玩家 > 0.5米, 距离玩家远了,break攻击循环,继续追击
if (距离玩家<0.5米)
{
break;
}
}
}
// 这里加个time,防止上面两个while循环没有进入,结果就会导致一直执行 target = 选择一个目标 这句话,会导致死循环
bool ret = await TimeComponent.Instance.Wait(100, cancelToken)
if (!ret) // false表示协程取消, 则需要return,停止整个协程
{
return;
}
}
c. 返回出生点伪代码:
while (true)
{
// 整个返回过程是无敌的
using (Buff buff = AddBuff(无敌))
{
pos = 找到离出生点10米的点
bool ret = await MoveToAsync(pos,cancelToken);
if (!ret) // false表示协程取消, 则需要return,停止整个协程
{
return;
}
// 移动到了, buff会删除,或者切换成其它状态,协程退出也会删除无敌buff
}
}
其实巡逻跟返回两个节点也可以合并成一个节点,这个大家自己去尝试尝试。可以看出,行为机编写是非常简单,代码是非常易读的。就这么一个怪物的逻辑,用行为树来编写,节点就很多了,而且并不好阅读跟重构。整个逻辑可能还有些瑕疵,不过意思应该很明白了。