因为单纯的2D游戏开发过于简单,所以本文尝试结合不同的2D平面游戏并将之归类,列出他们的优缺点,然后讨论下一些实施的细则。长远目标是在2D平面游戏开发方面的指导做的十分全面。如果有任何建议,更正,要求或者补充,请留言!
声明:有些文章的信息是通过反编译引擎的,并不是通过代码或者编程者本身。很有肯能他们不是通过这种方法实施的,只是看起来是这样而已!并且游戏tile的大小是为了游戏逻辑设定的,和实际是有出入的!
四种方案
由简单到复杂,我先将我知道的四种2D游戏常见实施方案列举出来:
方案1: 纯粹的瓦片渲染
在这种模式中,游戏角色的移动范围其实是完全被地图Tile限制死的,正常情况下,角色绝对无法站在两片相邻Tile的中央的位置。
而我们肉眼所见的渐进式角色移动(动画),也只是为了让游戏看起来比较流畅的权宜之计,它最终到达的也只是某一瓦片的范围之中,而不可能同时定位于两块瓦片之间(比如角色可以在(0,1)和(0,0)这两个坐标间任意移动,但每次实际的位置判定只能取这两个坐标之一,不能出现位于(0,0.5)这种定位情况)。
这种方案是最容易实施的,但暴露了很多严重的角色控制方面的限制,尤其是做传统动作游戏时,这是肯定不适合的。不过,这种方案在处理像素风的游戏或者卡通风格的游戏中却出乎意料的受到欢迎。
例子:波斯王子,Toki Tori, Lode Runner, FlashBack
如何实现?
因为地图是网格状的,所以每一块Tile区域(通常以2维数组形式存在)都存储都着是否是障碍物,能否通行的信息。
当然,有时也可以额外包含用什么图片表示,角色在此处脚步声音的信息,以及其他种种。一般来说,在这种模式中的游戏角色和其他游戏用单元通过一套统一的切图表示(素材由统一的纹理切分而来),但游戏角色或游戏单元占用多少地图瓦片却是不固定的。比如在Lode Runner这款游戏中,游戏角色占用的地图区域是1X1的Tile。
而在游戏Toki Tori中,游戏角色则占用了2X2的Tile。至于在Flashback中,由于背景用了不常见的小Tile,游戏角色甚至变成了2个Tile宽,站立的时候5个Tile高,匍伏的时候3个Tile高。
通常在这种类型的游戏中,游戏角色很少会进行对角线移动。但如果将移动分解成两个不同方向,也还是可以做到的。类似的,每次角色移动时只移动一像素位置是很常见的做法,而连续的快速移动,我们则可以通过多次移动像素位置来达到。具体判定移动的运算过程如下:
1、首先,提前拷贝角色位置信息,代入到他想移动到的地方(比如,向右移动时,每次拷贝一个位置到右边一格)。
2、检查这个拷贝与背景是否有碰撞。
3、如果发现碰撞,运动停止,作出反应。
4、否则,可以通过。把角色移动到此处。(可以加入动画)
这种方案对于弧线运动的操作十分之差,所以此类游戏一般不会出现这样的运动,或者只有上下左右的运动,因为这里只考虑了直线运动的情况而已。好处是这个方案的简单和准确性。因为处理的信息量很少,所以游戏角色被卡住的状况极少发生,游戏的可控性也会十分之强,甚至不需要对不同情况进行微调。
开发基于这种机制的游戏是十分简单的,你要做的只是检查用户Tile和背景的Tile,然后对齐它们并且适合赋予的动作。
原则上讲,这种模式是肯定不允许一个小于Tile的角色存在的,不过,我们也可以通过不同的方法来解决。比如,背景Tile可以设定的比游戏角色的Tile小些,或者你可以在某个Tile内做些视觉上的缩小效果,但也并不会影响游戏逻辑。(这个很可能是Lode Runner-The Legend Returns采取的方法)。
方案2:平滑的瓦片渲染
一般通过构建专用的Tiemap对象,检测通过Tilemap的具体角色单元来实现(很多游戏引擎中都内置有Tilemap类或类似作用类)。与上一方案不同的是,该模式中角色可以在地图里面自由移动,角色坐标不受网格制约(对于移动像素小于1像素的情况,选择取整)。这在当年的8位(如FC)和16位(如MD)游戏机器上也是十分常用的技巧,而今天也同样在使用。它的开发十分简单,而且对于地图编辑来说也并非太过负责。同时也能支持斜坡和平滑跳跃的动作。
如果你不确定你要在哪个平台上面实施游戏开发,并且你就只是想做个动作游戏的话,我建议你采取这个方案。它十分灵活,相对容易,堪称在四种类别中最容易掌握。这也解释了为什么绝大多数游戏开发者都会使用它。
例子: 超级玛丽, Sonic the Hedgehog, Mega Man, Super Metroid, Contra, Metal Slug
如何做?
就其基础部分而言,地图中瓦片信息的获取和纯Tile渲染方式是一样的,不同的仅仅是玩家和背景的互动算法。在Tiemap中,游戏角色的碰撞单位是一个轴对齐的盒子(AABB算法,可以把它理解为一个无法旋转的矩形),它通常会等于单独Tile的整数倍数大小。一般来说,应该是1Tile宽,1Tile高(小马里奥)或者2Tile高(大马里奥)甚至3Tile高。在许多游戏例子里面,角色的贴图是大于逻辑上碰撞单位的,这是为了更友好的视觉效果和游戏体验(比起防止让玩家碰到不该碰到的地方,更应该防止他碰不到应该碰到的地方)。比如,你的角色贴图约等于2个Tile宽,实际上你要碰撞的目标只有一个Tile宽。
假设没有斜坡和单方向的平台,这个算法是很直接的:
1、分解动作到x和y轴,移动一次。我通常采取y轴,但你可以在两个方向都移动非常大的一个跨度。
2、得到目标对象(前进面)边缘的坐标。比如,如果是向左移动,则是碰撞单位 的左边的x轴。如果是右,则是右边。向上则是上面。同理向下。
3、算出与那个Tile的边线相重叠-得到最小和最大的正交轴的直(比如我们向左走,那么正交轴就是y,然后玩家与32,33,34几个tile发生重叠,那么就可以算出贴图在这个方向的范围: y=32*TS, y=33*TS, y=34*TS, TS是tile的大小)
4.、在自己控制的角色未动前,算出移动方向上的静态和所有动态障碍物中间坐标值,何处离你最近,有多近。
5、玩家在那个方向上的可移动距离,将是最近障碍物离你的距离和你一开始想移动距离的最小值。
6、根据以上计算得出移动后游戏角色的新位置,更新坐标。
斜坡
斜坡计算(绿色箭头指的),通常是十分棘手的,因为它虽是障碍物,却又允许玩家部分移动进入它的Tile内。而同样的算法,X轴方向移动会引起Y轴变化。一种解决方案是,让Tile记录两次Y值(左右两边也就是Math.floor(y))。假设(0,0)为坐标左上角,那么角色左边的tile的 y为(0,3),站在上面的那个是(4,7),然后是(8,11),然后才是(12, 15)。接下来,是上面的那段Tiles的重复。再接下来是更陡的斜坡(0,7)和(8,15)。
下面的系统是允许任意斜坡的,由于感官上的原因,那两种斜坡是最常见的,总共是12个种类的tile(6个前面描述的和和他们的镜像)。
碰撞算法(横向运动):
* 在碰撞检测阶段(第四步),斜坡算法将只把玩家靠近高边缘(小y值)算作一次碰撞,那么玩家就不会看见角色有点在斜坡上的“抽风”(来回震颤)。
* 也许你禁止斜坡去阻止“半穿越”,很多类是Mega Man X一样的游戏都采用了这个限制。但如果你不想去限制,那么玩家从鞋面的低处爬坡的情况就会变的很复杂。一个方法是去预先处理,把这些麻烦的斜坡进行标注。然后在碰撞检测的阶段,如果玩家爬坡的时候在y轴上的最小值(底边)是大于斜坡的边缘高在y轴上的值(tile coord*tile size + floor y),就算作一次碰撞。
* 碰撞处理好后,玩家可以开始移动(第6步),但纵向位置需要调整。当上坡的时候,这相对比较容易:以玩家碰撞盒子的下边缘中点在坐标轴里面的值,计算它的高度:首先,找到处于哪快地面tile。如果是斜边,在当前的坐标系统中计算它的floor y。比如这样:t=(centX-tileX)/tileSize; floorY=t*leftFloorY+(1-t)*rightFloorY。移动玩家来找到最低的位置(假设玩家的固定在贴图的下边缘中间,那就是这点)。
* 下坡有点棘手。一种方法是计算玩家移动前地面上多少个 像素,然后移动后又有多少个(运用上面的公式),然后调整他的高度使这两个一样。这种方法对于上下坡都可以运用。另外的方法是引入重力,但十分麻烦。
* 非斜坡的障碍物tile,如果它和斜坡相邻,是不应该在碰撞检测中考虑进去的。如果玩家处于(0, *)位置,左上至右下方向出现斜坡,那就忽略他左边的tile。如果是(*,0)左下至右上方向斜坡,那就忽略右边的。如果玩家大于两个tile,那么要忽略更多的tile。这么做是为了防止玩家爬坡时候卡住(黄色区域)。
* 当向下的时候,不用考虑斜边tile的上边缘作为碰撞边界,相反,计算它在当前纵向的floor坐标(使用同样的算法)。
One-way platforms
此处对比超级马里奥,展示了马里奥下坠和站立在同一个单方向平台的情景。
所谓 One-way platforms(单方向平台)就是你可以站立,也可以跳跃穿过的那种平台。而另一种说法是,如果你站在上面了,就算是障碍物,否则将是可以被穿越的。这是理解这类平台的关键。算法也要做如下改变
* 在x方向上,永远没有障碍物
* 在y方向上,如果在玩家运动之前已经处于它的上方了,则是障碍物。(玩家的碰撞盒子的下底边至少是处在单向平台的上方的1像素)。在移动之前,你必须储存玩家位置的信息,才可以检查到这个状态。
有些人似乎认为,当玩家y轴的速度是正向的时候(向下)把单向平台算作障碍物是不错的想法,但这个想法是错误的。因为玩家有可能在跳跃的时候和平台重叠,但下落的时候脚步并没有接触平台。这那种情况下,玩家应该继续下落才对。
有些人则想要允许玩家从此类平台跳下,有几种相对比较简单的算法来解决。比如,在某一帧的时候禁止单向平台,然后保持玩家至少y方向速度为1(那么玩家在下一帧的碰撞检测中就不必考虑具体的游戏碰撞单元)。或者检查玩家是否正好在单向平台的上方,如果是的话,手动把玩家往下移动一个像素来达到穿越平台的效果。
Ladders
Ladders(楼梯)
游戏中的楼梯,看起来需要十分复杂的运算才能实现。但实际上,它不过是最简单的一条IF语句:当你在楼梯上面的时候,你可以忽略大部分的碰撞检测,用新的规则来定义就好了。大多数人的做法,是将楼梯定义为只有一个tile宽。进入楼梯的的两种方法:
* 让你的玩家碰撞盒子与楼梯重叠(比如LGame中,就直接算角色的getRectBox是否碰撞了Tile),可以是空中的也可以是地面上的,然后向上。
* 让你的玩家站在楼梯正上方,先用上述的单向平台方法处理,然后向下。
有时,这些方法会突然让玩家的x方向的值与楼梯tile对齐。如果从楼梯向下爬的话,移动y轴就可以让玩家实际上处于楼梯的内部了。而在此时,有些游戏会用不同的碰撞盒子来判断玩家时候处于楼梯上面。比如Mega Man,似乎就用了单tile来做这个事情。
离开楼梯的方法也有几种:
* 到达楼梯的顶部,这会产生一个把玩家向上推动几个像素的动画。
* 到达悬梯的底部,这会产生一个简单的下坠,尽管有些游戏会不让玩家离开楼梯。
* 向左或者向右。如果没有障碍物,则允许通过。
* 跳跃。有些人允许玩家在楼梯中做此类动作。
在楼梯上,玩家的行动也会改变。通常是只能上下移动,或者做些攻击之类。
Stairs
Stairs(阶梯)
阶梯是楼梯的变体,在Castlevania系列中十分著名。实施方法其实是很简单的,基本与楼梯相同,但有几处例外要提前指出:
* 玩家通常采取一个一个,或者半个半个tile移动
* 每次移动玩家在x和y方向移动相同的距离
* 初始的重叠检测会检查前面一个tile而不是当前的tile。
某些游戏也有象斜坡一样的阶梯。实际上,它们大多只是在视觉上做了些效果。
Moving Platforms
Moving platforms(可移动平台)
可移动的平台看起来也很棘手,但实际上却也是很简单的。不过与通常的平台不一样,它是运动的,不能用固定的tile来表示(事实上,多数时候可移动平台算作Sprite(精灵))。所以一般会用碰撞盒子(AABB)来表示。在碰撞监测中算作障碍物。
实施移动平台的方法有几种。一种算法是:
1、在屏幕中人和物体移动前,判断玩家是否处于移动平台上面。这个可以通过检查玩家的下底边中点是否正好1像素高于平台表面。如果是这样的,在平台上储存一个处理方法然后玩家现在的位置。
2、移动所有的移动平台,让它在玩家移动前发生。
3、对于每个站在平台上的角色,算出所在平台的平移量,然后把每个角色都移动相同的平移量。
Other Features
有些游戏有更复杂的特性,不如Sonic the hedgehog系列在这个方面十分著名,这些超过了作者的知识范围,所以不再深究(这种实现,可以使用LGame的Polygon建立形状,然后结合瓦片地图进行碰撞计算)。
方案3:像素位掩码计算
效果上类似于方案2,但在处理碰撞关系时却不是比较Tile与游戏角色的大小,而是直接使用一整张图片做背景,然后通过对背景中每个像素和游戏角色的图片像素进行碰撞检查,再得出移动关系。
这种看似更细致的处理方式,却也提高了游戏的复杂度,并且内存占用也会上升很多。同理,对地图的编辑处理也有很高的要求(美工的噩梦)。因为Tile不能用来创建视图了,也就导致了每层的单元,都要手工绘制得出(苦逼的美工~)。也因为这些问题,这个方法不是特别常用,但会比Tile产生更高质量的游戏结果。对于动态场景也是挺适用的,比如某些可破坏的场景(对美工有仇者,就可以天天让他们做这种地图玩~)。
例子: Worms, Talbot’s Odyssey
如何做?
其实,笔者在制作此类地图的原始想法,与第二种方案很类似:即把每个像素想象成一个Tile,然后实施一模一样的算法。不过,这种想法隐含着一个重大的差别,那就是斜坡的处理。因为,现在的斜坡是被周围的Tiles隐性定义的,前面的方案也就不再适合了。所以,我们也需要采用更复杂的算法(另外,楼梯处理也会相应的变得十分棘手)。
Slopes
Slopes(斜坡)
这种方案之所以难以实施,很大程度上是因为斜坡的存在。不幸的是,斜坡在很多游戏中是必须存在的场景。
下面粗略的描述了Talbot’s Odyssey对于斜坡的算法:
* 通过加速度和纵向速度计算每个轴方向的位移。
* 轴方向移动时优先适用位移较大的那个方向。
* 对于纵向,把碰撞单位(AABB)向上移动3个像素,这样达到爬坡的目的。
* 提前扫描坐标,通过检查所有实体存在的障碍物和位图本身来决定玩家在碰到障碍物之前能移动多少距离。然后把玩家移动到新位置。
* 如果是纵向移动,把玩家在斜坡能往上移动多少就移动多少,最多3个像素。
* 如果移动结束后,发现玩家与某个障碍物重叠。取消此次移动。
* 无论结果如何,在另外一个方向上继续以上述方法移动。
因为此种方案对于玩家正在下坡移动或者下坠根本没有区分(都是算像素),你就只需要建立一个系统去计算距离上次玩家在地面上已经过去多少帧了。这样,一但满足判定标准,我们就能根据判断结果,处理角色是否可以跳跃或者开始某种动画了。在Talbot中,通常是十帧一处理。
另一个窍门,是在碰撞发生之前就有效的计算出来可以移动的像素。不过有些因素会使计算比较复杂,比如单向通过的平台,向下滑的陡坡。总之,这个技术需要很多调优,并且相对第二种方案来说十分不稳定。所以除非你十分需要的(比如调戏美工妹妹-_-),一般不推荐使用。
方案四:向量地图
这种技术采用了向量数据(线或者多边形,比较典型的是使用SVG,可伸缩向量图形)来判断碰撞的边缘。虽然实施起来很难,但由于物理引擎的大行其道,这个方案还是很受欢迎的。这种方案即提供了bitmask方案的优点,又对内存计算消耗不大,还很便于图层编辑。
例子: Braid, Limbo
如何做?
一般有两种解决方案:
* 自己计算碰撞,类似于bitmask方案(方案3),使用多边形碰撞来计算反射或者滑动。
* 直接上游戏引擎……
基本上,大多数人都会选择第二种方法,这不仅仅 是方便简单的原因。更重要的是,你可以把心思和精力花在其他更重要的地方。但有一点需要注意:如果仅仅通过默认设置开发,那么你的游戏将和其它同款引擎开发出来的游戏极度近似,变得毫无特色可言。
Compound objects
Compound objects(复杂物体)
在此方案中,存在着非常独有的缺陷,它可能会突然无法判断玩家是否正在地面上(大多出于取整原因),或者撞到墙了,或者通过斜面向下滑动之类。用游戏引擎的话(Box2D之类),你还需要考虑摩擦力,移动的时候比较大,滑行的时候比较小之类。
而处理这些情况的比较通用方案,是把游戏角色划分成几个多边形。每个多边形代表不同的东西:比如,你会有主体躯干,两条细长的腿,一个方方的头部;甚至通过不同的多边形来组合之类。有时候,我们可以通过这些小小的修改,来避免角色被障碍物卡住。游戏角色的不同组成部分,它们大多也会具有不同的物理属性,或者碰撞反应。要获得更多信息,还可以使用传感器监听。通常会用它来判断条约前是否离地面太近,或者玩家是否在推墙(反复撞墙)。
全局考量
不论你在进行何种2D游戏开发,你都必须考虑到一些全局因素。
Acceleration(加速)
角色的加速度对于游戏玩家来说,将是有很大影响的部分。
加速,通常是指游戏角色速度的突然改变(上升),它大多包含实际的移动像素增加,以及短暂的加速动画两部分。当游戏角色速度很慢的时候,角色一般需要花很长的时间去达到最大速度,但这时的加速动画,则可能半途被停止下来(如果玩家松开按钮)。这样,又很可能会让玩家觉得角色很逊,很难控制。而当角色速度已经很快的时候,则很容易加速到最大速度,但是因为动画变更的关系,它又很可能猛然卡住一下。这样看起来,动画和加速的组合关系十分敏感,我们编码时也要十分小心。
即使一款游戏横向没有加速度,它至少会在跳跃的时候加入这类因素。不然,跳跃的弧线就变成三角形了。
如何做?
实现加速通常很简单,但要注意几个地方:
* 注意x方向上面的速度,如果控制按钮没有按下去的话,加速度就应该是0(具体设定的话,可以向左键一直按着会达到最大负速度,右边的话是最大正速度)。
* 注意y方向速度。在平地上是0,下落的时候达到最大。
* 对于每个具体方向,可以把当前速度加速后的最大速度,用每个方向的权重来平均下或者直接加入速度值中。
两种加速方法如下:
* 权重平均:加速度是一个介于0(无变化)和1(瞬时加速)的数字。用那个直去对目标速度和现在速度进行线性插值。把结果作为现在速度。
- vector2f curSpeed = a * targetSpeed + (1-a) * targetSpeed;
- if (fabs(curSpeed.x) < threshold) curSpeed.x = 0;
- if (fabs(curSpeed.y) < threshold) curSpeed.y = 0;
* 加速度:先判断在哪个方向上添加加速度(用sin函数),然后检查是不是加速过多。
- vector2f direction = vector2f(sign(targetSpeed.x - curSpeed.x),sign(targetSpeed.y - curSpeed.y));
- curSpeed += acceleration * direction;
- if (sign(targetSpeed.x - curSpeed.x) != direction.x)
- curSpeed.x = targetSpeed.x;
- if (sign(targetSpeed.y - curSpeed.y) != direction.y)
- curSpeed.y = targetSpeed.y;
在移动角色前把加速度整合到角色速度上面是很重要的,否则在角色画面显示上,你会发现总有一帧的落后。当碰到障碍物后,应把对应方向的加速度设定为0。
Jump control
在2D平面游戏中,跳跃只需要检测玩家是否处于地面就好了(或者在前几帧的时候,是否在地面上)。如果在的话,设定给角色一个初始的负y速度(或物理上说的“推动力”)然后再让重力做剩下的事情。
通常玩家有四种控制跳跃的方法:
* 推动力:在超级马里奥游戏中经常看见。角色在跳跃之前,蓄好动能。在有些游戏中,这是唯一可以影响跳跃弧线的方法,与现实生活一样。你其实不需要实现什么,除非你强行干预去停止它。
* 空中加速:在空中的时候保持对于横向的移动控制。虽然在现实生活中是不可能实现的,但在游戏中这个特性很受欢迎,这会让玩家更容易控制角色。几乎所有的平面游戏都会有这个特性,但波斯王子是个特列。通常空中的加速度减少的十分快,所以推动力十分重要,但有些游戏(Mega Man)会给力完全的空中控制。这个仅仅是在空中的时候稍微修改下加速度参数就好了。
* 上升控制:另一个物力上无法实现的行为,也是很受欢迎的。你按住跳跃按钮越久,你就可以跳得更高。这种特性,通过不断得补充推动力给角色(推动力的大小是递减的趋势)来达到,或者也可以通过克服重力影响也可以达到。不过这种补充是有时间限制的,不然你会看到你的角色“一飞冲天”(游戏王zexal主角台词)。
* 多重跳跃:一旦到了空中,有些游戏允许玩家再次跳跃,甚至会无限制的跳跃,直到玩家到达地面(两次跳跃一般是被大家统一采用的)。这种方法可以在游戏内部增加一个计数器来达到。这个计数器在每次跳跃时自动加1,然后到达地面后,自动变成0。只有在计数器没有超过限定值的时候,才可以继续跳跃。通常第二次的跳跃会比第一次的短很多。另外一个限制是,空中跳跃只有在角色完成了旋转跳跃且开始回落的时候才可以被触发。
Animations and leading
在许多游戏中,你的角色在实际执行你的命令前,都会先执行一段预先设定好的动画。如果在对操作有很高要求的游戏中,这可能会让你的玩家抓狂的。所以,此类游戏里千万不要这么做。但是,你仍然可以加入向导动画(在跳跃和跑动之前)。如果你在意游戏的响应速度,最好只是装饰下而已,然后立刻让角色对命令进行反应。
Smoother movement with pseudo-float coordinates(更加平滑的移动)
相对使用浮点计算而言,用整形来代表玩家位置是很明智的选择,因为这会让计算变得更快更稳定。但是,如果你把什么都整形了,你会发现移动十分卡(因为所有位置都取整了)。对于这种情形,有一些处理方案可供参考:
* 用浮点型数据来计算和储存位置信息,在计算和表现碰撞的时候转成整型,很快也很简单。但如果你从原点开始移动,会发现偏差会很大。不过,如果你的场景十分小,这点差异是不那么明显的。但是记住,如果真碰到了,你可以用双精度来进行计算修正。
* 用固定长度数字来计算位置,同样在碰撞的时候转成整型。虽然场景大小限制更大,但精度是全局统一的,在某些硬件上更快(比如手机上)。
* 把位置信息储存成整型,把小数点后面的数字转化成浮点型。当计算未知位置的时候,把位移按照浮点来算,然后加上小数点后面的数字,再后把整数部分加到未知整数上面,把小数部分加到未知的小数上面。在下一帧的时候,同样这么计算。这样做的好处是,你只有在移动的时候才会用浮点型,而其他地方都是整型,可以提高整体性能。