GameMaker: Studio 中文教程 #8: 恶魔行者与AI

作者:青铜的幻想
2016-09-07
24 26 22

引言

indienova 会员青铜的幻想为希望了解学习 GameMaker: Studio 的中文读者专门撰写了本系列教程。由于他目前正在参与的《冰杖秘闻》开发工作日益繁重,本次教材将是正篇教材的最后一期,之后会以特别篇的形式进行小主题更新,大家对这个系列有什么建议也欢迎留言说明。

特别推荐

正如本系列教材第一篇介绍过的,GMS 相比其他游戏引擎在中小型的 2D 游戏项目上有许多优势,也提高了免费版本供学习者使用。但如果想要开发商业作品,还是需要购买专业版本,各个导出模块的费用相对来说也并不便宜。steam 上的 GMS 大师版本国区价格高达 5100 元。

不过呢,GMS 实际上经常有优惠打折活动,偶尔也会参加慈善包活动。目前,humble bundle 最新一期的慈善包正是 Humble Gamemaker bundle,仅需 15 美元你就能够购买包括安卓,ios,html 导出模块以及若干个项目源码在内的 GMS 专业版。

dba05622aaf6192afefd01b3df73f7b0cea010c9

链接在这里

:D 想要学习使用 GMS 的读者可以趁机入正了。

教程文件

本次教程共享至 GitHub 的资源文件分为两部分:

  • 其一位于GMS_TUT_07_BASE目录中,是对上一次教程的项目文件进行重构后的结果,作为此次教程后续内容的基础。
  • 其二位于GMS_TUT_07目录中,包含此次教程完成后的全部内容。

教程目标

在上一次教程里,我们加入了恶魔行者这个敌方角色,但只是作为一个傀儡的存在。这一次,我们要将他变成疾行在黑暗中的冷血杀手!

再次重构

如果你看过上一次的教程,应该会还记得恶魔行者的脚本 DevilCreateDevilStep 等最初是从伊瑟拉的脚本复制过来,然后进行修改的。当时的出发点是为了能够复制一些必要的功能过来,从而快速的添加一个简单的恶魔行者。我们对比一下现在这两者脚本的差异,以 YseraCreate 脚本和 DevilCreate 脚本为例:

1

除了前面声明枚举变量 PlayerDirection 的地方之外,唯一的差别就是在后面添加了这几行代码:

m_attachedHitbox = instance_create(x, y, obj_hitbox);
m_attachedHitbox.m_attachedParent = id;
m_hp = 2;
m_isDead = false;    

主要作用是为角色添加碰撞盒、增加生命值变量和标志角色死亡的变量,但我们想想看,其实这部分的代码正是伊瑟拉也需要的功能。这说明两者的创建脚本是完全一样的,这样的重复就是我们需要为这两个人物提取出一个共同的父类的强烈信号。
于是我们新建一个Object类别叫做obj_character(人物类别)作为他们的父类,它在这个游戏的类别继承关系中是这样的,在加入它之前:

2

在插入人物类别以后:

3

具体的重构过程不再赘述,主要的思路就是将两者共有的代码移入父类的对应脚本中,而具体取值的差异,例如伊瑟拉的站立动画是spr_ysera_idle,恶魔行者的站立动画是spr_devil_idle。对于这种情况可以新建一个变量spr_character_idle,然后对两者分别赋值即可。这里给出一个重构前后的代码行数对比:

重构前共计 252 行代码:

YseraCreate:14
YseraStep:107
YseraAnimationEnd:11

DevilCreate:12
DevilStep:90
DevilAnimationEnd:17
DevilOnDamage:1





重构后变动如下:

YseraCreate:12
YseraStep:64
YseraAnimationEnd:该脚本删除

DevilCreate:12
DevilStep:38
DevilAnimationEnd:该脚本删除
DevilOnDamage:该脚本删除

CharacterCreate:33
CharacterStep:52
CharacterAnimationEnd:17
CharacterOnDamage:1

重构之后的代码总行数变为 229 行。减少的部分主要来自于从两个子类移入父类的代码,而增加的部分来自于新增变量的初始化和在子类中赋值的部分,但总的代码量还是减少了。重构后的项目文件已放入 GitHub 项目的 GMS_TUT_07_BASE 文件夹中,作为本次教材的基础供读者参考。

AI行为模式

说起 AI,常常会给人一种高深、神秘的感觉。但现实情况是,高深而神秘的 AI 一般只存在于学术界的前沿课题中,而游戏里需要具体实现的 AI 系统通常只是运用了简单的“规则”。

为了让我观点更具有说服力,我特意挑选了一款最近玩过的游戏《挺进地牢》来作为验证。首先让我们来看看以下场景:

4

这里你看到了什么?只是一群疯狂追逐着你的敌人和弹幕,你很难察觉到规则的存在。但如果我们将这个场景中三种敌人的行为分解开来看,你就会发现第一种敌人是一个一次射击一发子弹的杂兵:

5

在这个动图里可以清楚的看到它的所有逻辑可以很简单的描述成:靠近至主角到一定距离后以一定的频率向主角射出一发子弹。
第二种敌人和前者类似,唯一的区别是发出一圈扇形的子弹:

6

第三种敌人同样是尝试接近游戏主角,只是到一个更近的距离,然后在一段时间蓄力后冲撞玩家人物:

7

如果单看每种敌人,逻辑都很简单,但当策划把不同种类的敌人放在一起,他们之间的各种组合以及玩家自发的反应结合起来,就奇妙的形成了一个生机勃勃的战斗场景。

因此对于我们要赋予恶魔行者的AI,也并不需要多么复杂,根据我们的策划案,恶魔行者是一个敏捷的近战,他的行为描述如下:

  • 向玩家方向移动
  • 当与玩家间小于一定距离后对玩家发起冲刺,冲刺的目标是玩家角色当前位置两侧,该目标确定后不再随玩家角色移动而改变
  • 冲刺到达目标位置后向玩家角色所在的方向发起一次攻击
  • 攻击完成后向远离玩家的方向后退2秒钟,然后重复第1步

有限状态机模型

“有限状态机”这个词同样源自计算机科学,如果你去谷歌搜索一下,你可能又会迷失在一大堆有的没的专业术语之中。以上面提到的恶魔行者的行为为例,我这里简单介绍一下有限状态机在游戏开发中的具体应用:

  • 状态的定义。如果按照以上的描述,可以为恶魔行者建立4个状态——追踪、冲刺、攻击、撤退。
  • 每种状态下的行为。例如“追踪”状态下,他的行为是想玩家角色所在的方向移动。
  • 状态的切换。在“追踪”状态下,当与玩家角色的距离小于一定数值时,切换至“冲刺”状态。
  • 状态切换时的行为。例如从“追踪”状态切换到“冲刺”状态时,是不是需要给角色播放一个大喝一声的音效呢?

我们为他建立的状态机模型可以按下图来描述:

8

在我看来,把这样的状态模型用图的形式描述出来的最大好处是方便你思考现有模型是否合理,状态切换有没有其他的可能性。例如,如果主角有瞬间移动的功能可以突然出现在恶魔行者的面前,那么他有没可能直接在追踪状态下发起攻击,跳过冲刺的过程呢?如果在追踪的时候,恶魔行者受到了攻击生命值很低,我们要不要设定他直接从追踪状态切换到撤退状态呢?这些都是在开始具体实现之前需要考虑的问题。但在这个教程中,我们将完全按照这张图中所描述的模型来实现。

有限状态机在 GMS 中的实现

有限状态机的实现方式非常灵活,最简单的一种就是将枚举类型与 if-else 语句配合使用。

首先在脚本 DevilCreate中声明枚举变量,并将初始状态设置为“追踪”:

enum DevilState{
    DEVIL_FOLLOW,    //追踪
    DEVIL_DASH,    //冲刺
    DEVIL_ATTACK,    //攻击
    DEVIL_RETREAT    //撤退
}
m_devilState = DevilState.DEVIL_FOLLOW;  

然后就是整个AI行为控制的主循环:

if(m_devilState == DevilState.DEVIL_FOLLOW){
    DevilUpdateFollow();
}
else if(m_devilState == DevilState.DEVIL_DASH){
    DevilUpdateDash();
}
else if(m_devilState == DevilState.DEVIL_ATTACK){
    DevilUpdateAttack();
}
else if(m_devilState == DevilState.DEVIL_RETREAT){
    DevilUpdateRetreat();
}    

这些 DevilUpdateXXX函数,简单来说就是在哪个状态就干哪个状态该干的事,同时辅助状态的切换。

但值得注意的是,由于 GML 中并不支持一个脚本文件中定义多个函数,因此需要对每个 DevilUpdateXXX 函数定义一个同名脚本,即新建 DevilUpdateFollowDevilUpdateDashDevilUpdateAtttackDevilUpdateRetreat 四个新的脚本。

在明确了代码的结构和功能后,剩下的工作就是具体的编码实现了,这可能反倒是游戏开发中较为容易的部分。

追踪状态

首先从 DevilUpdateFollow 开始,这部分其实就是在上一次的教程里恶魔行者的行动代码,只需要从 DevilStep 搬运至 DevilUpdateFollow 中即可,但需要在这里加入状态切换的条件。

DevilCreate脚本中新增变量有:

//用于定义从追踪状态切换至冲刺状态的距离范围
m_dashDistance = 200;
//用于定义冲刺至玩家角色两侧的距离,这个距离应当与恶魔行者的攻击动画匹配。若玩家角色保持静止不动,那么恶魔行者冲刺到这里时应当正好能够攻击到她
m_dashDelta = 40;
//冲刺的终点目标
m_dashTargetX = 0;
m_dashTargetY = 0;

DevilUpdateFollow脚本中状态切换的代码:
if (distance_to_point(player.x, player.y) < m_dashDistance){
    m_devilState = DevilState.DEVIL_DASH;
    if(x < player.x){//冲刺至玩家左侧
        m_dashTargetX = player.x - m_dashDelta;
    }
    else{//冲刺至玩家右侧
        m_dashTargetX = player.x + m_dashDelta;
    }
    m_dashTargetY = player.y;
}    

这段代码应该也很好理解,就是当恶魔行者距玩家的距离小于设定数值时,切换至冲刺状态,并设置冲刺的终点。如果当前他的位置在玩家左侧,那么就向左侧冲刺,反之亦然。
写了这么多代码,来测试一下:

9

恶魔行者在行至玩家一定距离处就停住了,停住我们就放心了,说明他进入了冲刺状态,而冲刺状态我们还什么都没有做。

冲刺状态

接下来我们来添加 DevilUpdateDash 脚本中的代码,它所做的事情与追踪状态类似,主要的差别是目标点换成了 m_dashTargetXm_dashTargetY所定义的坐标,但这次我们尝试用与之前追踪状态里不同的方式来实现:

var distance = distance_to_point(m_dashTargetX, m_dashTargetY);
var deltaX = (m_dashTargetX - phy_position_x)/distance * m_dashSpeed;
var deltaY = (m_dashTargetY - phy_position_y)/distance * m_dashSpeed; 

if(distance < m_dashSpeed){
    phy_position_x = m_dashTargetX;
    phy_position_y = m_dashTargetY;
    m_devilState = DevilState.DEVIL_ATTACK;

    m_isAttacking = true;
    sprite_index = spr_devil_attack; 
    image_index = 0;
    m_fired = false;
}
else{
    phy_position_x += deltaX;
    phy_position_y += deltaY;
}

if(deltaX > 0){
    image_xscale = -1;
}
else if(deltaX < 0){
    image_xscale = 1;
}

DevilUpdateFollow 脚本中,x 轴方向与 y 轴方向的运动距离是独立计算的,这样的做法会导致当人物在沿斜 45 度角移动时,速度要比沿水平或垂直方向移动的速度要快,因为它是这两个方向移动的叠加。而目前在 DevilUpdateDash 脚本中,移动的距离是按照目标点与自身的连线方向计算的,因此能够保证各个方向同样的移动速度。

然后在达到冲刺目标后,设置为攻击状态,并播放攻击动画。好,那么在添加了冲刺的代码后再次进行测试:

10

很好,现在恶魔行者开始疯狂的输出了!

攻击状态与撤退状态

在从冲刺状态切换至攻击状态时,实际已经开始播放了攻击动画。所以在这个状态里所要做的事情仅仅是等待攻击动画播放完毕时,然后切换至下一个状态——撤退,并取消攻击动画和重置撤退时间。在 DevilUpdateAttack 脚本中:

if(m_isAttacking == false)
{
    m_devilState = DevilState.DEVIL_RETREAT;
    sprite_index = spr_devil_walk;
    m_retreatCurrentTime = 0;
}

在撤退的脚本 DevilUpdateRetreat 里,所做的事情和在追踪与冲刺里的相反,恶魔行者向远离玩家的方向前进,我们在 DevilCreate中增加两个变量:

m_retreatCurrentTime = 0;
m_retreatTime = 2;    

用于跟踪撤退的时间,具体实现如下:

if(m_retreatCurrentTime < m_retreatTime){
    var player = instance_find(obj_ysera, 0);
    var distance = distance_to_point(player.x, player.y);
    if(distance > 0){
        var deltaX = (phy_position_x - player.x)/distance * m_retreatSpeed;
        var deltaY = (phy_position_y - player.y)/distance * m_retreatSpeed; 
        
        phy_position_x += deltaX;
        phy_position_y += deltaY;
    }
    
    m_retreatCurrentTime += 1/30.0;
}
else{
    m_devilState = DevilState.DEVIL_FOLLOW;
}  

测试如下:

11

怎么样?是不是看起来差不多像样了:)

但实际上现在恶魔行者的攻击还只是一个动画而已,并没有实际的碰撞检测和减少主角生命值的功能。

恶魔行者的攻击实现

想要让他的攻击造成伤害,其实现原理与伊瑟拉发出的魔法球实际上相当近似,我们可以想象成在恶魔行者挥出那一刀时发出了一阵剑气,所有与剑气碰撞的目标都会受到伤害。唯一所不同的是魔法球是可以持续飞行的,而剑气在出现后立刻消失。

因此在具体编码实现上,两者也十分类似。对于伊瑟拉,我们有一个 Object 是 obj_ysera_magic_bullet,同样我们也为恶魔行者建立一个用于进行碰撞检测造成伤害的 Object,叫做 obj_devil_attack_area。但记住这个剑气其实只是我所做的比喻,实际上你并不想让玩家看到它,不过为了调试你可以在开发的初期给这个形状一个半透明的颜色用来观察它是否出现在了你想要的位置:

12

比如这个就是我初始设置的形状颜色,当功能全部调试完成后,只要简单的把这个图形的不透明度(Opacity)设置成0就好了。

这个 Object 的具体实现首先需要设定碰撞形状,其次是需要两个与之关联的脚本,一个用于在创建时利用 GMS 的 alarm 系统设定一个闹钟,另一个在闹钟到期时删除它:

脚本 DevilAttackAreaCreate:

alarm[0] = 10;  

这里为了调试查看碰撞形状,暂把闹钟时间设定为 10 帧以后。由于 GMS 默认游戏是 30 帧,因此这个时间是三分之一秒钟。在10帧过后,由于我们设定的是闹钟 0(alarm[0]),因此 Alarm 0 事件对应的行为会被调用,添加该事件是在事件窗口的以下选项:

13

在这个事件对应的行为中我们编写代码调用脚本 DevilAttackAreaAlarm,该脚本内容为:

instance_destroy();    

最后我们把DevilCreate脚本中的这一行:

obj_character_bullet = noone;    

改为:

obj_character_bullet = obj_devil_attack_area;    

这样,每次恶魔行者在做完攻击动作后,都会生成一个obj_devil_attack_area作为碰撞检测的物体,并在10帧后去除。最后再加上碰撞形状在人物两侧进行攻击时的偏移量(具体实现可参考源代码及教程六中的相关部分),测试如下:

14

最后,如果你还记的我们在上次教程中为每个人物添加的全身碰撞盒,我们现在需要再为它加上与obj_devil_attack_area的碰撞,并设定一些碰撞条件:

if((m_attachedParent.object_index == obj_ysera 
&& other.object_index == obj_devil_attack_area)
|| (m_attachedParent.object_index == obj_devil 
&& other.object_index == obj_ysera_magic_bullet))
{
    with(m_attachedParent)
    {
        CharacterOnDamage();
    }
}   

这里的含义是让恶魔行者与伊瑟拉的魔法球进行碰撞,以及伊瑟拉与恶魔行者的攻击判定碰撞。这里在以后添加更多种类的敌人或者己方队友时可扩展为将子弹类物品和人物分为“敌方”和“我方”两类,然后令不属同一方的子弹能与人物发生碰撞。

在这个改动后,伊瑟拉和恶魔行者之间相互伤害的流程就完整了:

15

上期教程的错误更正

感谢细心的网友小囧(821096877)在上期教程发现的一处错误,魔法球飞行尾迹的方向问题。问题产生的原因是当魔法球具有物理属性后,不再能通过设置物体的 image_angle 属性来控制它的旋转方向,而是应该用 phy_rotation 来控制。这一点与 phy_position_xphy_position_y类似,具有物理属性的物体,你同样无法直接设置它们的x和y坐标。

另外值得注意的一点是 phy_rotationimage_angle的旋转方向是相反的,一个是顺时针,一个是逆时针。

结束语

这个教程的初衷是在参与《冰杖秘闻》制作的过程中萌发的,我惊讶地发现 Game Maker:Studio 这款已经拥有了十多年历史的游戏引擎竟然可以如此完美地达到易用性与灵活性间的平衡。所以想要把制作过程中的一些经验心得整理出来做成一个系列教程,能够让有心制作游戏但对于编程又不那么有信心的玩家有多一个选择。此外,我也坦承希望通过这篇教材宣传一下我们正在开发中的独立游戏《冰杖秘闻》。

冰杖秘闻

indienova 地址 去看看

因此,我最初的想法是希望这个教程是一个让从来没有接触过 GMS 的新手都能跟着做游戏的教程,最开始的规划是至少做 10 期。但随着教程项目的进展,内容丰富度与日俱增,我感觉教程的难度在飞速提升。不仅仅超过了新手的接受范畴,对我来说写作难度也逐步提高。随着《冰杖秘闻》开发工作日益繁重,这个教材系列从早期能够把每个遇到的概念都讲透彻,到后期很多地方只能遗憾地简单带过,以致最近两期自我感觉并不满意,风格有些近乎流水账了。为了保证教材的质量,加之我也需要将精力重心转移到《冰杖秘闻》的开发工作之中,因此,这篇教材可能是本系列正篇的最后一节了。

不过,这个系列并不会就此宣告终结,由于对这个教程我还非常依依不舍,在与 indienova 沟通协商后,我们计划在正篇内容外再追加特别篇的教材 —— 下一期我们会特别教授配合版本控制工具与 Git 的使用来辅助进行多人协作的游戏开发。比起某些具体的技术细节来说,是否使用版本控制工具绝对是玩一票和认真做的独立游戏开发者之间更大的差距。无论是个人开发还是多人合作,版本控制都是必不可少的。

正如前文提到的一些原因,特别篇部分的 GMS 教材可能不会完全都由我来撰写,想参与本系列教材撰写工作的开发者也可以站内私信 @craft。

所以呢~ 老时间,下周再会!

近期点赞的会员

 分享这篇文章

您可能还会对这些文章感兴趣

参与此文章的讨论

  1. doodle 2016-09-07

    先抢占一个沙发。顺便想问一下楼主,gms对于按钮和ui的支持好吗?有没有什么入门的内容可以推荐一下!

  2. 小囧(821096877) 2016-09-07

    来学习新课程了,支持大神~

  3. 不高冷的学渣 2016-09-07 Steam 用户

    青铜的幻想打算做多少期教程?

    • Ibot 2016-09-08

      @不高冷的学渣:正篇似乎完结了。之后以特别篇的形式来更新。下一期会讲版本管理。

      最近由 Ibot 修改于:2016-09-08 17:01:09
  4. 小囧(821096877) 2016-09-08

    我在humble bundle上买了对应的东西,去YOYO官网上激活了professional/ios/android等。
    然后点击打开GMS,输入了license,GMS接着提示我重新打开来update。

    最后问题来了.........这个Updating Gamemaker Studio的小窗口完全不动了呀,更新不了咋办........
    搞了一上午还是没搞定.....无法下载更新.....有朋友知道是怎么回事吗?

    • Ibot 2016-09-08

      @小囧(821096877):激活过程可能需要开代理?另外官网版本激活2周以后可以取回 steam key 的版本,之后就简单了。

    • 小囧(821096877) 2016-09-08

      @Ibot:恩恩,谢谢,我用代理才更新好的,而且必须是可以下载东西的代理线路,总算弄好了~~

    • tabriswe 2016-09-11

      @小囧(821096877):请问您是挂什么代理,我换了几个VPN都还是不成功

  5. 核桃不是桃 2016-09-10

    版本控制可以可以,很想听听!感谢感谢

  6. forxidian 2016-09-13

    花了这么多时间免费教大家,建议可以开一个知乎live,或者值乎,想看的人可以付费。

    或者简单贴一下二维码,大家略微表达感激之情。

    • craft 2016-09-14

      @forxidian:目前作者正在紧张地投入到《冰杖秘闻》的开发中,希望大家关注支持这个项目,我相信这对作者是最大的鼓舞了。

  7. oKamiNemo 2016-09-14

    感谢分享~

  8. Hambaka 2016-09-15

    已经剁手花了15刀买了慈善包,爽到。等明年高考完的假期仔细看看这篇教程

  9. forxidian 2016-10-07

    终于用十一假期把8期教程囫囵吞枣学完了。

    越往后的难度越高得多啊。

    想要自学的话,下一步有什么建议吗?是啃帮助文档,还是找个简单的游戏源码来读?

  10. dreanzy 2016-10-29

    重构后, 当攻击没有m_attachedParent的物体时, 例如那些场景物件, MagicBulletCollision 脚本会报错.
    错误:
    Variable .(100018, -2147483648) not set before reading it.

  11. KiraXT 2017-03-07

    非常感谢您的教程,还是期望您以后有时间可以再完善教程给我们这种学生党学习~虽然有点伸手党,不过我也在跟着学习的,另祝游戏大卖~

  12. Akusto 2017-03-09 Steam 用户

    大大!我一个小白跟着你的教程一路做到了这里积攒了一些疑问
    1.关于魔法球初始位置的问题,为什么我在给魔法球加上了物理效果以后魔法球就总是在人物原点出现呢?那个deltaY和deltaX修改坐标位置也没有办法使用了?
    2.在给恶魔行者加了碰撞盒之后,恶魔行者死亡,但要如何摧毁他的碰撞盒呢?现在的情况是恶魔行者变成了一团灰烬,但那个透明的碰撞盒还在那里,魔法球也飞不过去,很是尴尬......有什么指令能够让碰撞盒随恶魔行者的死亡而被摧毁呢?

    • DuelWinVictory 2017-04-13

      学习了

      最近由 DuelWinVictory 修改于:2017-04-13 19:34:51
  13. DuelWinVictory 2017-04-13

    给作者点个赞!期待更多分享!

    最近由 DuelWinVictory 修改于:2017-04-13 19:34:08
  14. ChunMan_Yip 2018-07-14

    想问下这里devil的移动用move_towards_point为什么会动不了

  15. ltzibaozhe 2018-08-14

    请问demo7 里已经有这些代码了,还是按教程自己添加?

  16. 皮皮熊? 2021-05-04 微信会员

    2021年5月4日青年节,到这个年头GMS经过5年变化已经很多不同了,非常感谢大佬的讲解,在这里留名,领我进入了门槛。

您需要登录或者注册后才能发表评论

登录/注册