编者按
indienova 会员青铜的幻想为希望了解学习 GameMaker: Studio 的中文读者专门撰写了本系列教程。本期教程将在游戏中添加一名敌人,并为主角的攻击动作加入逻辑。
教程目标
在上期教程中,我们向游戏添加了《冰杖秘闻》女主角伊瑟拉的魔法攻击和特殊技能。但就目前而言,仅有动作和效果,还只是美术表现上的完成而已。想要完成它的功能,我们必须还得有一个敌人来配合承受伤害,因此在本次教程中,我们会首先来为游戏加入一名蠢蠢的敌人——恶魔行者,接着真正意义上实现攻击的逻辑。
准备工作
照例,本系列教程的项目/代码及原始美术素材全部位于该 GitHub 项目库。
本次教程的内容基于目录 GMS_TUT_05 下的内容开始,完成后的项目文件放在目录 GMS_TUT_06。此次教程所需的美术资源可以在这里下载。
如果对之前内容感到生疏,可以查看这里的专题内容做回顾和复习。
导入美术资源
这次教程里需要的美术资源分别是恶魔行者的站立、移动、攻击和死亡的动画:
将上述4个动画分别导入GMS并保持与之前的规则一致,命名为:
- spr_devil_idle
- spr_devil_walk
- spr_devil_attack
- spr_devil_die
和主角主要的区别在于,他只有一个方向的行走和攻击动画(小角色嘛,没那么多预算)。为了使的资源结构清晰,我们为不同人物分别建立目录存放其相关资源:
注:在 Objects 或 Scripts 等资源分类中新建资源时我通常也会遵循类似的原则,尽量将相关资源单独建立文件夹存放管理。这只是我为了能够方便的浏览资源内容的一个习惯,并非必要的步骤,因此在今后的教程中为了节省篇幅会略过这样的细节。
建立Object
在开始动手写代码之前,让我们先想一想这个需要新加入的角色和之前的主角伊瑟拉有什么区别和共同点,因为这决定了哪些功能可以共享,哪些是需要新加的功能。比如两者最主要的区别在于,恶魔行者的行动是由 AI 代码来控制的,而伊瑟拉的行动是由玩家按键来控制的。但他们也有很多相似的地方,例如在站立、行走、攻击时需要播放对应的动画,又或者是在攻击的时候不能移动这样的逻辑。
既然与之前的代码有相似的地方,我们不妨试试看直接从之前的 obj_ysera
复制一个一模一样的 Object 来试试看会如何:
你们可以看到,我给这个 Object 取了新的名字 obj_devil
,为他的 Sprite 选了新的站立动画 spr_devil_idle
。那么如果此时我们将这个新的恶魔行者放入场景会怎么样呢?
出现了两个伊瑟拉!并且她们可以同时被玩家所控制!
这是因为之前 YseraStep 脚本中类似这样的代码:
else if(keyboard_check(ord('A'))) { phy_position_x = phy_position_x - 4; sprite_index = spr_ysera_walk_side; image_xscale = 1; m_playerDirection = PlayerDirection.LEFT; }
我们直接在移动的时候把人物动画设置成了 spr_ysera_walk_side
,所以不管是什么 Object 在使用这段代码,他移动时都会变成伊瑟拉的样子了。
但直接复制一个 Object 的好处是可以为我们省去一些相同的设置,例如物理属性的设置、父类的选取以及事件与脚本的关联等等,作为下一步修改的基础。
复制并修改代码
因为我们要对之前的代码进行修改,需要先复制一份代码出来,将 YseraCreate、YseraStep 和 YseraAnimationEnd 复制为 DevilCreate、DevilStep 和 DevilAnimationEnd。
然后在 obj_devil
的事件界面中把 Create、Step 和 Animation End 这三个事件里面对之前脚本的调用换成对新复制出来的三个恶魔行者的脚本的调用。
接下来我们就可以在新的脚本中开始改动了。首先看 DevileCreate 脚本,里面没有什么和伊瑟拉相关的代码因此保留不变就好。
然后看 DevilStep 脚本,我们将要把伊瑟拉相关的代码替换为恶魔行者的数据。首先打开脚本编辑器界面,然后 Ctrl+f 打开搜索框,在其中填入 ysera,那么所有高亮显示的地方就是我们要改动的地方:
我先总结一下,然后直接贴出最终修改后的代码:
- 将三个攻击动画
spr_ysera_attack_back
、spr_ysera_attack_front
和spr_ysera_attack_side
替换为spr_devil_attack
。 - 将技能动画
spr_ysera_skill
替换成noone
。这个关键词noone
(记住不是 none,是 no one——“没有人”)等价与其它编程语言中的null
,就是表示没有值或者是空值的意思。 - 将伊瑟拉的三个行走动画替换为
spr_devil_walk
。 - 将spr_ysera_idle替换为spr_devil_idle。
进行以上改动后,最后还剩三处出现了 ysera 的地方分别是发出魔法球的代码和释放技能效果的代码,因为在恶魔行者的逻辑中暂时还没有相对应的地方,所以先不管放在这里。修改后的 DevilStep 脚本如下:
image_speed = 0.25; if(!m_isAttacking && !m_isInSkill){ if(keyboard_check(ord('J'))){ m_isAttacking = true; switch(m_playerDirection){ case PlayerDirection.UP: sprite_index = spr_devil_attack; break; case PlayerDirection.DOWN: sprite_index = spr_devil_attack; break; case PlayerDirection.LEFT: sprite_index = spr_devil_attack; break; case PlayerDirection.RIGHT: sprite_index = spr_devil_attack; break; } image_index = 0; m_fired = false; } else if(keyboard_check(ord('K'))){ m_isInSkill = true; sprite_index = noone; image_index = 0; m_fired = false; } else if(keyboard_check(ord('A'))){ phy_position_x = phy_position_x - 4; sprite_index = spr_devil_walk; image_xscale = 1; m_playerDirection = PlayerDirection.LEFT; } else if(keyboard_check(ord('D'))){ phy_position_x = phy_position_x + 4; sprite_index = spr_devil_walk; image_xscale = -1; m_playerDirection = PlayerDirection.RIGHT; } else if(keyboard_check(ord('W'))){ phy_position_y = phy_position_y - 4; sprite_index = spr_devil_walk; m_playerDirection = PlayerDirection.UP; } else if(keyboard_check(ord('S'))){ phy_position_y = phy_position_y + 4; sprite_index = spr_devil_walk; m_playerDirection = PlayerDirection.DOWN; } else{ sprite_index = spr_devil_idle; } } if(sprite_index == spr_devil_attack || sprite_index == spr_devil_attack || sprite_index == spr_devil_attack){ if(image_index > 2 && m_fired == false){ var magicBullet = instance_create(x, y, obj_ysera_magic_bullet); var deltaX = 0; var deltaY = 0; switch(m_playerDirection){ case PlayerDirection.UP: magicBullet.m_speedY = -10; magicBullet.image_angle = 270; deltaY = -89; break; case PlayerDirection.DOWN: magicBullet.m_speedY = 10; magicBullet.image_angle = 90; deltaY = 7; break; case PlayerDirection.LEFT: magicBullet.m_speedX = -10; deltaX = -65; deltaY = -33; break; case PlayerDirection.RIGHT: magicBullet.m_speedX = 10; magicBullet.image_angle = 180; deltaX = 65; deltaY = -33; break; } magicBullet.x += deltaX; magicBullet.y += deltaY; m_fired = true; } } if(sprite_index == spr_ysera_skill){ if(image_index > 2 && m_fired == false){ instance_create(x, y, obj_ysera_skill_effect); m_fired = true } }
最后修改 DevilAnimationEnd 脚本,将其中的攻击动画替换为 spr_devil_attack
,技能动画 spr_ysera_skill
不用管就好。
此时如果尝试运行的话,会发现一个编译错误提示我们枚举类型 PlayerDirection 重复定义了,因此我们需要删除掉 DevilCreate 脚本中重复定义 PlayerDirection 的部分。
注:其实这里更加完善的做法是抽取伊瑟拉和恶魔行者的脚本中相同的部分并建立一个两个共享的父类(例如叫做 obj_character_base
),将这些相同的脚本放入父类中执行。而上面提到的枚举类型 PlayerDirection 的定义也应该放入这个父类中。
接下来运行测试看看:
这次测试验证了恶魔行者的站立、行走和攻击动画现在已经可以正确播放了,接下来需要改动的是 AI 控制部分。
人物控制
在控制主角的时候,人物上下左右的移动和攻击是通过检测按键的状态来完成的。而对于敌人,则是由 AI 代码来完成。出于篇幅的限制,我们没法在这期教程里实现复杂的AI逻辑,但我们可以通过一个简单的行为来演示 AI 控制人物。我们将要实现的逻辑是让恶魔行者始终向着玩家所在的位置移动。
我们把这部分的代码添加到 DevilStep 脚本,放在之前的这个条件语句中,把这个条件括号中之前的代码删除掉:
if(!m_isAttacking && !m_isInSkill){ //将之前的控制代码删除 }
然后加入AI控制的逻辑,首先是获取伊瑟拉所在的位置。这里需要用到的函数是 instance_find(obj, n)
,它的作用是找到第 n 个 obj 类型的实例(instance),所以我们需要调用:
var player = instance_find(obj_ysera, 0);
来取得场景中的伊瑟拉,这里我们做了一个假设是场景中有且仅有一个伊瑟拉存在。随后计算出从当前的这个恶魔行者到伊瑟拉的坐标差值:
var deltaX = player.x - x; var deltaY = player.y - y;
接下来根据这个差值来移动恶魔行者的坐标:
var mySpeed = 2; if(deltaX > mySpeed){ phy_position_x += mySpeed; } else if(deltaX < -mySpeed){ phy_position_x -= mySpeed; } else{ phy_position_x += deltaX; } if(deltaY > mySpeed){ phy_position_y += mySpeed; } else if(deltaY < -mySpeed){ phy_position_y -= mySpeed; } else{ phy_position_y += deltaY; }
最后再根据移动方向设置人物的朝向,以及设定动画:
if(deltaX > 0){ image_xscale = -1; } else if(deltaX < 0){ image_xscale = 1; } sprite_index = spr_devil_walk;
注意这里总是把人物动画设定成 spr_devil_walk
,这是因为他没有不同方向的行走动画。再次运行游戏:
这样,我们就实现了一个基本的跟随行为。
魔法球的碰撞检测
在加入了这个基本的敌人之后,就可以回到我们最初想要实现的攻击了。伊瑟拉的攻击方式有两种,第一个是发出魔法球的普通攻击,第二个是范围技能攻击,但实现方式基本相同,这个教程里以魔法球为例。首先需要让魔法球能够与敌人发生碰撞检测,所以要设置 obj_ysera_magic_bullet
的物理属性,将魔法球的碰撞形状设置成圆形,如下:
在为魔法球加入了物理属性之后,我们就不能直接修改它的坐标 x 和 y 了,而是设置 phy_position_x
和 phy_position_y
,因此要将 MagicBulletStep 脚本的内容修改为:
phy_position_x = phy_position_x + m_speedX; phy_position_y = phy_position_y + m_speedY;
接下来在事件窗口中为魔法球加入和其他物品的碰撞并添加碰撞的处理脚本 MagicBulletCollision:
脚本MagicBulletCollision的内容是:
if(other.object_index != obj_ysera){ instance_destroy(); }
这里只是简单的在魔法球与其他物品发生碰撞以后,将魔法球自身从场景中删除掉。这个括号里的条件是碰撞另一方(other)不可以是游戏的主角,因为如果没有这个条件的话,在魔法球发出的一瞬间就会和主角自身发生碰撞而被删除了。之后测试如下:
我们看到魔法球正确的与场景中的障碍物发生了碰撞后消失掉。但是却穿过了恶魔行者的身体,而没有击中他!原因是我们将所有人物的碰撞形状设置成了他们脚下的一小块矩形,这么做的理由又是为了在人物行走时能够正确的与场景中的物品碰撞。但开始做攻击判定时,却需要全身的碰撞才能实现我们想要的功能!
这个问题曾经困扰了我一段时间,最初我尝试在官网上找到能够为一个人物设置两个碰撞形状的方法,但结果是GMS并不支持这样。后来在一个论坛里看到了一个实现思路,顺着这个思路才完美实现了使用脚下的小块矩形做为行走的碰撞检测,而用等身大小的矩形做攻击的碰撞检测。这个思路的核心就是用另一个紧紧跟随人物移动的矩形物体来做碰撞检测,然后将碰撞结果通知人物。
添加全身碰撞
恶魔行者的人物大小约为宽40、高80的矩形,我们按照这个大小建立一个矩形的 Sprite,命名为 spr_hitbox
(hitbox——碰撞盒):
在此 Sprite 的基础上再建立一个名为 obj_hitbox
的物体,并设置物理属性。对于恶魔行者来说,他的 hitbox 可以放在 DevilCreate 脚本中生成,代码如下:
m_attachedHitbox = instance_create(x, y, obj_hitbox); m_attachedHitbox.m_attachedParent = id;
其作用是首先为恶魔行者创建一个碰撞盒(obj_hitbox
)并存放于变量 m_attachedHitbox
,其次是为这个碰撞盒的实例添加一个变量 m_attachedParent
用于引用恶魔行者自身,因为在碰撞盒的 Step 更新函数中,需要知道恶魔行者的位置用以更新自己的位置。
还需要为 obj_hitbox
创建两个脚本 HitBoxCreate 和 HitBoxStep,分别对应 Create 事件和 Step 事件,它们的内容如下:
HitBoxCreate脚本:
m_attachedParent = noone;//声明变量 phy_fixed_rotation = true;//固定旋转
HitBoxStep脚本:
//碰撞盒的坐标总是跟随恶魔行者的坐标 if(instance_exists(m_attachedParent)) { phy_position_x = m_attachedParent.phy_position_x; phy_position_y = m_attachedParent.phy_position_y; }
通过运行游戏实际测试来调整碰撞块的原点坐标,使得在游戏中人物和它能够尽量的重合,那么这一步就接近完成了:
这里可以看到碰撞盒已经可以跟随恶魔行者移动。最后,只要再在 obj_ysera_magic_bullet
的事件中加上与碰撞盒的碰撞检测,这样魔法球就可以和人物的碰撞盒产生碰撞并消失了:
碰撞处理函数
由于是恶魔行者身上的碰撞盒与魔法球发生了碰撞,因此还需要需要将碰撞事件通知到恶魔行者。整个消息的传递通过这样的方式进行,首先也为碰撞盒添加与魔法球的碰撞事件,并建立一个新的脚本 HitBoxOnDamage 用于处理这个事件。然后再为恶魔行者也建立一个 DevilOnDamage 脚本。暂时我们只在 DevilOnDamage 脚本里面加上一个调试语句,用于在运行是检查是不是正确调用到了这个函数:
show_debug_message("Devil on damage.");
show_debug_message
函数的作用是在调试窗口中输出一条信息。
而碰撞盒的 HitBoxOnDamage 脚本的内容是这样的:
if(instance_exists(m_attachedParent)){ with(m_attachedParent){ DevilOnDamage(); } }
这里的 m_attachedParent
变量是在碰撞盒被创建时就把恶魔行者对象赋值给了它,这里我们先做一个安全检查,验证这个恶魔行者是否还存在,如果存在的话,就调用他的 DevilOnDamage 函数。
前面的教程我们提到过,GML 语言并不是一种面向对象的语言,但提供了一些机制来模拟面向对象。在这个脚本中的关键词“with”就是起到这个作用,通常对于 java、c++ 或者 c# 这样的语言来说,想要调用一个对象的成员函数,通常的格式是 object.DoSomthing(),而对于 GMS 来说它的格式是:
with(object){ DoSomething(); }
这时,采用调试模式运行游戏(Run game in debug mode)进行测试的话,会在魔法球打到恶魔行者身上的碰撞盒时,看到调试信息。
生命值与死亡
现在我们可以为恶魔行者加入用于表示生命值和是否死亡的变量了,在 DevilCreate 中添加:
m_hp = 2; m_isDead = false;
然后在之前添加的DevilOnDamage中删去调试信息,加入代码使得每次被魔法球打中时生命值减少1:
m_hp = m_hp - 1;
最后,在 DevilStep 中检查他的生命值是否等于 0,如果等于 0 则播放死亡动画,并将标记该人物是否死亡的变量 m_isDead
设置为 true。下面这段代码是加入到 DevilStep 脚本的最开始:
if(m_isDead) return 0; if(m_hp == 0){ m_isDead = true; sprite_index = spr_devil_die; return 0; }
值得注意的是这两个 return 返回语句,这是因为当人物处于死亡状态后,我们就不希望继续进行下面的人物移动代码了。
另外在 DevilAnimationEnd 脚本中加入:
if(m_isDead){ image_speed = 0; image_index = image_number - 1; }
因为在 GMS 中是默认所有的动画都是无限循环播放的,这段代码让人物的死亡动画播放完一遍后,就停止动画的播放(通过把动画播放速度image_speed设置为0),同时让人物停留在死亡动画的最后一帧。
最后我们再把 spr_hitbox
的透明度设置为0:
就可以测试最终的结果了:
好,现在主角的攻击就完成了,我们下次再来完善恶魔行者的攻击并加入更加有趣的 AI!
在这抢个沙发不会被爆破吧~
感谢大触的教程,昨晚上刚学完了第二课,对我这种从没接触过代码的人来说
有个教程真是太好了
关于光球碰撞的那个问题,其实应该一开始就把光球的原点设在光球图案的下方(也就是光球在地面的投影中心),光球判定框也应该设在脚下,这样所有的飞行道具和浮空角色就都统一了。
@BrotherShort:是的,从三维空间上来分析是这样的。但是会造成这样的可能性,比如光球和敌人的下半身擦过却没有击中,我觉得这样会感觉有点奇怪。
@青铜的幻想:我觉得BrotherShort方法更好一些,这样可以避免很多问题,给弹道在地上增加个投影有立体感就不会太奇怪了,我看了下以撒的结合的子弹应该也是用这种方法
@kamimika:我也去看了下以撒的结合,它的碰撞范围挺大的,有的看起来没有发生碰撞的情况也都做出击中的判定了。不过这也说明了其实真正在游戏的时候玩家是不会那么在乎精确性的。从是实现的角度来说,确实BrotherShort的方法一致性更好。
新手提问....
在子弹还不是物理属性之前,用image_angle实现了子弹不同方向发射拥有不同朝向的效果。
把子弹设置成物理属性之后,无论哪个方向射出来的子弹动画都是朝左了,看你的示例也是有这个问题。
请问是哪个地方出问题了?
@821096877:设置成物理属性以后就是由物理引擎的部分来控制物体的旋转了,应该是设置phy_rotation。和phy_position_x和x的对应关系一样。啊~~~ 我居然没注意到就这样发出来了 -_-!
@青铜的幻想:强迫症使然,哈哈,所以就问你了~才玩这个两天,幸亏有大神你的教程,真是太适合我们这种新手了!由衷感谢!期待更新更多的教程~
感谢分享~学完打卡。
发现一个问题(大概?):
最近由 SandCas 修改于:2016-09-25 13:55:55在“魔法球全身碰撞”部分最后的gif动画里面,向右发射出去的光球 方向是向左的……?
而且在下一张gif图里,向下发出去的光球,方向还是向左的
连最终的结果测试里,魔法球的方向都是固定向左的_(:з」∠)_
是增加完物理碰撞之后一定会是这个样子吗
我和朋友加完碰撞之后,都出现了法球发射点变回人物中心以及方向固定左边的问题。
目前正在一点点核对范例项目中……
@SandCas:我之前的回复说过这个问题了,加了碰撞以后就不能用x、y和rotation来设置了,要分别改用phy_position_x、phy_position_y和phy_rotation
首先感谢教程,学到了许多的东西,但是进行到加hitbox这块有些懵了。hitbox加了物理属性后会阻挡玩家的移动。
id...在这里是什么