游戏邦在:
杂志专栏:
gamerboom.com订阅到鲜果订阅到抓虾google reader订阅到有道订阅到QQ邮箱订阅到帮看

开发者初级教程:制作等距游戏世界

发布时间:2013-06-10 08:42:23 Tags:,,,

作者:Juwal Bose

在本教程中,我将综述制作等距游戏世界的知识,包括什么是等距投影、如何将等距平面表现为2D数组。我们将理清视角和逻辑的关系,这样我们就能轻易地处理屏幕上的对象和贴图碰撞检测了。我们还将了解深度排序和角色动画。

1、等距的游戏世界

等距视图是一种把2D游戏伪装为3D游戏的显示方法。使用这种方法的游戏有时候会被称作伪3D或2.5D。例子如下面两张截图所示:

暗黑破坏神(from gamedev)

暗黑破坏神(from gamedev)

帝国时代2(from gamedev)

帝国时代2(from gamedev)

执行等距视图的方法有很多,但为了简化,我只重点介绍最有效最常用的一种——贴图法。从上面两张图可以看出,其上覆盖的菱形网格把地形划分贴图。

2、贴图游戏

在贴图法中,各个视觉元素都被切分为更小的部件,称为“贴图”,都是标准尺寸的。根据预先确定的平面数据——通常是2D数组,这些贴图被组织成游戏世界。

例如,标准的由上往下的两种2D视角贴图——草地贴图和墙体贴图,如下图所示:

base-2d-tiles(from gamedev)

base-2d-tiles(from gamedev)

这些贴图的尺寸是一样的,且都是正方形,所以贴图高度和宽度也是一样的。

在一个墙体包围着草地的平面中,平面数据的2D数组将如下所示:

1[[1,1,1,1,1,1],
2 [1,0,0,0,0,1],
3 [1,0,0,0,0,1],
4 [1,0,0,0,0,1],
5 [1,0,0,0,0,1],
6 [1,1,1,1,1,1]]

在这里,0表示草地贴图,1表示墙体贴图。根据平面数据排列贴图产生的图像如下:

2d-level-simple(from gamedev)

2d-level-simple(from gamedev)

我们可以通过添加拐角贴图、垂直墙体贴图和水平墙体贴图来丰富这张图像,数组如下:

1[[3,1,1,1,1,4],
2 [2,0,0,0,0,2],
3 [2,0,0,0,0,2],
4 [2,0,0,0,0,2],
5 [2,0,0,0,0,2],
6 [6,1,1,1,1,5]]

2d-level-complex(from gamedev)

2d-level-complex(from gamedev)

我想读者们现在应该清楚贴图的概念了吧。执行这种简单的2D网格的代码如下:

1for (i, loop through rows)
2 for (j, loop through columns)
3  x = j * tile width
4  y = i * tile height
5  tileType = levelData[i][j]
6  placetile(tileType, x, y)

在此,我们假设贴图宽度和高度是相等的(所有贴图都是),然后匹配贴图图像的尺寸。这样,例子中的贴图宽度和高度都是50px,整个水平面的尺寸就是300x300px——也就是,6列和6行50x50px大小的贴图。

在常规贴图法中,我们也使用自上而下视图或侧视图;至于等距视图,我们需要执行等距投影。

3、等距投影

我认为“等距投影”的最佳技术解释是出自Clint Bellanger写的文章中的一段话:

“我们沿着两个轴取摄像机角度(游戏邦注:将摄像机调成侧边45度后再下向30度)。这就产生了各个网格宽度为高度2倍的菱形(斜方形)网格。这种方法广泛运用于策略游戏和动作RPG。如果我们以这种角度看方块,就能看到方块的三个面(上面和两个侧面)。”

虽然看起来有点复杂,事实上执行这种视图是很简单的。我们需要理解的是,2D空间和等距空间之间的关系——也就是,平面数据和视图的关系;从自上而下的“笛卡尔”座标转换成等距座标。

the_isometric_grid(from gamedev)

笛卡尔座标vs等距座标(from gamedev)

(我们不考虑六边形贴图技术,那是另一种制作等距游戏世界的方法。)

放置等距贴图

我们简化一下储存为2D数组的平面数据和等距视图之间的关系吧。也就是,我们如何把笛卡尔座标转换成等距座标。

我们以上面提到的墙体包围草地的平面数据为例,制作一个等距视图:

1[[1,1,1,1,1,1],
2 [1,0,0,0,0,1],
3 [1,0,0,0,0,1],
4 [1,0,0,0,0,1],
5 [1,0,0,0,0,1],
6 [1,1,1,1,1,1]]

在这个场景中,我们可以通过检查座标上的数组元素是否为0(草地)来确定可通行的区域。这个2D视图执行法是简单的二次循环,即用固定的贴图高度值和宽度值弥补正方形贴图。

1for (i, loop through rows)
2 for (j, loop through columns)
3  x = j * tile width
4  y = i * tile height
5  tileType = levelData[i][j]
6  placetile(tileType, x, y)

至于等距视角,代码还是一样的,但placeTile()函数变了。

我们必须计算循环内的对应的等距座标。等式如下,其中isoX和isoY分别表示等距X和Y座标,cartX和cartY分别表示笛卡尔X和Y座标:

1 //Cartesian to isometric:
2
3 isoX = cartX – cartY;
4 isoY = (cartX + cartY) / 2;

1 //Isometric to Cartesian:
2
3 cartX = (2 * isoY + isoX) / 2;
4 cartY = (2 * isoY – isoX) / 2;

这些函数显示了如何从一个系统转换成另一个:

1 function isoTo2D(pt:Point):Point{
2   var tempPt:Point = new Point(0, 0);
3   tempPt.x = (2 * pt.y + pt.x) / 2;
4   tempPt.y = (2 * pt.y – pt.x) / 2;
5   return(tempPt);
6 }

1 function twoDToIso(pt:Point):Point{
2   var tempPt:Point = new Point(0,0);
3   tempPt.x = pt.x – pt.y;
4   tempPt.y = (pt.x + pt.y) / 2;
5   return(tempPt);
6 }

这个循环的伪代码如下:

1 for(i, loop through rows)
2   for(j, loop through columns)
3     x = j * tile width
4     y = i * tile height
5     tileType = levelData[i][j]
6     placetile(tileType, twoDToIso(new Point(x, y)))

isolevel(from gamedev)

等距视图下的围墙草地(from gamedev)

作为例子,我们看看典型的2D位置如何转换成等距位置:

1 2D point = [100, 100];
2 // twoDToIso(2D point) will be calculated as below
3 isoX = 100 – 100; // = 0
4 isoY = (100 + 100) / 2;  // = 100
5 Iso point == [0, 100];

类似地,输入[0, 0]将产生[0, 0],而[10, 5]则产生[5, 7.5]。

通过以上方法,可以产生2D平面数据和等距座标之间的正相关。我们可以发现来自其笛卡尔座标中的平面数据的贴图座标使用了以下函数:

1function getTileCoordinates(pt:Point, tileHeight:Number):Point{
2  var tempPt:Point = new Point(0, 0);
3  tempPt.x = Math.floor(pt.x / tileHeight);
4  tempPt.y = Math.floor(pt.y / tileHeight);
5  return(tempPt);
6}

(这里,我们假设贴图高度和宽度是相等的)

因此,从一对屏幕(等距)座标上看,我们可以通过调用以下代码来找到贴图座标:

1 getTileCoordinates(isoTo2D(screen point), tile height);

这个屏幕点可以是鼠标点击位置或选择位置。

注:另一个放置方法是完全不同的锯齿形模型。

移入等距座标

移动是很容易的:你在笛尔卡座标中操作你的游戏世界数据,只要使用上述函数更新它在屏幕上的显示。例如,如果你想让角色在Y轴正方向上移动,你只要增加它的Y轴数,然后将它的位置转换成等距座标:

1 y = y + speed;
2 placetile(twoDToIso(new Point(x, y)))

深度排序

除了一般的放置法,我们还需要注意绘制等距游戏世界时的深度排序。也就是,确保更靠近玩家的物品要在更远离的物品之后绘制出来。

最简单的深度排序法就是使用笛卡尔Y座标值,也就是,对象在屏幕上的位置越远,就应该越早绘制。只要我们没有任何子画面占据空间超过一个贴图,这个办法就管用。

对于等距游戏世界,最有效的深度排序法是将所有贴图切分成标准大小,且不允许更大的图像。例如,下图有一个贴图虽然不是标准贴图大小,但可以把它分成符合贴图尺寸的多个贴图:

split-big-tile(from gamedev)

大图像被分成多个标准等距尺寸的贴图(from gamedev)

4、制作美术

等距美术可以是象素美术,但不一定是。当处理等距象素美术时,你可以在Wikipedia中找到相关理论。

当制作等距美术时,一般原则是:

1)从空白的等距网格开始,保证象素精确度。

2)将画面分成单个等距贴图图像。

3)确保各个贴图可通行或不可通行。如果一个贴图中既包含可通行区域又包含不可通行区域,那么就比较复杂了。

4)大部分贴图都必须在一个方向或多个方向上无缝平铺。

5)阴影可能比较难实现,除非使用图层法,也就是在底层上绘制阴影,然后在顶层上绘制角色(或树木等其他对象)。如果你使用的不是多图层法,那么就要保证阴影落在前面,这样当角色站在树后面时才不会产生错乱。

6)如果你不得不使用比标准等距贴图大的图像,那么就要保证图像的大小为等距贴图的倍数。在这种情况下,最好使用图层法,这样就可以根据高度把图像分成不同的小块。这使得深度排序更容易,因为我们可以在符合其高度的相应图层上绘制小块。

比单个贴图尺寸大的等距贴图会在深度排序上产生问题。

5、等距角色

在等距视角上执行角色听起来复杂,但做起来并不难。角色美术必须根据某个标准来制作。首先,我们必须确定我们的游戏允许角色在什么方向上运动——通常是四方向或八方向。

iso-directions(from gamedev)

自上而下视角和等距视角下的八方向导航(from gamedev)

对于自上而下视角,我们可以制作一套面向一个方向的角色动画,然后简单地在各个方向上旋转。对于等距角色美术,我们必须重新制作各个方向上的动画——所以对于八方同运动,我们必须为每个动作制作8种动画。为了便于理解,我们通常按逆时针把方向称为北、西北、西、西南、南、东南、东和东北。

sprite Sheet(from gamedev)

面向不同方向的等距角色(from gamedev)

我们按放置贴图的方法来放置角色。通过计算角色在笛卡尔座标中的移动,然后转换到等距座标中,是很复杂的。假设我们使用键盘控制这个角色。

根据方向键,我们要设置两个变量,即dX和dY。默认这些变量为0,然后按下表更新,其中,U、D、R和L分别表示上、下、右和左方向键。值为1的方向键表示该键被按下;值为0表示该键没有被按下。

01   Key       Pos
02 U D R L    dX dY
03 ================
04 0 0 0 0     0  0
05 1 0 0 0     0  1
06 0 1 0 0     0 -1
07 0 0 1 0     1  0
08 0 0 0 1    -1  0
09 1 0 1 0     1  1
10 1 0 0 1    -1  1
11 0 1 1 0     1 -1
12 0 1 0 1    -1 -1

现在,使用dX和dY的值,我们可以更新笛卡尔座标如下:

1 newX = currentX + (dX * speed);
2 newY = currentY + (dY * speed);

根据按下的键,dX和dY分别表示角色的X和Y轴位置。

我们可以轻松地计算出新的等距座标,正如我们已经讨论过的:

1 Iso = twoDToIso(new Point(newX, newY))

有了新等距位置以后,角色就必须移动到这个位置。根据赋给dX和dY的值,我们就可以确定角色面向的方向,并使用相应的角色图像。

碰撞检测

通过查看在计算后的新位置上的贴图是否可通行,可以完成碰撞检测。所以,我们找到新位置后,并不立即把角色移动到那里,而是先检查占据那个空间的贴图是什么。

1 tile coordinate = getTileCoordinates(isoTo2D(iso point), tile height);
2 if (isWalkable(tile coordinate)) {
3   moveCharacter();
4 } else {
5   //do nothing;
6 }

在函数isWalkable()中,我们检查座标的平面数据数组的值,看这个区域是否可通行。我们必须注意更新角色面向的方向——即使角色没有移动,因为撞上不可通行的贴图。

角色的深度排序

考虑一下等距游戏世界中的角色和树木。

为了正确理解深度排序,我们必须先理解,当角色的X和Y座标比那些树的小的时候,树就覆盖角色。当角色的X和Y座标比树的大时,角色就覆盖树。

当二者的X座标相同时,Y座标较大的就覆盖Y座标较小的;当二者的Y座标相同时,就看X座标。

简化做法是,从最远的贴图——贴图[0][0]开始连续绘制平面,再逐个绘制各行中的所有贴图。如果角色占据贴图,那么就先绘制底层贴图,然后再绘制角色贴图。这个办法很管用,因为角色不能占据墙体贴图。

贴图每改变一次位置,就要完成一次深度排序。例如,当角色移动时,我们就要更新显示的场景,以反映深度变化。

6、实验

现在,利用你的新知识制作一个原型吧。以下是我的样本:

demo(from gamedev)

demo(from gamedev)

你可能觉得实用类很有用(我已经在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);
}

}
}

如果你遇到困难,请参考我的样本的完整代码(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();

记录点

要特别留意一下贴图和角色的记录点(游戏邦注:记录点可以理解为各个子画面的源点)。这些通常不会落入图像中,而是在子画面的边界框的左上角。

我们得改变我们的绘制代码来修正记录点,主要是角色的记录点。

碰撞检测

另一点要注意的是,我们根据角色所在的点来执行碰撞检测。

但角色有体积,不能用单个点来准确地表示,所以我们需要将角色表示为矩形,并查看这个矩形与各个拐角的碰撞情况,这样就没有其他贴图与它重叠,也就没有深度问题了。

捷径

在样本中,我根据角色的新位置简单地重绘了各帧场景。我们找到角色占据的贴图,当渲染循环达到这些贴图时再绘制底层贴图上的角色。

但如果我们再仔细考虑一下,会发现没有必要循环通过所有贴图。草地贴图和上方及左边的墙体贴图总是在角色绘制以前就绘制了,所以我们完全没必要重绘它们。另外,下方和左边的墙体贴图总是在角色的前方,因此要在角色绘制完以后绘制。

基本上,我们只需要执行有效面积内的墙体和角色之间的深度排序,也就是,两种贴图。注意,这些捷径可以让你节省大量处理时间,这对游戏表现是非常重要的。

总结

现在,你应该掌握了制作等距游戏世界的基础知识了:你可以渲染等距游戏世界中的对象,把平面数据表现为简单的2D数组,将笛卡尔座标转换为等距座标,了解了深度排序和角色动画的概念。试试制作你自己的等距游戏世界吧!(本文为游戏邦/gamerboom.com编译,拒绝任何不保留版权的转载,如需转载请联系:游戏邦

Creating Isometric Worlds: A Primer for Game Developers

by Juwal Bose

In this tutorial, I’ll give you a broad overview of what you need to know to create isometric worlds. You’ll learn what the isometric projection is, and how to represent isometric levels as 2D arrays. We’ll formulate relationships between the view and the logic, so that we can easily manipulate objects on screen and handle tile-based collision detection. We’ll also look at depth sorting and character animation.

1. The Isometric World

Isometric view is a display method used to create an illusion of 3D for an otherwise 2D game – sometimes referred to as pseudo 3D or 2.5D. These images (taken from the original Diablo and Age of Empires games) illustrate what I mean:

Classic Diablo

Age of Empires

Implementing an isometric view can be done in many ways, but for the sake of simplicity I’ll focus on a tile-based approach, which is the most efficient and widely used method. I’ve overlaid each screenshot above with a diamond grid showing how the terrain is split up into tiles.

2. Tile-Based Games

In the tile-based approach, each visual element is broken down into smaller pieces, called tiles, of a standard size. These tiles will be arranged to form the game world according to pre-determined level data – usually a 2D array.

Related Posts

Tony Pa’s tile-based tutorials.

For example let us consider a standard top-down 2D view with two tiles – a grass tile and a wall tile – as shown here:

Some simple tiles

These tiles are each the same size as each other, and are each square, so the tile height and tile width are the same.

For a level with grassland enclosed on all sides by walls, the level data’s 2D array will look like this:

[[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]]

Here, 0 denotes a grass tile and 1 denotes a wall tile. Arranging the tiles according to the level data will produce the below level image:

A simple level, displayed in a top-down view.

We can enhance this by adding corner tiles and separate vertical and horizontal wall tiles, requiring five additional tiles:

[[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]]

Enhanced level with tile numbers

I hope the concept of the tile-based approach is now clear. This is a straightforward 2D grid implementation, which we could code like so:

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)

Here we assume that tile width and tile height are equal (and the same for all tiles), and match the tile images’ dimensions. So, the tile width and tile height for this example are both 50px, which makes up the total level size of 300x300px – that is, six rows and six columns of tiles measuring 50x50px each.

In a normal tile-based approach, we either implement a top-down view or a side view; for an isometric view we need to implement the isometric projection.

3. Isometric Projection

The best technical explanation of what “isometric projection” means, as far as I’m aware, is from this article by Clint Bellanger:

We angle our camera along two axes (swing the camera 45 degrees to one side, then 30 degrees down). This creates a diamond (rhombus) shaped grid where the grid spaces are twice as wide as they are tall. This style was popularized by strategy games and action RPGs. If we look at a cube in this view, three sides are visible (top and two facing sides).

Although it sounds a bit complicated, actually implementing this view is straightforward. What we need to understand is the relation between 2D space and the isometric space – that is, the relation between the level data and view; the transformation from top-down “Cartesian” coordinates to isometric coordinates.

Cartesian grid vs. isometric grid.

(We are not considering a hexagonal tile based technique, which is another way of implementing isometric worlds.)

Placing Isometric Tiles

Let me try to simplify the relationship between level data stored as a 2D array and the isometric view – that is, how we transform Cartesian coordinates to isometric coordinates.

We will try to create the isometric view for our wall-enclosed grassland level data:

[[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]]

In this scenario we can determine a walkable area by checking whether the array element is 0 at that coordinate, thereby indicating grass. The 2D view implementation of the above level was a straightforward iteration with two loops, placing square tiles offsetting each with the fixed tile height and tile width values.

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)

For the isometric view, the code remains the same, but the placeTile() function changes.

For an isometric view we need to calculate the corresponding isometric coordinates inside the loops.

The equations to do this are as follows, where isoX and isoY represent isometric x- and y-coordinates, and cartX and cartY represent Cartesian x- and y-coordinates:

//Cartesian to isometric:

isoX = cartX – cartY;
isoY = (cartX + cartY) / 2;

//Isometric to Cartesian:

cartX = (2 * isoY + isoX) / 2;
cartY = (2 * isoY – isoX) / 2;

These functions show how you can convert from one system to another:

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);
}
The pseudocode for the loop then looks like this:

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)))

Our wall-enclosed grassland in an isometric view.

As an example, let’s see how a typical 2D position gets converted to an isometric position:

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];
Similarly, an input of [0, 0] will result in [0, 0], and [10, 5] will give [5, 7.5].

The above method enables us to create a direct correlation between the 2D level data and the isometric coordinates. We can find the tile’s coordinates in the level data from its Cartesian coordinates using this function:

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);
}

(Here, we essentially assume that tile height and tile width are equal, as in most cases.)

Hence, from a pair of screen (isometric) coordinates, we can find tile coordinates by calling:

getTileCoordinates(isoTo2D(screen point), tile height);
This screen point could be, say, a mouse click position or a pick-up position.
Tip: Another method of placement is the Zigzag model, which takes a different approach altogether.

Moving in Isometric Coordinates

Movement is very easy: you manipulate your game world data in Cartesian coordinates and just use the above functions for updating it on the screen. For example, if you want to move a character forward in the positive y-direction, you can simply increment its y property and then convert its position to isometric coordinates:

y = y + speed;
placetile(twoDToIso(new Point(x, y)))
Depth Sorting

In addition to normal placement, we will need to take care of depth sorting for drawing the isometric world. This makes sure that items closer to the player are drawn on top of items farther away.

The simplest depth sorting method is simply to use the Cartesian y-coordinate value, as mentioned in this Quick Tip: the further up the screen the object is, the earlier it should be drawn. This work well as long as we do not have any sprites that occupy more than a single tile space.

The most efficient way of depth sorting for isometric worlds is to break all the tiles into standard single-tile dimensions and not to allow larger images. For example, here is a tile which does not fit into the standard tile size – see how we can split it into multiple tiles which each fit the tile dimensions:

A large image is split into multiple tiles of standard isometric dimensions

4. Creating the Art

Isometric art can be pixel art, but it doesn’t have to be. When dealing with isometric pixel art, RhysD’s guide tells you almost everything you need to know. Some theory can be found on Wikipedia as well.

When creating isometric art, the general rules are

Start with a blank isometric grid and adhere to pixel perfect precision.

Try to break art into single isometric tile images.

Try to make sure that each tile is either walkable or non-walkable. It will be complicated if we need to accommodate a single tile that contains both walkable and non-walkable areas.

Most tiles will need to seamlessly tile in one or more directions.

Shadows can be tricky to implement, unless we use a layered approach where we draw shadows on the ground layer and then draw the hero (or trees, or other objects) on the top layer. If the approach you use is not multi-layered, make sure shadows fall to the front so that they won’t fall on, say, the hero when he stands behind a tree.

In case you need to use a tile image larger than the standard isometric tile size, try to use a dimension which is a multiple of the iso tile size. It is better to have a layered approach in such cases, where we can split the art into different pieces based on its height. For example, a tree can be split into three pieces: the root, the trunk, and the foliage. This makes it easier to sort depths as we can draw pieces in corresponding layers which corresponds with their heights.

Isometric tiles that are larger than the single tile dimensions will create issues with depth sorting. Some of the issues are discussed in these links:

Related Posts

Bigger tiles.

Splitting and Painter’s algorithm.

Openspace’s post on effective ways of splitting up larger tiles.

5. Isometric Characters

Implementing characters in isometric view is not complicated as it may sound. Character art needs to be created according to certain standards. First we will need to fix how many directions of motion are permitted in our game – usually games will provide four-way movement or eight-way movement.

Eight-way navigation directions in top-down and isometric views.

For a top-down view, we could create a set of character animations facing in one direction, and simply rotate them for all the others. For isometric character art, we need to re-render each animation in each of the permitted directions – so for eight-way motion we need to create eight animations for each action. For ease of understanding we usually denote the directions as North, North-West, West, South-West, South, South-East, East, and North-East, anti-clockwise, in that order.

An isometric character facing in different directions.

We place characters in the same way that we place tiles. The movement of a character is accomplished by calculating the movement in Cartesian coordinates and then converting to isometric coordinates. Let’s assume we are using the keyboard to control the character.

We will set two variables, dX and dY, based on the directional keys pressed. By default these variables will be 0, and will be updated as per the chart below, where U, D, R and L denote the Up, Down, Right and Left arrow keys, respectively. A value of 1 under a key represents that key being pressed; 0 implies that the key is not being pressed.

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

Now, using the values of dX and dY, we can update the Cartesian coordinates as so:

newX = currentX + (dX * speed);
newY = currentY + (dY * speed);

So dX and dY stand for the change in the x- and y-positions of the character, based on the keys pressed.
We can easily calculate the new isometric coordinates, as we’ve already discussed:

Iso = twoDToIso(new Point(newX, newY))

Once we have the new isometric position, we need to move the character to this position. Based on the values we have for dX and dY, we can decide which direction the character is facing and use the corresponding character art.

Collision Detection

Collision detection is done by checking whether the tile at the calculated new position is a non-walkable tile. So, once we find the new position, we don’t immediately move the character there, but first check to see what tile occupies that space.

tile coordinate = getTileCoordinates(isoTo2D(iso point), tile height);
if (isWalkable(tile coordinate)) {
moveCharacter();
} else {
//do nothing;
}

In the function isWalkable(), we check whether the level data array value at the given coordinate is a walkable tile or not. We must take care to update the direction in which the character is facing – even if he does not move, as in the case of him hitting a non-walkable tile.

Depth Sorting With Characters

Consider a character and a tree tile in the isometric world.

For properly understanding depth sorting, we must understand that whenever the character’s x- and y-coordinates are less than those of the tree, the tree overlaps the character. Whenever the character’s x- and y-coordinates are greater than that of the tree, the character overlaps the tree.

When they have the same x-coordinate, then we decide based on the y-coordinate alone: whichever has the higher y-coordinate overlaps the other. When they have same y-coordinate then we decide based on the x-coordinate alone: whichever has the higher x-coordinate overlaps the other.

A simplified version of this is to just sequentially draw the levels starting from the farthest tile – that is, tile[0][0] – then draw all the tiles in each row one by one. If a character occupies a tile, we draw the ground tile first and then render the character tile. This will work fine, because the character cannot occupy a wall tile.

Depth sorting must be done every time any tile changes position. For instance, we need to do it whenever characters move. We then update the displayed scene, after performing the depth sort, to reflect the depth changes.

6. Have a Go!

Now, put your new knowledge to good use by creating a working prototype, with keyboard controls and proper depth sorting and collision detection. Here’s my demo:

Click to give the SWF focus, then use the arrow keys. Click here for the full-sized version.

You may find this utility class useful (I’ve written it in AS3, but you should be able to understand it in any other programming language):

+ expand source

If you get really stuck, here’s the full code from my demo (in Flash and AS3 timeline code form):

+ expand source

Registration Points

Give special consideration to the registration points of the tiles and the hero. (Registration points can be thought of as the origin points for each particular sprite.) These generally won’t fall inside the image, but rather will be the top left corner of the sprite’s bounding box.

We will have to alter our drawing code to fix the registration points correctly, mainly for the hero.

Collision Detection

Another interesting point to note is that we calculate collision detection based on the point where the hero is.

But the hero has volume, and cannot be accurately represented by a single point, so we need to represent the hero as a rectangle and check for collisions against each corner of this rectangle so that there are no overlaps with other tiles and hence no depth artifacts.

Shortcuts

In the demo, I simply redraw the scene again each frame based on the new position of the hero. We find the tile which the hero occupies and draw the hero on top of the ground tile when the rendering loops reach those tiles.

But if we look closer, we will find that there is no need to loop through all the tiles in this case. The grass tiles and the top and left wall tiles are always drawn before the hero is drawn, so we don’t ever need to redraw them at all. Also, the bottom and right wall tiles are always in front of the hero and hence drawn after the hero is drawn.

Essentially, then, we only need to perform depth sorting between the wall inside the active area and the hero – that is, two tiles. Noticing these kinds of shortcuts will help you save a lot of processing time, which can be crucial for performance.

Conclusion

By now, you should have a great basis for building isometric games of your own: you can render the world and the objects in it, represent level data in simple 2D arrays, convert between Cartesian and isometric coordiates, and deal with concepts like depth sorting and character animation. Enjoy creating isometric worlds!(source:gamedev.tutsplus)


上一篇:

下一篇: