Jeff Orkin ———— Monolith Productions
jorkin@blarg.net
游戏中的触发器系统主要负责两个任务:对游戏中的所有的智能体 (agents) 响应事件 (event) 进行追踪;使智能体对这些事件做出响应时的处理开销最小化。集中化的触发器系统能够根据每一个触发器消息的优化级和影响范围对其进行过滤,从而保证对于每一个智能体而言,只处理它能力所及范围内的拥有最高优先级的触发器消息。
触发器消息是游戏设计者所希望的使游戏中的智能体做出反应的任何 " 刺激源 " ([Nilsson98],[Russell95]) 。在动作类游戏中,触发器消息可以是会影响智能体行为的任何听觉和视觉刺激,例如枪声、爆炸、临近的敌人或者尸体。触发器消息也可以由游戏中涉及到智能体的非生命物体发出,例如游戏中的操纵杆和控制平台。触发器消息可以通过许多种途径来实现,包括游戏代码、脚本、控制台命令或动画关键帧。智能体可以确定那些种类的触发器消息对于它是有意义的。
集中化的优点
如果不使用集中化触发器系统的话,那就需要对事件进行轮询 ( polling ) 。轮询有两个显著的缺点。首先,轮询要求每一个智能体查询整个游戏世界来寻找感兴趣的事件。例如,如果一个智能体要对敌人的枪声做出反应,它就必须遍历游戏中的每一个角色,询问每一个角色最后一次开火的信息。这就要求每一个智能体都额外的保存任何感兴趣的历史数据。而这些复杂度为 O(n ² ) 的查询操作的结果很可能是最近根本没有任何人开枪。其次,采用轮询制的话,为了减轻 CPU 的负荷,某些智能体会处于休眠状态,那么这时即使一枚火箭呼啸着从它身边飞过,它也根本无法做出相应的反应。
而在集中化系统中,在事件发生的时候,相应的触发器信息就被注册 ( register ) 了。在每一个时间周期,系统只需要遍历智能体列表。然后对每一个智能体,系统采用一系列的测试来决定是否对当前存在的任何触发器信息感兴趣。如果某一个智能体对当前所有的触发器信息都不感兴趣,那么他就根本不需要任何的额外处理。更为重要的时,系统可以根据触发器消息的类型和范围来过滤触发器消息。
结合了分组的触发器消息过滤在许多情况下是非常有效的,这在本文的最后会详细说明。通过对当前的触发器信息的优先级进行排序,系统可以保证在任何一个时刻,智能体只对最重要的事件进行相应。例如,当一个敌人已经站在你面前的时候,你根本不会再去理会远处的脚步声了。集中化系统比较轮询系统具有更高的通用性,可重用性和可扩展性。这是因为当一个新的触发器信息被引入系统时,几乎不必编写任何特殊的编码来处理这一新的类型。
定义一个触发器信息
触发器信息使用了位标志 ( bit— flag ) 来枚举其类型,并且定义了一组变量来描述其所需参数。下列的 TriggerRecordStruct 结构就定义了一个触发器消息的示例。
Struct TriggerRecordStruct
{
EnumTriggerType eTriggerType;
Unsigned long nTriggerID;
Unsigned long idSource;
Vector VPos;
Float fRadius;
Unsigned long nTimeStamp;
Unsigned long nExpirationTime;
Bool bDynamicSourcePos;
…
};
触发器消息的类型是通过位标示来枚举的。在每一个智能体中都记录了其感兴趣的所有触发器消息,这是通过一个将这些感兴趣的触发器消息的位标志组合而来的 unsigned long
类型的变量来实现的。下面是一个动作游戏的触发器消息类型示例。
Enum EnumTriggerType
{
kTrig_None = 0 ,
kTrig_Explosion = ( 1 << 0 ) ,
kTrig_EnemyNear = ( 1 << 1 ),
kTrig_Gunfire = ( 1 << 2 ),
…
};
位标志的组合使得智能体可以确定要注意和忽视的触发器消息。智能体还可以在游戏中改变某一个位标志来暂时忽略和注意某一类触发器消息。例如一个只对声音做出相应反应的“盲智能体”可以通过所有声音触发器消息位标示的逻辑与来定义,这就同时忽略了所有视觉触发器消息。
dwTriggerFlags = kTrig_Explosion | kTrig_Gunfire ;
触发器消息 ID 是在这个触发器消息注册时由触发器系统分配的唯一标示符。触发器消息 ID 使得在以后的操作中可以引用这个触发器消息,这在后面的删除触发器消息中会谈到。
源 ID(source ID ) 是创建了这个触发器消息的游戏对象的 ID 。这个源可以是开枪了的游戏角色,或者是爆炸了的一颗地雷。相应触发器消息的智能体必须知道究竟是谁产生了这个消息,从而可以开枪还击或者快速逃离爆炸地点。
每一个触发器消息在游戏世界中都有其位置和范围半径,它只会影响到其范围半径之内的智能体。大多数的触发器消息是静态的,但是有些却是持续移动的。如果 bDynamicSourcePos 为真,则表明这个触发器消息是移动的,每一个时间周期都必须刷新它的位置。这个属性使得一个移动的目标只需要注册一个触发器消息,而不是每一个它移动时都注册一个新的触发器消息。敌人靠近就是这样的一个例子,它必须提醒智能体周围有敌人,因此这类触发器消息的位置必须跟踪其产生源的位置。
触发器消息只生存一段特定的时间片断。每一个触发器消息都记录了它的创建时间和删除事件。在本书提供的代码中,时间是通过 Windows 多媒体计时器 timeGetTime() 函数返回的毫秒级时间。如果生存时间为 0 ,那么意味着这个触发器消息永远存在,或者是通过不同于定时的其他系统操作删除的。例如,如果智能体在到达某处时会拉动一个控制杆,那么控制杆就可以注册一个永不过期的触发器消息,知道它被拉动时,这个触发器消息才被删除。
触发器消息系统
触发器消息系统是一个存储现存的触发器消息记录的类。它提供了注册、删除和更新触发器消息的方法。
class CTriggerSystem
{
public:
CTriggerSystem();
~CTriggerSystem();
unsigned long RegisterTrigger( EnumTriggerType _eTriggerType,
unsigned long _nPriority, unsigned long _idSource,
const Vector& _vPos, float _fRadius, float _fDuration,
bool _bDynamicSourcePos );
void RemoveTrigger( unsigned long nTriggerID );
void Update();
Private:
TRIGGER_MAP m_mapTriggerMap;
BOOOL m_bTriggerCriticalSection;
};
当前的触发器信息被存储在一个 STL ( stand template library ) 多重映射表 ( multimap ) 中,根据优先级排序。
typedef std::multimap<unsigned short, TriggerRecordStruct*, std::greater<unsigned short>>TRIGGER_MAP;
在这里使用 greater<unsigned short> 比较函数而不是默认的 less< unsigned short > 比较函数是为了保证具有更高优先级的触发器消息排列在前面。多重映射表允许重复的关键字,这意味着不同的触发器消息可以拥有相同的优先级。
注册触发器消息
触发器消息是通过调用 RegisterTrigger() 来注册到系统中的。 RegisterTrigger() 创建一个新的触发器消息并且设置其属性。我们并不一定需要在参数中传递所有的属性值。常用的做法是在资源文件中预先定义好触发器消息的类型,从而只需要传递一个结构的引用即可。
unsigned long CTriggerSystem::RegisterTrigger(EnumTriggerType _eTriggerType, unsigned long _nPriority, unsigned long _idSource, const Vector& _vPos, float _fRadius,
float _fDuration, bool _bDynamicSourcePos )
{
// 创建触发器消息记录,并且赋值
TriggerRecordStruct* pTriggerRecord = new TriggerRecordStruct( _eTriggerType, _idSource, _vPos, _fRadius, _fDuration, _bDynameicSourcePos);
// 根据优先级存储触发器消息记录
m_mapTriggerMap.insert( TRIGGER_MAP::value_type( _nPriority, pTriggerRecord));
// 返回新建的触发器消息的唯一标识符
Return pTriggerRecord -> nTriggerID;
}
这个函数返回给调用者唯一的触发器消息 ID 。可以保证这个 ID ,然后用来引用这个触发器消息实例,特别是在删除这个触发器消息的时候。
删除触发器消息
通常,在游戏世界中删除某个现存的触发器消息是必须的。例如:如果某个触发器消息的产生源 " 死 " 了,那么这个触发器消息也就没有意义了。有些触发器消息可以被激活或者休眠。一个已经被拉动的操纵杆在游戏中对于智能体而言不再有任何用处。某个角色通过掩饰自己而暂时停止发出敌人接近类型的触发器消息。在所有这些情况中,可以通过调用 RemoveTrigger() 来删除当前的某个触发器消息。
void CTriggerSystem::RemoveTrigger( unsigned long nTriggerID )
{
TRIGGER_MAP::iterator it = _mapTriggerMap.begin();
while( it != m_mapTriggermap.end() )
{
if( it->second->nTriggerID == nTriggerID )
{
delete (it->second);
return;
}
else
++it;
}
}
刷新触发器消息系统
触发器消息系统的核心是 Update() 函数。这个函数删除过期的触发器消息,刷新动态位置的触发器消息,并且通知与某个触发器消息有关的所有智能体。
void CTriggerSystem::UpDate()
{
CAgent *pAgent = NULL;
float fDistance - 0.f;
TriggerRecordStruct* pRec;
TRIGGER_MAP::iterator it;
unsigned long nCurTime = timeGetTime();
// 删除过期的触发器消息,更新未过期的运动触发器消息的位置信息。
it = m_mapTriggerMap.begin();
while ( it != m_mapTriggerMap.end() )
{
pRec = it->second;
if( (pRec->nExpirationTime != 0) && (pRec->nExpirationTime < nCurTime) )
{
delete (pRec);
it = m_mapTriggerMap.erase(it);
}
else
{
// 刷新动态标示为真的触发器消息,重置时间戳
if(rRec-> bDynamicSourcePos == true )
{
UpdatePos( pRec->vPos );
pRec->nTimeStamp = nCurTime;
}
++it;
}
}
// 触发器智能体
for( unsigned long i = 1; i<g_nNumAgents; ++i )
{
pAgent = g_pAgentList[i];
// 检查是否需要更新
If ( nCurTime > pAgent->GetNextTriggerUpdate() )
{
pPagent->SetNextTriggerUpDate(nCurTime);
// 遍历所有触发器消息
for( it = m_mapTriggerMap.begin(); it != m_mapTriggerMap.end(); ++it )
{
pRec = it->second;
// 是否响应
if( !(pRec->eTriggerType & pAgent->GetTriggerFlags()) )
continue;
// 产生源不是智能体自己吧
if(pRec->idSource == i)
continue;
// 如果这个智能体响应这个触发器消息,那么 HandleTrig() 返回真
if( pAgent->HandleTrig(pRec))
{
// 在任何时候,只响应最高优先级的触发器消息
break;
}
}
}
}
}
首先, Update() 遍历所有当前的触发器消息。删除过期的触发器消息;对于拥有动态源的触发器消息,更新其位置和时间戳,而不用创建新的触发器消息。
然后, Update() 遍历所有的智能体通知它们相关的触发器消息。这个循环包含了一系列的 if 语句,在进一步的复杂处理前逐步进行必要的过滤。最先的去除是根据智能体的更新时间来进行。每一个智能体都有一个更新速率,用来防止它太过频繁,甚至于每一帧都进行更新。通常的更新速率为每秒 15 次。不同智能体的更新时间要错开,这样才能保证更新速率相同的智能体不会总是在同一个时刻更新。
对于在当前周期中需要更新的智能体而言, Update() 遍历当前的触发器消息。检查每一个触发器消息是否属于这个智能体感兴趣的触发器消息类型。智能体只对感兴趣的触发器消息进行进一步处理。
如果智能体对某个触发器消息有兴趣,而且这个触发器消息不是它自身创建的,这才执行最复杂的距离检查。
如果某一个触发器消息通过了智能体的所有检查,那么这个智能体的 HandleTrig() 函数被调用来处理这个触发器消息。 HandleTrig() 可能会进一步的计算和判断来确定是否对这个触发器消息做出反应。返回值的 true 或 false 用来告诉 Update() ,它是否进行了响应。一个智能体在某一个时刻只能响应一个触发器消息,因此如果已经响应了一个触发器消息,则停止检查其他的触发器消息。如果返回值是 false ,则循环继续,允许智能体响应其他触发器消息。因为触发器消息是根据优先级排序的,因此在任一时刻,保证了只响应具有最高优先级的触发器消息。“只响应一个触发器消息”的背后存在着一个假设:触发器消息会使智能体进入有限状态的某个状态。而这个状态控制着智能体如果对这个触发器消息代表的事件进行响应。只响应最高优先级的触发器消息,保证了智能体采取当前状况下的最正确的行为方式。如果智能体采用的时不同于有限状态机的其他结构,而且必须支持同时对多个触发器消息的响应,那么可以通过返回 false 来继续查找较低优先级的其他触发器消息。在任何情况下,触发器消息的生存周期都必须大于一个时间周期。只有这样,智能体才能在处理完最高优先级的触发器消息之后再来处理较低优先级的触发器消息。
智能体的层次分组
可以通过智能体的分组来最大化的体现触发器系统过滤的好处。如果游戏中有众多的智能体,对所有智能体进行触发器消息检查是不可能的。取而代之的是对智能体组进行触发器消息检查。触发器消息系统只需要一些细微的改动,就可以对智能体组进行处理。
智能体可以根据各种各样的标准来分类,包括位置、更新速率、种族或者派别。触发器信息系统可以循环地测试这些分组。如果系统确定某个组对当前的一个触发器消息感兴趣,
那么可以进一步测试组内的每一个成员。组内的成员也可以是智能体组,浙江欧形成了一个层次结构。例如,智能体可以先根据种族来划分大类,然后又根据位置来划分小类。分组使得只通过一次检测就可以过滤掉大量无关的智能体。例如可以有效地忽略掉遥远的触发器消息,或者中立的智能体可以忽略掉绝大多数的触发器消息,从而最小化其处理开销。
在实现上,支持智能体组的类可以通过支持单个智能体的类派生而来。当智能体形成一个组时,通过设定组的成员变量来反应组内成员的属性组合。例如:组对应的感兴趣的触发器消息的标志位可以通过组内所有成员的标志位的组合得到。如果一个组内有两个成员,一个对爆炸有反应,另一个对敌人的位置敏感,那么组的标志位就类似如下 :
pPgroup->SetTriggerFlags( pAgent0 -> GetTriggerFlags() , pAgent0 -> GetTriggerFlags() )
// 上述语句等价于
DwTriggerFlags = kTrig_Explosion | kTrig_EnemyNear ;
组的位置可以采用类似的模拟来处理,其位置被设置成所有成员位置的平均值。智能体需要增加一个变量来表示其半径,这个半径保卫了组内所有成员的位置。触发器消息系统的距离检测可以改为检查组的范围半径和触发器消息的范围半径是否相交来确定。
触发器消息系统的 Update() 函数的参数改为使用一个指向智能体列表的指针,而不是全局的智能体列表。 Update() 首先检查智能体组的列表。组的 HandleTrig() 函数然后再次调用 Update(), 传入组内成员列表。重复这个过程就可以完成从智能体组到智能体个体的递归过程。
触发所有可能性
一旦上述的基本的触发器消息系统得以应用,游戏中就可以实现无穷可能性的人工智能了。触发器消息可以用来提醒智能体枪声和爆炸;一个受了伤的智能体可以利用活动位置的触发器消息来寻求同样的帮助;甚至可以利用附着在无生命对象 ( 例如 门 ) 上的触发器消息来检测游戏者的交互行为。
让我们想象如下的场景:游戏者开枪打开了一扇锁着的门。枪为枪声创建了一个触发器消息,触发了附近的一个电脑角色智能体。这个智能体走到发出声音的位置,注意到了游戏者。智能体通过一个由游戏者角色的外表创建的活动位置的触发器消息看见了游戏者,它沿着这个活动触发器消息追击游戏者。游戏者打死了这个电脑角色智能体,尸体创建了一个永久的触发器消息。另一个电脑角色智能体经过尸体,响应了这个触发器消息。它起了疑心,因此激活了脚印触发器消息的标志位。而由于游戏者受了伤,在他的脚印位置上留下了一连串的触发器消息。当电脑角色智能体沿着脚印触发器消息追踪的时候,他经过了一个报警按钮。这个按钮在其创建时就注册了一个永久的触发器消息。智能体响应了按钮的触发器消息,于是走到按钮边,按响了警报。从而触发其他的电脑角色智能体去搜寻游戏者。
这只是触发器消息系统如何用来制作有趣的游戏的一个很简单的例子。触发器消息系统的功能是极为强大的,就看游戏设计者的想象力和创造力有多么丰富了 !
参考文献
[ Musser 01 ] Musser David R , Derge Gillmer J, Sainni Atul. STL Tutorial and Reference Guide : C++ Programming with the Standard Templage Library 2nd Ed. Addison – Wesley Publishing Co . , 2001
[ Nillsson 98 ] Nillson Nils J. Artificial Intelligence: A New Synthesis. Morgan Kaufman Publishers, Inc . , 1998
[ Russell 95 ] Russell Stuart Norvig Peter . Artificial Intelligence. A Modern Approach. Prentice Hall, 1995