翻译:张华栋(wcby) 审校:陈敬凤(nunu) 这篇文章是系列教程《使用群体行为创建一个冰球游戏的AI》的第三部分,这个系列共4篇文章,将用详细的步骤描述如何构建一个冰球游戏的AI部分。
在这个系列之前的文章中,我们主要是聚焦在我们所学到的人工智能背后的概念。在这篇文章中我们将把所有学到的内容都在一款完整的冰球游戏中展现出来。。你将学到如何给游戏添加必要的组件,诸如计分、道具以及一些游戏设计的东西。 最终结果 下面的内容是使用这个教程描述的所有元素实现的冰球游戏。 考虑下游戏设计 这个系列之前的文章主要聚焦在对游戏人工智能框架如何工作进行解释。每一部分都详细描述了游戏的某一个特定方面,如运动员如何移动,运动员的攻击和防御状态如何实现等等。它们都是以群体行为和基于堆栈的有限状态机器等概念为基础。 为了让这个游戏具有充分的可玩性,核心的游戏机制必须包含游戏的方方面面。最显著的选择便是实现一场正式冰球比赛中的所有正式规则,但这需要我们投入非常非常多的精力与时间。让我们用一种更加简单的方法来替代。 所有的冰球规则都将被简化成一条:如果你控制着冰球并与一名对手发生了接触,你便会被冰冻起来并被彻底粉碎!这样的规则改变对于双方玩家来说都让游戏变得更简单并且更有趣:一方控制着冰球而另一方尝试着抢球。 为了强化这一机制,我们将添加一些道具。它们将帮助玩家得分并让游戏变得更加动态化更加有趣。 添加得分的能力 让我们先从分数系统开始,它负责决定玩家的输赢。每当一支球队将冰球弄进对方的球门他们便能得分。 实现这一方式的最简单方式便是使用两个重叠的矩形: 用来描述球门区域的重叠矩形。如果冰球与红色矩形发生了碰撞,那么球队就得分了。 绿色矩形代表的是球门结构(球门的框和球网)所占有的区域。它就像是一个固体障碍物,所以冰球和运动员不可能穿越它,如果他们与球门结构发生了碰撞,他们将被反弹回去。 红色矩形代表的是”得分区域”。如果冰球与红色矩形重叠,这便代表团队得分了。 红色矩形挨着绿色矩形,并且位于绿色矩形的前方,所以如果冰球碰触到是球门前方以外的任何一面,它便会被反弹并且团队也不会得分: 如果移动冰球根据触碰到的球门所在矩形不同位置将会发生的一些情况 在某队得分以后对一切内容进行重新组织 在某支队伍得分后,所有的运动员必须返回他们在冰球比赛场的初始位置,冰球也必须再次被置于冰球比赛场的中心位置。这些处理完毕之后,比赛将继续进行。 将运动员移动到他们在冰球比赛场的初始位置 正如这个系列之前的文章解释的那样,所有运动员都有一个名为“准备比赛”的AI状态,这个状态把会把他们移到他们在运动场的的初始位置,并会让他们平滑地停在那里。 当冰球与其中一个“得分区域”重叠时,所有运动员的任何当前有效的AI状态都会被清除,所有运动员的AI状态都会被重置成”准备比赛”状态。不管这时候运动员当时位于何处,他们都将在几秒钟后返回到他们在比赛的初始位置: 将摄像机移动到冰球比赛场的中间 因为摄像机总是跟着冰球,所以如果在某队得分后它直接转向比赛场中央位置的话,那么当前的视图便会突然发生改变,从而会让玩家感到困惑并且非常不舒服。 解决这个问题的一种更好的方法便是把冰球平滑的移动到冰球比赛场的中间位置,因为摄像机总是跟着冰球移动,所以这能够非常优雅地将视角从球门慢慢转向比赛场的中间。 这一点可以在冰球进入球门得分区域后通过改变冰球的速度矢量来做到。全新的速度矢量必须能够“推着”冰球向冰球比赛场中间进发,所以速度矢量的计算过程如下: 1 2 3 4 5 6 7 | var c :Vector3D = getRinkCenter(); var p :Vector3D = puck.position; var v :Vector3D = c - p; v = normalize(v) * 100; puck.velocity = v; |
通过从冰球当前的位置减去比赛场中间的位置,我们便能够计算出直接指向比赛场中间位置的矢量。 在对这个矢量归一化后,它可以放缩成任何值,比如说100,它将用来控制冰球朝比赛场中间位置移动的快慢。 下面这张图是关于新的速度矢量: 计算出让冰球移动向冰球比赛场中间位置所需的新速度矢量。 矢量V用来作为冰球的速度矢量,所以冰球将按照预期朝着比赛场中间位置移动。 为了确保冰球在朝着比赛场中间位置移动的同时能够避免任何奇怪的行为,比如与运动员产生的交互行为,在整个移动过程中我们需要将冰球设为无效。结果便是,它将不再与运动员产生交互并且将被标记为不可见。玩家将看不到冰球的移动,但是摄像机仍然能够跟随着它。 为了判断冰球是否已经位于正确的位置上,我们可以在移动过程中计算它与比赛场中间位置间的距离。举例来说,如果距离小于10,冰球便已经足够接近比赛场的中间位置,我们可以直接将冰球重置到比赛场的中间位置而不会让摄像机发生任何强烈的抖动。 处理完这些以后,便可以再次激活冰球以推动比赛的继续。 添加游戏道具 设计道具的原因是为了玩家能够达成游戏的主要目标,即通过控制这冰球到对方的球门从而得分。
至于道具的范围,我们游戏目前只有2种道具:“上帝的帮助”和“冰球恐惧”。“上帝的帮助”能让玩家的队伍在一段时间内增加三名额外的运动员,而后一种道具“冰球恐惧”会让对手远离冰球几秒钟。
当任何一队得分时,双方队伍都能够获得道具。 实现道具”上帝的帮助“ 因为所有通过“上帝的帮助“这个道具添加的运动员都是暂时的,所以运动员类要修改下可以支持一名运动员被标记成”幽灵“。如果一名运动员是幽灵的话,那么在几秒钟后它可以把自己从游戏中移除。 以下便是运动员类,只高亮了添加的幽灵功能: [size=1em][size=1em]1
[size=1em]2
[size=1em]3
[size=1em]4
[size=1em]5
[size=1em]6
[size=1em]7
[size=1em]8
[size=1em]9
[size=1em]10
[size=1em]11
[size=1em]12
[size=1em]13
[size=1em]14
[size=1em]15
[size=1em]16
[size=1em]17
[size=1em]18
[size=1em]19
[size=1em]20
[size=1em]21
[size=1em]22
[size=1em]23
[size=1em]24
[size=1em]25
[size=1em]26
[size=1em]27
[size=1em]28
[size=1em]29
[size=1em]30
[size=1em]31
[size=1em]32
[size=1em]33
[size=1em]34
[size=1em]35
[size=1em]36
| [size=1em][size=1em]public class Athlete
[size=1em]{
[size=1em] // (...)
[size=1em] private var mGhost :Boolean; // tells if the athlete is a ghost (a powerup that adds new athletes to help steal the puck).
[size=1em] private var mGhostCounter :Number; // counts the time a ghost will remain active
[size=1em]
[size=1em] public function Athlete(thePosX :Number, thePosY :Number, theTotalMass :Number) {
[size=1em] // (...)
[size=1em] mGhost = false;
[size=1em] mGhostCounter = 0;
[size=1em]
[size=1em] // (...)
[size=1em] }
[size=1em]
[size=1em] public function setGhost(theStatus :Boolean, theDuration :Number) :void {
[size=1em] mGhost = theStatus;
[size=1em] mGhostCounter = theDuration;
[size=1em] }
[size=1em]
[size=1em] public function amIAGhost() :Boolean {
[size=1em] return mGhost;
[size=1em] }
[size=1em]
[size=1em] public function update() :void {
[size=1em] // (...)
[size=1em]
[size=1em] // Update powerup counters and stuff
[size=1em] updatePowerups();
[size=1em]
[size=1em] // (...)
[size=1em] }
[size=1em]
[size=1em] public function updatePowerups() :void {
[size=1em] // TODO.
[size=1em] }
[size=1em]}
|
mGhost属性是一个布尔变量,能够表明运动员是否是一个幽灵,同时mGhostCounter 属性记录了运动员在从游戏内移除自己之前应该在游戏内呆的秒数。 这两个属性都是供updatePowerups()方法来使用: [size=1em][size=1em]1
[size=1em]2
[size=1em]3
[size=1em]4
[size=1em]5
[size=1em]6
[size=1em]7
[size=1em]8
[size=1em]9
[size=1em]10
[size=1em]11
[size=1em]12
[size=1em]13
[size=1em]14
[size=1em]15
[size=1em]16
[size=1em]17
| [size=1em][size=1em]private function updatePowerups():void {
[size=1em] // If the athlete is a ghost, it has a counter that controls
[size=1em] // when it must be removed.
[size=1em] if (amIAGhost()) {
[size=1em] mGhostCounter -= time_elapsed;
[size=1em]
[size=1em] if (mGhostCounter <= 2) {
[size=1em] // Make athlete flicker when it is about to be removed.
[size=1em] flicker(0.5);
[size=1em] }
[size=1em]
[size=1em] if (mGhostCounter <= 0) {
[size=1em] // Time to leave this world! (again)
[size=1em] kill();
[size=1em] }
[size=1em] }
[size=1em]}
|
updatePowerups()方法是在运动员的update()方法中进行调用的,能够处理运动员所有的道具处理过程。现在它所做的就是判断当前的运动员是否是一个幽灵。如果是的话,mGhostCounter属性将随着时间流逝而递减。 当mGhostCounter值到达0时,这意味着临时运动员的存活时间已过,它将把自己从游戏中删除。为了让玩家意识到这点,运动员会在消失前2秒开始进行闪烁。 最后,现在是时候来实现当道具被激活时给游戏添加临时运动员的逻辑了。这是在powerupGhostHelp()方法执行的,可以被主游戏逻辑调用: [size=1em][size=1em]1
[size=1em]2
[size=1em]3
[size=1em]4
[size=1em]5
[size=1em]6
[size=1em]7
[size=1em]8
[size=1em]9
[size=1em]10
[size=1em]11
| [size=1em][size=1em]private function powerupGhostHelp() :void {
[size=1em] var aAthlete :Athlete;
[size=1em]
[size=1em] for (var i:int = 0; i < 3; i++) {
[size=1em] // Add the new athlete to the list of athletes
[size=1em] aAthlete = addAthlete(RINK_WIDTH / 2, RINK_HEIGHT - 100);
[size=1em]
[size=1em] // Mark the athlete as a ghost which will be removed after 10 seconds.
[size=1em] aAthlete.setGhost(true, 10);
[size=1em] }
[size=1em]}
|
这一方法将对所有添加的临时运动员进行遍历。每个新的运动员将被加到冰球比赛场的底部区域并被标记为幽灵。 就像上面所描述的那样,标记为幽灵的运动员会在时间到达后被删除。 实现道具”冰球恐惧“ “冰球恐惧“这个道具会让所有对手远离冰球几秒。 就像对”上帝的帮助“道具做的事情一样,运动员类也要做一定的修改来进行适应: [size=1em][size=1em]1
[size=1em]2
[size=1em]3
[size=1em]4
[size=1em]5
[size=1em]6
[size=1em]7
[size=1em]8
[size=1em]9
[size=1em]10
[size=1em]11
[size=1em]12
[size=1em]13
[size=1em]14
[size=1em]15
[size=1em]16
[size=1em]17
[size=1em]18
[size=1em]19
[size=1em]20
[size=1em]21
[size=1em]22
[size=1em]23
[size=1em]24
[size=1em]25
[size=1em]26
[size=1em]27
[size=1em]28
[size=1em]29
[size=1em]30
[size=1em]31
[size=1em]32
[size=1em]33
[size=1em]34
[size=1em]35
[size=1em]36
[size=1em]37
[size=1em]38
[size=1em]39
[size=1em]40
[size=1em]41
[size=1em]42
[size=1em]43
[size=1em]44
[size=1em]45
[size=1em]46
[size=1em]47
[size=1em]48
[size=1em]49
[size=1em]50
[size=1em]51
[size=1em]52
[size=1em]53
| [size=1em][size=1em]public class Athlete
[size=1em]{
[size=1em] // (...)
[size=1em] private var mFearCounter :Number; // counts the time the athlete should evade from puck (when fear powerup is active).
[size=1em]
[size=1em]
[size=1em] public function Athlete(thePosX :Number, thePosY :Number, theTotalMass :Number) {
[size=1em] // (...)
[size=1em] mFearCounter = 0;
[size=1em]
[size=1em] // (...)
[size=1em] }
[size=1em]
[size=1em] public function fearPuck(theDuration: Number = 2) :void {
[size=1em] mFearCounter = theDuration;
[size=1em] }
[size=1em]
[size=1em] // Returns true if the mFearCounter has a value and the athlete
[size=1em] // is not idle or preparing for a match.
[size=1em] private function shouldIEvadeFromPuck() :Boolean {
[size=1em] return mFearCounter > 0 && mBrain.getCurrentState() != idle && mBrain.getCurrentState() != prepareForMatch;
[size=1em] }
[size=1em]
[size=1em] private function updatePowerups():void {
[size=1em] if(mFearCounter > 0) {
[size=1em] mFearCounter -= elapsed_time;
[size=1em] }
[size=1em]
[size=1em] // (...)
[size=1em] }
[size=1em]
[size=1em] public function update() :void {
[size=1em] // (...)
[size=1em]
[size=1em] // Update powerup counters and stuff
[size=1em] updatePowerups();
[size=1em]
[size=1em] // If the athlete is an AI-controlled opponent
[size=1em] if (amIAnAiControlledOpponent()) {
[size=1em] // Check if "fear of the puck" power-up is active.
[size=1em] // If that's true, evade from puck.
[size=1em] if(shouldIEvadeFromPuck()) {
[size=1em] evadeFromPuck();
[size=1em] }
[size=1em] }
[size=1em]
[size=1em] // (...)
[size=1em] }
[size=1em]
[size=1em] public function evadeFromPuck() :void {
[size=1em] // TODO
[size=1em] }
[size=1em]}
|
首先updatePowerups()方法将对mFearCounter属性的值减一,mFearCounter是运动员应该躲避冰球的总时间。每当调用fearPuck()方法的时候,mFearCounter的属性值就会变小。 在运动员类的update()方法中,我们添加了一个测试用来判断道具是否生效。如果运动员是玩家的对手方,并且由AI控制(调用amIAnAiControlledOpponent会返回真)并且运动员应该躲避冰球(调用shouldIEvadeFromPuck也返回真), 那么evadeFromPuck()方法该被调用。 evadeFromPuck()方法使用了躲避行为,这让实体可以避开任何物体以及这些物体的移动轨迹: [size=1em][size=1em]1
[size=1em]2
[size=1em]3
| [size=1em][size=1em]private function evadeFromPuck() :void {
[size=1em] mBoid.steering = mBoid.steering + mBoid.evade(getPuck().getBoid());
[size=1em]}
|
evadeFromPuck()方法所做的事情就是给当前的运动员添加一个躲避力。这个力将让他忽略其他已经添加的转向力而去避开冰球,那些已经添加的转向力可能是由当前活跃的AI状态添加的。 为了能够让其他对象避开,冰球的行为必须与boid一致,这也是所有运动员会做的行为(更多关于boid的信息请看这个系列的第一部分)。因此,一个包含了冰球当前位置的属性和速度的boid属性必须添加到冰球类里。 [size=1em][size=1em]1
[size=1em]2
[size=1em]3
[size=1em]4
[size=1em]5
[size=1em]6
[size=1em]7
[size=1em]8
[size=1em]9
[size=1em]10
[size=1em]11
[size=1em]12
[size=1em]13
[size=1em]14
[size=1em]15
[size=1em]16
[size=1em]17
| [size=1em][size=1em]class Puck {
[size=1em] // (...)
[size=1em] private var mBoid :Boid;
[size=1em]
[size=1em] // (...)
[size=1em]
[size=1em] public function update() {
[size=1em] // (...)
[size=1em] mBoid.update();
[size=1em] }
[size=1em]
[size=1em] public function getBoid() :Boid {
[size=1em] return mBoid;
[size=1em] }
[size=1em]
[size=1em] // (...)
[size=1em]}
|
最后,我们将在道具起作用的时候更新游戏的主逻辑来让对手避开冰球。 [size=1em][size=1em]1
[size=1em]2
[size=1em]3
[size=1em]4
[size=1em]5
[size=1em]6
[size=1em]7
[size=1em]8
[size=1em]9
[size=1em]10
[size=1em]11
[size=1em]12
| [size=1em][size=1em]private function powerupFearPuck() :void {
[size=1em] var i :uint,
[size=1em] athletes :Array = rightTeam.members,
[size=1em] size :uint = athletes.length;
[size=1em]
[size=1em] for (i = 0; i < size; i++) {
[size=1em] if (athletes != null) {
[size=1em] // Make athlete fear the puck for 3 seconds.
[size=1em] athletes.fearPuck(3);
[size=1em] }
[size=1em] }
[size=1em]}
|
这个方法将遍历所有对手的运动员(在我们的游戏里,就是右边队伍),调用每个运动员的fearkPuck()方法。这将触发逻辑让运动员在随后的几秒内避开冰球,这个的原因我们刚才解释过。 冰冻和破碎 最后需要添加到游戏中的便是冰冻与碎裂部分。它们是在主游戏逻辑里面执行的,程序将检测左边队伍的运动员是否与右边队伍的运动员发生重叠。 这个重叠检测是由Flixel游戏引擎自动执行的,每当发现重叠的时候都会去调一个回调函数: [size=1em][size=1em]1
[size=1em]2
[size=1em]3
[size=1em]4
[size=1em]5
[size=1em]6
[size=1em]7
[size=1em]8
[size=1em]9
[size=1em]10
[size=1em]11
[size=1em]12
[size=1em]13
[size=1em]14
[size=1em]15
[size=1em]16
| [size=1em][size=1em]private function athletesOverlapped(theLeftAthlete :Athlete, theRightAthlete :Athlete) :void {
[size=1em] // Does the puck have an owner?
[size=1em] if (mPuck.owner != null) {
[size=1em] // Yes, it does.
[size=1em] if (mPuck.owner == theLeftAthlete) {
[size=1em] //Puck's owner is the left athlete
[size=1em] theLeftAthlete.shatter();
[size=1em] mPuck.setOwner(theRightAthlete);
[size=1em]
[size=1em] } else if (mPuck.owner == theRightAthlete) {
[size=1em] //Puck's owner is the right athlete
[size=1em] theRightAthlete.shatter();
[size=1em] mPuck.setOwner(theLeftAthlete);
[size=1em] }
[size=1em] }
[size=1em]}
|
这个回调函数的参数是每个队伍发生重叠的运动员。测试将判断冰球的控制者是否不为空,也就是说冰球目前是被某个运动员控制着。 在这种情况下,冰球的控制者将与被重叠的运动员进行比较,如果其中的一名运动员正控制着冰球(也就是冰球的所有者),他便会被粉碎,而冰球的所有权也会转交到其他运动员手上。 运动员类中的shatter()方法将把运动员标记为未激活,并在几秒钟后将其置于冰球比赛场底部。它还会发射一些粒子来表示冰块,但是这块的内容将在其他的文章里面提及。 总结 在这篇教程中,我们实现了一些可以把我们的冰球原型变成一款真正完整可玩游戏的关键元素。我希望将注意力放在这些元素背后的概念上,而不是如何在这个或者那个引擎中实现它们。 也许在游戏中使用冰冻与碎裂方法听起来太过于疯狂,但它们有助于保证这个项目是可控的。体育规则总是非常明确,但是实现它们有时候感觉非常棘手。 通过添加一些画面和HUD元素,你可以以这个demo为基础创建你自己完整的冰球游戏。 参考内容 关于作者 Fernando Bevilacqua Fernando是一名计算机科学方向的教授,他在业余时间会作为独立开发者开发一些好玩的项目。他运营着一个Flash游戏开发网站: As3GameGears.
|