引言
本文出自“游戏古登堡计划”。
译者:powup33
校对:craft
来源:Creating Isometric Worlds: A Primer for Game Developers
说明
本教程中,我将为你大致介绍创造一个等轴视角世界所需的知识。你将了解何为等轴视角投影,并学会如何用二维数组来代表一个等轴视角关卡。我们将制定视图和逻辑之间的关系,这样我们就能操纵屏幕上的物体并处理瓷砖间的碰撞检测。本文也会涉及到深度排序和人物动画的问题。
为了加速你的游戏开发,你可以在 Envato 商店上找到大量的等轴视角游戏素材,并随即运用到你的游戏当中。
相关帖子
想要学到有关创建等轴视角世界更多的知识吗?来这里查看本教程的续篇,《创造等轴视角世界:给游戏开发者的入门介绍,续》(游戏古登堡译文版即将推出)吧,Juwal 的著作《Starling 游戏开发要点》也能给予你们许多提示。
1. 等轴视角世界
等轴视图(斜45度视图)是一种在 2D 游戏中制造 3D 效果的显示方法——有时我们也称它为伪 3D 或 2.5D。下列(截图像取至《暗黑破坏神2》和《帝国时代》的)可以解释我想说明的意思:
实现等轴视图的方式多种多样,但为了简化说明,本文将专注于基于瓷砖的方案,它同时也是最高效也最广泛使用的方式。我已在截图上覆盖了菱形格子来演示地形是如何被分割成瓷砖的。
2. 基于瓷砖的游戏
在基于瓷砖的方案中,所有的视觉元素都将被分解成名为瓷砖、具有标准尺寸的最小单位。这些瓷砖将根据预先定义的关卡数据(通常为二维数组)排列构成游戏世界。
相关帖子
作为例子我们先来考虑标准顶视图下的两块瓷砖,分别为草地瓷砖和墙壁瓷砖。如下图所示:
这些瓷砖尺寸相同,且皆为正方形,因此瓷砖的高与宽相等。
要想创造一个草地被墙壁环绕的关卡,它的二维数组会像这样:
[[1,1,1,1,1,1], [1,0,0,0,0,1], [1,0,0,0,0,1], [1,0,0,0,0,1], [1,0,0,0,0,1], [1,1,1,1,1,1]]
此处,0
代表草地瓷砖而 1
则代表墙壁瓷砖。按照这样的关卡数据排列的瓷砖将生成下面的关卡图像:
我们可以通过添加边角瓷砖,竖直和水平的墙壁瓷砖来增强其表现效果,如图所示,这样会用到五种不同的瓷砖:
[[3,1,1,1,1,4], [2,0,0,0,0,2], [2,0,0,0,0,2], [2,0,0,0,0,2], [2,0,0,0,0,2], [6,1,1,1,1,5]]
至此我希望你已经能清晰理解基于瓷砖的方案理念了。下面是一个直观的 2D 网格实现,代码可以这样来写:
for (i, loop through rows) for (j, loop through columns) x = j * tile width y = i * tile height tileType = levelData[i][j] placetile(tileType, x, y)
此处我们假设瓷砖的高宽等长(所有的瓷砖都是这样),瓷砖贴图也采用同样尺寸。本例中,瓷砖大小采用 50x50 px,因此关卡总大小为 300x300 px——即 6 行 6 列的 50x50 px 的瓷砖。
在一般的基于瓷砖方案中,我们会采用顶视图或侧视图;对于等轴视图我们则需要采用等轴视角投影。
3. 等轴视角投影
目前我能找到关于“等轴视角投影”最好的技术解释来自 Clint Bellanger 的文章,他是这么说的:
将我们的相机沿着轴线横向旋转45度,接着向下旋转30度。这将使得瓷砖以菱形呈现,且宽长为高度的两倍。这种风格因策略类游戏和动作系角色扮演类游戏而变得流行起来。如果我们在这个视图下观察正方体,我们将能看到它的三个面(顶面和两个侧面)。
尽管听上去有些复杂,但实际上实现这种视图的方法还是挺直截了当的。我们需要明白的是二维空间和等轴视角空间之间的关系——换言之,即关卡数据和视图间的关系以及从顶视角的“笛卡尔”坐标到等轴视角坐标的转换方法。(本教程不考虑基于六角瓷砖的技巧,这是另一种实现等轴视角世界的方式,相关内容可以查看 indienova 的这篇译文:六角网格的实现)
放置等轴视角瓷砖
让我试图简化以二维数组方式储存的关卡数据与等轴视图之间的关系——换句话说,如何将笛卡尔坐标转换成等轴视角坐标。
我们将尝试从我们的被墙壁环绕的草地关卡创建等轴视图:
[[1,1,1,1,1,1], [1,0,0,0,0,1], [1,0,0,0,0,1], [1,0,0,0,0,1], [1,0,0,0,0,1], [1,1,1,1,1,1]]
在这个场景中,我们可以通过检测指定坐标对应的数组元素是否为零来判断瓷砖是否是草地,即是否属于可行走范围。上述关卡的2D视图实现方式非常直观,通过两个循环的迭代将正方形瓷砖放置在高宽间隔固定的网格上即可。
for (i, loop through rows) for (j, loop through columns) x = j * tile width y = i * tile height tileType = levelData[i][j] placetile(tileType, x, y)
对等轴视图来说,代码基本相同,但 placeTile()
函数会有所不同。
对于等轴视图我们将需要在循环中计算相对应的等轴视角坐标。
具体实现的公式如下,isoX
和 isoY
表示在等轴视图下的坐标,而 cartX
和 cartY
则表示笛卡尔坐标:
//Cartesian to isometric: isoX = cartX - cartY; isoY = (cartX + cartY) / 2;
//Isometric to Cartesian: cartX = (2 * isoY + isoX) / 2; cartY = (2 * isoY - isoX) / 2;
这些函数展示了如何在不同坐标系统间相互转换:
function isoTo2D(pt:Point):Point{ var tempPt:Point = new Point(0, 0); tempPt.x = (2 * pt.y + pt.x) / 2; tempPt.y = (2 * pt.y - pt.x) / 2; return(tempPt); }
function twoDToIso(pt:Point):Point{ var tempPt:Point = new Point(0,0); tempPt.x = pt.x - pt.y; tempPt.y = (pt.x + pt.y) / 2; return(tempPt); }
循环伪代码如是:
for(i, loop through rows) for(j, loop through columns) x = j * tile width y = i * tile height tileType = levelData[i][j] placetile(tileType, twoDToIso(new Point(x, y)))
作为展示,让我们来看看一个典型的 2D 坐标是如何被转换成等轴视角坐标的:
2D point = [100, 100]; // twoDToIso(2D point) will be calculated as below isoX = 100 - 100; // = 0 isoY = (100 + 100) / 2; // = 100 Iso point == [0, 100];
类似,[0,0]
会被转换成 [0,0]
,而 [10,5]
则会被转换成 [5,7.5]
。
上述方法使我们能把 2D 关卡数据和等轴视角坐标直接关联起来。我们可以利用这个函数从关卡数据中找到瓷砖坐标:
function getTileCoordinates(pt:Point, tileHeight:Number):Point{ var tempPt:Point = new Point(0, 0); tempPt.x = Math.floor(pt.x / tileHeight); tempPt.y = Math.floor(pt.y / tileHeight); return(tempPt); }
(这里我们假设瓷砖的高宽相等,多数情况下确实如此)
因此,当我们希望从屏幕(等轴视角)坐标找到瓷砖坐标时,我们可以调用:
getTileCoordinates(isoTo2D(screen point), tile height);
这里,所谓的屏幕坐标既可以是鼠标点击位置,也可能是拾取位置。
小贴士:另一种放置的方法是使用 Z 字形模型,但那又是截然不同的另一套方案了。
在等轴视角坐标中移动
移动的实现相当简单:只需要在笛卡尔坐标中操纵游戏世界的数据,接着调用上述的函数来更新屏幕即可。举例来说,如果想让人物沿着正 y 方向向前移动的话,你只需正确地增加它的 y 属性然后将它的位置转换成等轴视角坐标就可以了:
y = y + speed; placetile(twoDToIso(new Point(x, y)))
深度排序
为了渲染出等轴视角世界,我们在正常的放置之上还要处理好深度排序。这能保证距离玩家更近的物体是放置在离玩家更远的物体之上。
最简单的深度排序方法是直接利用笛卡尔y坐标值,就如这条小提示中所提到的:越是靠近屏幕顶部的物体应当越早地被画出来。只要我们没有任何大于单个瓷片空间的图形,那么这种方法还是可行的。
在等轴视角世界中最有效的深度排序是将所有的瓷砖分解到一个标准的单个瓷砖维度,同时不允许出现比此更大的图片。举个例子,以下是一个不能放入到标准瓷片大小的瓷片——看清楚我们是如何将其分解成每个都能融入瓷砖尺寸的碎片的:
4. 制作美术素材
等轴视角的美术素材可以使用像素画,但这并非必要元素。这篇 RhysD 的指南将详尽地为你讲解有关等轴视角像素画你所需要知道的近乎全部知识。维基百科也能查到一些相关的理论。
制作等轴视角美术素材的基本原则是:
- 先从一张严格对齐像素的空等轴视角网格开始;
- 尝试将图形分解成单个的等轴视角瓷砖贴图;
- 尝试确认此瓷砖是否能被人物占据。单个瓷砖内如果既包括可行走区域,又包括不可行走区域,实现会变得相当复杂;
- 多数瓷砖应做到在某个方向或者多个方向实现无缝连接;
- 影子的实现会比较棘手,除非我们采用分层结构:我们可以先将影子画在底层,然后将主角(或树木和其他物体)画在顶层。如果你不想使用分层的方案,请确保影子落在前方而非落在站在树后的主角身上;
- 如需使用比标准等轴视角瓷砖尺寸更大的瓷片图像,请尝试标准尺寸整倍大小的素材。这种情况下,更适合采用分层结构,允许我们就将图像按其高度进行分层。举例来说,一棵树能被划分成三部分:树根、树干和树叶。这样我们就能更简单地进行深度排序了,因为它们所在的层数和它们的高度是相对应的。
比单个瓷砖大小要大的等轴视角瓷砖会在深度排序中制造麻烦。下面列出的参考文章会谈及相关问题:
相关帖子
5. 等轴视角人物
在等轴视图中实现人物并非听上去那样困难。人物美术需要根据某些标准制作。首先我们需要固定我们游戏所允许的移动方向的数量——通常游戏会使用四方向或八方向移动设计。
在顶视图中,我们可以创造一组人物动画,每组动画面朝某个方向,接着我们通过旋转来获取其他方向的动画。我们需要为每个可移动方向重新渲染动画——因此使用八方向移动设定时我们将需要为每个动作创造八组动画。为了便于理解,我们通常将方向以逆时针的顺序表达:北、西北、西、西南、南、东南、东和东北。
放置人物与放置瓷砖的方式相同。通过在笛卡尔坐标中计算运动结果并将其转换到等轴视角坐标来完成人物移动。这里姑且假设我们通过键盘来控制人物。
我们将根据按下的方向键来设置两个变量 dX
和 dY
。变量默认值为 0
,并会如下表所示那样进行更新(UDLR分别对应上下左右键。数值为1代表这个按键被按下,而数值0则表示这个键没有被按下):
Key Pos U D R L dX dY ================ 0 0 0 0 0 0 1 0 0 0 0 1 0 1 0 0 0 -1 0 0 1 0 1 0 0 0 0 1 -1 0 1 0 1 0 1 1 1 0 0 1 -1 1 0 1 1 0 1 -1 0 1 0 1 -1 -1
现在我们可以使用 dX
和 dY
的数值来更新笛卡尔坐标了:
newX = currentX + (dX * speed); newY = currentY + (dY * speed);
因此 dX
和 dY
表示的是根据按下的方向键人物 x
和 y
位置所进行的变更。
正如我们之前讨论的一样,我们可以轻松地算出新的等轴视角坐标:
Iso = twoDToIso(new Point(newX, newY))
一旦我们有了新的等轴视角位置,我们需要将人物被移动到那个位置。根据 dX
和 dY
的数值,我们可以决定人物所面对的方向并使用相对应的人物动画。
碰撞探测
碰撞探测将通过检查位于新位置的瓷砖是否为可行走的。因此在我们找到新位置时,我们不会马上将人物移动到那里,而是先检查哪种瓷砖占据那个地方。
tile coordinate = getTileCoordinates(isoTo2D(iso point), tile height); if (isWalkable(tile coordinate)) { moveCharacter(); } else { //do nothing; }
在函数 isWalkable()
中,我们检查在那个位置的关卡数据数组的数值是否为可行走的。我们必须更新人物面对的方向——即使它没有移动,比如在碰到不可行走瓷砖时。
加上人物的深度排序
想象在等轴视角的世界里存在着一名角色和一可树木的瓷砖。
为了正确地理解深度排序,我们必须先知道每当人物 x
和 y
坐标小于树木时,树会遮挡住角色。而每当角色的 x
和 y
坐标大于树木时,角色会遮挡住树木。
当它们拥有相同的 x
坐标时,我们将仅根据 y
坐标做决定:y
坐标更大的一方将覆盖另一方。同理,当它们的 x
坐标相同时,我们仅根据 x
坐标做决定:x
坐标高的覆盖低的。
这种做法的更简化版本只需从最远的瓷砖——即 tile[0][0]
——开始渲染这个世界,然后一行一行地渲染出所有的瓷砖。如果人物占据一个瓷砖,我们先渲染地面瓷砖然后再渲染人物瓷砖。此方法是可行的,因为人物不能占据墙壁瓷砖。
深度排序必须在任何瓷砖改变位置时执行。比如说在每次人物移动的时候。在每次执行完深度排序后我们将更新显示屏来显示深度的变化。
6. 来试试看吧
那么现在我们利用我们新学到的知识来创造一个可用的原型吧,包括键盘控制、适当的深度排序和碰撞探测。
你也许会认为这个实用工具类有用(我使用 AS3 来编写,但掌握了其他任何编程语言也应该可以理解它):
package com.csharks.juwalbose { import flash.display.Sprite; import flash.geom.Point; public class IsoHelper { /** * convert an isometric point to 2D * */ public static function isoTo2D(pt:Point):Point{ //gx=(2*isoy+isox)/2; //gy=(2*isoy-isox)/2 var tempPt:Point=new Point(0,0); tempPt.x=(2*pt.y+pt.x)/2; tempPt.y=(2*pt.y-pt.x)/2; return(tempPt); } /** * convert a 2d point to isometric * */ public static function twoDToIso(pt:Point):Point{ //gx=(isox-isoxy; //gy=(isoy+isox)/2 var tempPt:Point=new Point(0,0); tempPt.x=pt.x-pt.y; tempPt.y=(pt.x+pt.y)/2; return(tempPt); } /** * convert a 2d point to specific tile row/column * */ public static function getTileCoordinates(pt:Point, tileHeight:Number):Point{ var tempPt:Point=new Point(0,0); tempPt.x=Math.floor(pt.x/tileHeight); tempPt.y=Math.floor(pt.y/tileHeight); return(tempPt); } /** * convert specific tile row/column to 2d point * */ public static function get2dFromTileCoordinates(pt:Point, tileHeight:Number):Point{ var tempPt:Point=new Point(0,0); tempPt.x=pt.x*tileHeight; tempPt.y=pt.y*tileHeight; return(tempPt); } } }
如果还有困难,还可以参见我 demo 的完整源码(以Flash和AS3时间线代码的样式):
// Uses senocular's KeyObject class // http://www.senocular.com/flash/actionscript/?file=ActionScript_3.0/com/senocular/utils/KeyObject.as import flash.display.Sprite; import com.csharks.juwalbose.IsoHelper; import flash.display.MovieClip; import flash.geom.Point; import flash.filters.GlowFilter; import flash.events.Event; import com.senocular.utils.KeyObject; import flash.ui.Keyboard; import flash.display.Bitmap; import flash.display.BitmapData; import flash.geom.Matrix; import flash.geom.Rectangle; var levelData=[[1,1,1,1,1,1], [1,0,0,2,0,1], [1,0,1,0,0,1], [1,0,0,0,0,1], [1,0,0,0,0,1], [1,1,1,1,1,1]]; var tileWidth:uint = 50; var borderOffsetY:uint = 70; var borderOffsetX:uint = 275; var facing:String = "south"; var currentFacing:String = "south"; var hero:MovieClip=new herotile(); hero.clip.gotoAndStop(facing); var heroPointer:Sprite; var key:KeyObject = new KeyObject(stage);//Senocular KeyObject Class var heroHalfSize:uint=20; //the tiles var grassTile:MovieClip=new TileMc(); grassTile.gotoAndStop(1); var wallTile:MovieClip=new TileMc(); wallTile.gotoAndStop(2); //the canvas var bg:Bitmap = new Bitmap(new BitmapData(650,450)); addChild(bg); var rect:Rectangle=bg.bitmapData.rect; //to handle depth var overlayContainer:Sprite=new Sprite(); addChild(overlayContainer); //to handle direction movement var dX:Number = 0; var dY:Number = 0; var idle:Boolean = true; var speed:uint = 5; var heroCartPos:Point=new Point(); var heroTile:Point=new Point(); //add items to start level, add game loop function createLevel() { var tileType:uint; for (var i:uint=0; i<levelData.length; i++) { for (var j:uint=0; j<levelData[0].length; j++) { tileType = levelData[i][j]; placeTile(tileType,i,j); if (tileType == 2) { levelData[i][j] = 0; } } } overlayContainer.addChild(heroPointer); overlayContainer.alpha=0.5; overlayContainer.scaleX=overlayContainer.scaleY=0.5; overlayContainer.y=290; overlayContainer.x=10; depthSort(); addEventListener(Event.ENTER_FRAME,loop); } //place the tile based on coordinates function placeTile(id:uint,i:uint,j:uint) { var pos:Point=new Point(); if (id == 2) { id = 0; pos.x = j * tileWidth; pos.y = i * tileWidth; pos = IsoHelper.twoDToIso(pos); hero.x = borderOffsetX + pos.x; hero.y = borderOffsetY + pos.y; //overlayContainer.addChild(hero); heroCartPos.x = j * tileWidth; heroCartPos.y = i * tileWidth; heroTile.x=j; heroTile.y=i; heroPointer=new herodot(); heroPointer.x=heroCartPos.x; heroPointer.y=heroCartPos.y; } var tile:MovieClip=new cartTile(); tile.gotoAndStop(id+1); tile.x = j * tileWidth; tile.y = i * tileWidth; overlayContainer.addChild(tile); } //the game loop function loop(e:Event) { if (key.isDown(Keyboard.UP)) { dY = -1; } else if (key.isDown(Keyboard.DOWN)) { dY = 1; } else { dY = 0; } if (key.isDown(Keyboard.RIGHT)) { dX = 1; if (dY == 0) { facing = "east"; } else if (dY==1) { facing = "southeast"; dX = dY=0.5; } else { facing = "northeast"; dX=0.5; dY=-0.5; } } else if (key.isDown(Keyboard.LEFT)) { dX = -1; if (dY == 0) { facing = "west"; } else if (dY==1) { facing = "southwest"; dY=0.5; dX=-0.5; } else { facing = "northwest"; dX = dY=-0.5; } } else { dX = 0; if (dY == 0) { //facing="west"; } else if (dY==1) { facing = "south"; } else { facing = "north"; } } if (dY == 0 && dX == 0) { hero.clip.gotoAndStop(facing); idle = true; } else if (idle||currentFacing!=facing) { idle = false; currentFacing = facing; hero.clip.gotoAndPlay(facing); } if (! idle && isWalkable()) { heroCartPos.x += speed * dX; heroCartPos.y += speed * dY; heroPointer.x=heroCartPos.x; heroPointer.y=heroCartPos.y; var newPos:Point = IsoHelper.twoDToIso(heroCartPos); //collision check hero.x = borderOffsetX + newPos.x; hero.y = borderOffsetY + newPos.y; heroTile=IsoHelper.getTileCoordinates(heroCartPos,tileWidth); depthSort(); //trace(heroTile); } tileTxt.text="Hero is on x: "+heroTile.x +" & y: "+heroTile.y; } //check for collision tile function isWalkable():Boolean{ var able:Boolean=true; var newPos:Point =new Point(); newPos.x=heroCartPos.x + (speed * dX); newPos.y=heroCartPos.y + (speed * dY); switch (facing){ case "north": newPos.y-=heroHalfSize; break; case "south": newPos.y+=heroHalfSize; break; case "east": newPos.x+=heroHalfSize; break; case "west": newPos.x-=heroHalfSize; break; case "northeast": newPos.y-=heroHalfSize; newPos.x+=heroHalfSize; break; case "southeast": newPos.y+=heroHalfSize; newPos.x+=heroHalfSize; break; case "northwest": newPos.y-=heroHalfSize; newPos.x-=heroHalfSize; break; case "southwest": newPos.y+=heroHalfSize; newPos.x-=heroHalfSize; break; } newPos=IsoHelper.getTileCoordinates(newPos,tileWidth); if(levelData[newPos.y][newPos.x]==1){ able=false; }else{ //trace("new",newPos); } return able; } //sort depth & draw to canvas function depthSort() { bg.bitmapData.lock(); bg.bitmapData.fillRect(rect,0xffffff); var tileType:uint; var mat:Matrix=new Matrix(); var pos:Point=new Point(); for (var i:uint=0; i<levelData.length; i++) { for (var j:uint=0; j<levelData[0].length; j++) { tileType = levelData[i][j]; //placeTile(tileType,i,j); pos.x = j * tileWidth; pos.y = i * tileWidth; pos = IsoHelper.twoDToIso(pos); mat.tx = borderOffsetX + pos.x; mat.ty = borderOffsetY + pos.y; if(tileType==0){ bg.bitmapData.draw(grassTile,mat); }else{ bg.bitmapData.draw(wallTile,mat); } if(heroTile.x==j&&heroTile.y==i){ mat.tx=hero.x; mat.ty=hero.y; bg.bitmapData.draw(hero,mat); } } } bg.bitmapData.unlock(); //add character rectangle } createLevel();
注册点
这里额外注意一下瓷砖和主角的注册点。(术语注册点表示每个精灵图的原点位置)注册点一般不会位于图形内部,但一般位于图形碰撞盒的左上角。
我们将需要修改我们的渲染代码来正确地修复注册点,这么做主要是为了主角。
碰撞探测
另一个值得注意的地方是我们的碰撞探测计算是根据主角的位置算的。
但是主角是有体积的,并不能准确地用一个点来代表,因此我们需要用一个长方形来代表主角然后给长方形的每个角都检测碰撞,这样就不会与其他瓷砖重叠也就不会造成深度遗留问题了。
捷径
在这个演示中,我直接根据主角的新位置重新渲染了场景。我们找到主角占据的瓷砖然后当渲染循环碰到那些瓷砖时将主角画在地面瓷砖上。
但如果我们仔细想想,我们会得出在此情景我们并不需要循环到所有的瓷砖。草地瓷砖、左边和顶部的墙总是会在主角之前渲染,因此我们完全不需要重新渲染它们。同时,底部和右边的墙总是会在主角前面并在主角之后渲染。
总的来说,我们只需要在可活动区域内的墙和主角之间,即两个瓷砖,进行深度排序。注意到这些捷径将帮助你省下大量的处理时间,它可能会成为左右性能的关键。
总结
到目前为止,你应该奠定了创建等轴视角世界的良好基础:你可以渲染世界与其中的物品,并用简单的二维数组来表示关卡数据,在笛卡尔坐标与等轴视角坐标间相互转换和处理像深度排序和任务动画之类的问题。好好享受创建等轴视角世界的乐趣吧!
作为一个土木狗,我想说一句,这种视角叫“正等测投影”。
最近由 BrotherShort 修改于:2016-09-03 18:11:12工业制图中的投影有正等测、正二测、正面斜等测、正面斜二测、水平斜二测等等……
正等测指的是投影平面与投影光线垂直且坐标轴投影比例相等的投影。
文中所说:“将我们的相机沿着轴线横向旋转45度,接着向下旋转30度。这将使得瓷砖以菱形呈现,且宽长为高度的两倍。 ”严格来说是中心投影,而正等测是平行投影。
@BrotherShort:http://www.ituring.com.cn/article/788 参考这篇。游戏界确实在用一个错误的术语,不过好像已经约定俗成了。
@BrotherShort:欢迎土木专家!