创建等距世界:游戏开发入门
在本教程中,会让你知道要创建的等距世界的广泛概述。你将学习什么是等角投影,以及如何用二维数组表示等距水平。我们会制定视图和逻辑之间的联系,这样我们就可以很容易的操纵屏幕上的对象,处理区块碰撞检测。我们也考虑深度排序和角色动画。
1.等距世界
等距视图是一种用来为2D游戏-有时也被称为伪3D或2.5D,创建3D错觉的显示方法。这些图片(图片来自最初的暗黑破坏神和帝国时代游戏)说明了我的意思:
实施等距视图有很多方法,但为了简单起见,我将重点放在一个基于区块的方法,这是最有效、使用最广泛的方法。我上面的截图已经覆盖了一个菱形网格将地形分成区块。
2.基于区块的游戏在基于区块的方法中,每个视觉元素都被分解成小块,称为tile,具有标准的尺寸。这些tile将根据预先确定的水平数据-通常是一个二维数组,被用来形成游戏世界。
相关文章
Tony Pa’s tile-based tutorials
例如,让我们考虑一个有两个tile的标准俯视图-草tile和墙tile的2D视图-如下图所示:
这些tile大小都一样,而且都是正方形,所以tile的高度和宽度是相同的。
表示四面八方的墙壁围着草场的数据层的二维数组,看起来像这样:
[[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表示草tile,1表示墙tile.根据数据层排列tile会产生以下图像:
我们可以通过添加拐角tile和独立的垂直和水平墙tile来增强它,需要另外5个tile:
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]]
我希望现在基于区块的方法的概念是明确的。这是一个简单的二维网格实现,我们的代码可以像这样:
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, x, y)
在这里,我们假设tile高度和宽度是相等的(相同的tile),tile图像的尺寸相匹配。所以在这个例子中的tile的宽度和高度均为50px,这使得总水平尺寸为300x300px-即6行6列的tile,每个tile尺寸50x50px.
在一个普通的基于区块的方法中,我们可以实现俯视图或侧视图,要实现等距视图,我们需要创建等角投影。
3.等角投影
关于“等角投影”最好的技术解释,我认为,是出自Clint Bellanger的这篇文章:
1 “ 首先(以Z轴为主轴)整体顺时针旋转90度,那么所有边都与X轴成45度。然后(以X轴为主轴)旋转60度,也就是下面点向自己,上面的点向外,整个面跟Z轴成30度,这样我们 就能看到投影的高刚好是原来的高的一半 ,这种方式通常用于战略游戏和动作RPG游戏,在这个视图中看一个立方体我们会看到三个面。(顶部和两个相对的侧面)”
虽然这听起来有点复杂,实际上做到这一点很简单。我们需要了解的是二维空间和等距空间之间的关系-也就是说,数据层和视图的关系,数据层是根据俯视图的笛卡尔坐标转换的等距坐标。
(我们不考虑基于六角形tile的技术,那是实现等距世界的另一种方法)
放置等距tile
让我们尽量简化数据存储为一个二维数组和等距视图之间的关系-那就是,我们如何将笛卡尔坐标转换为等距坐标。
我们试着为墙壁围着草地数据创建等距视图。
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视图实现上述情况使用一个简单的双重循环,将正方形tile放置到固定的高度和宽度值的位置。
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, 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;
5 //Isometric to Cartesian:
6
7 cartX = (2 * isoY + isoX) / 2;
8 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 }
7 function twoDToIso(pt:Point):Point{
8 var tempPt:Point = new Point(0,0);
9 tempPt.x = pt.x - pt.y;
10 tempPt.y = (pt.x + pt.y) / 2;
11 return(tempPt);
12 }
伪代码的循环看起来像这样:
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)))
墙壁围着草地的等距视图
让我们看到一个二维位置被转换为等距位置的典型的一个例子,
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水平数据和等距坐标之间的直接关系。我们看到使用此函数将tile的笛卡尔坐标转换为水平坐标数据。
1 function 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 }
(在这里,我们基本上认为tile的宽度和高度是相等的,在大多数情况下。)
因此,根据一对等距坐标,我们通过调用可以找到tile坐标。
1 getTileCoordinates(isoTo2D(screen point), tile height);
screen point表示,可以用鼠标点击位置和获取位置。
提示
另一种方法是 Zigzag model,这是一种完全不同的方法。
移动等距坐标
运动是很简单的,你只是使用上述函数在笛卡尔坐标系中操作你的游戏世界数据,在屏幕上更新。例如,假如你想沿着Y轴正方形移动一个字符,你可以简单的增加Y值,然后将其转换为等距坐标位置。
1 y = y + speed;
2 placetile(twoDToIso(new Point(x, y)))
深度排序
除了正常的位置,我们需要考虑绘制等距世界的深度排序。这可以确保项目中绘制的元素更真实。
简单的深度排序方法就是使用笛卡尔y坐标值,Quick Tip中提到:屏幕上深一层的对象,应该先被绘制。只要我们没有任何一个sprite占据超过一个单一tile空间就可以了。
等距世界的深度排列的最有效方法就是要不允许更大的图像,将其全部分解成标准tile尺寸。例如,这里是一个不适合标准tile尺寸的图像--看我们如何把它分解成多个适合tile尺寸的tile:
4.创建艺术
等距艺术可以是像素艺术,但这并不是必须的。当处理等距像素艺术时,RhysD's guide可以告诉你你想知道的一切。有些理论看维基百科比较好。
创建等距艺术时,一般的规则是:
- 开始一个空白的等距网格,坚持完美像素精度。
- 尝试打破单一等距tile图像艺术。
- 尽量确保每个tile是允许通过或禁止通过。这将是复杂的,我们需要包含一个单一的tile同时包含可通行区域和非通行区域。大部分tile都需要一个或多个方向上的无缝tile.
- 阴影的实现可能会非常棘手,除非我们使用分层的方法,我们将阴影绘制在底层,然会将英雄(或树木,或其他物体)绘制在顶层。如果你使用的不是分层的方法,当英雄站在树后面时,确保阴影落到前面,让它们不会重叠。
- 假如你需要使用tile大于标准等距tile大小的图像时,请尝试使用多重tile标准尺寸。在这种情况下,最好是用分层的方法,根据其高度我们可以分割成不同的部分。例如,树可以被分成三个部分:根,树干,枝叶。这使得它更容易进行排序,我们可以根据高度将其绘制在相应的层。
大于标准tile尺寸的图像将是创建深度排序的障碍。在下面链接中对这个问题进行了一些讨论:
相关文章
Bigger tiles
Splitting and Painter’s algorithm
Openspace’s post on effective ways of splitting up larger tiles
5.等距角色
创建等距角色视图听起来并不复杂。角色艺术需要按照一定的标准创建。首先,我们需要确定在我们的游戏中有多少运动方向--通常是四方向运动或八方向运动。
在一个自上而下的视图中,我们可以创建一组朝向一个方向的动画角色,只需要旋转就可以得到其他方向。
对于等距角色艺术,我们需要在每个允许的方向上重新显示每个动画--所以对于八方向运动,我们需要创建8个动画。为了便于理解,我们通常指的方向是:北,西北,西,西南,东南,东,东北,沿逆时针顺序。
我们将以与tile相同的方式放置角色。角色的移动是通过在笛卡尔坐标系中计算运动,然后转换为等距坐标。假如我们是使用键盘控制人物。
我们将设置两个变量dX和dY,根据按下的方向键。默认情况下,这些变量为0,在下面的表格中 U , D , R 和 L分别表示上,下,左和右箭头键。值为1时表示该键被按下,为0时没有被按下。
1 Key Pos
2 U D R L dX dY
3 ================
4 0 0 0 0 0 0
5 1 0 0 0 0 1
6 0 1 0 0 0 -1
7 0 0 1 0 1 0
8 0 0 0 1 -1 0
9 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的值,我们可以确定角色面对的方向,并使用相应的角色艺术。
碰撞检测
碰撞检测是通过检查一个新位置上的tile是否为可通行tile.因此,一旦找到了新的位置,我们不立即移动角色,而是,首先检查tile是否可通行。
1 tile coordinate = getTileCoordinates(isoTo2D(iso point), tile height);
2 if (isWalkable(tile coordinate)) {
3 moveCharacter();
4 } else {
5 //do nothing;
6 }
在isWalkable()函数中,我们检查数据数组值的坐标是否是一个可通行的tile.我们必须考虑角色朝向的方向--即使不动的情况下,也要检测tile是否可通行。
角色深度排序
考虑等距世界里的一个角色和一颗树。
为了正确理解深度排序,我们必须明白,当角色的x和y坐标小于树时,树覆盖角色,当角色坐标大于树时,角色覆盖树。
当他们具有相同的x坐标时,我们取决于单独的y坐标:具有较高的y坐标的会覆盖另一个,当有相同的y坐标时,取决于x坐标,具有较高的x坐标的会覆盖另一个.
一个简化版本就是从最远的地方开始绘制,即 tile[0][0],然后一行一个绘制tile。如果一个角色占用tile,我们首先绘制地面tile,然后绘制角色tile.这将正常工作,因为角色不会占用墙tile.
深度排序每次必须完成每一次tile位置的改变。举例来说,我们在角色移动后,更新显示场景,进行深度排序后,反映深度的变化。
6.试一试
现在,很好地利用你学到的新知识,来创造一个项目,用键盘控制,适当的深度排序和碰撞检测。这里是我的演示:
点击获得焦点,然后使用方向键。点击这里为全尺寸的版本
你可能会觉得这个工具类有用(我用AS3写的,但你应该能理解他能用于任何编程语言):
1 package com.csharks.juwalbose
2 {
3 import flash.display.Sprite;
4 import flash.geom.Point;
5
6 public class IsoHelper
7 {
8 /**
9 * convert an isometric point to 2D
10 * */
11 public static function isoTo2D(pt:Point):Point{
12 //gx=(2*isoy+isox)/2;
13 //gy=(2*isoy-isox)/2
14 var tempPt:Point=new Point(0,0);
15 tempPt.x=(2*pt.y+pt.x)/2;
16 tempPt.y=(2*pt.y-pt.x)/2;
17 return(tempPt);
18 }
19 /**
20 * convert a 2d point to isometric
21 * */
22 public static function twoDToIso(pt:Point):Point{
23 //gx=(isox-isoxy;
24 //gy=(isoy+isox)/2
25 var tempPt:Point=new Point(0,0);
26 tempPt.x=pt.x-pt.y;
27 tempPt.y=(pt.x+pt.y)/2;
28 return(tempPt);
29 }
30
31 /**
32 * convert a 2d point to specific tile row/column
33 * */
34 public static function getTileCoordinates(pt:Point, tileHeight:Number):Point{
35 var tempPt:Point=new Point(0,0);
36 tempPt.x=Math.floor(pt.x/tileHeight);
37 tempPt.y=Math.floor(pt.y/tileHeight);
38
39 return(tempPt);
40 }
41
42 /**
43 * convert specific tile row/column to 2d point
44 * */
45 public static function get2dFromTileCoordinates(pt:Point, tileHeight:Number):Point{
46 var tempPt:Point=new Point(0,0);
47 tempPt.x=pt.x*tileHeight;
48 tempPt.y=pt.y*tileHeight;
49
50 return(tempPt);
51 }
52
53 }
54 }
如果你卡在这,这里有完整的代码(在flash的时间轴上写的AS3代码)
1 // Uses senocular's KeyObject class
2 // http://www.senocular.com/flash/actionscript/?file=ActionScript_3.0/com/senocular/utils/KeyObject.as
3
4 import flash.display.Sprite;
5 import com.csharks.juwalbose.IsoHelper;
6 import flash.display.MovieClip;
7 import flash.geom.Point;
8 import flash.filters.GlowFilter;
9 import flash.events.Event;
10 import com.senocular.utils.KeyObject;
11 import flash.ui.Keyboard;
12 import flash.display.Bitmap;
13 import flash.display.BitmapData;
14 import flash.geom.Matrix;
15 import flash.geom.Rectangle;
16
17 var levelData=[[1,1,1,1,1,1],
18 [1,0,0,2,0,1],
19 [1,0,1,0,0,1],
20 [1,0,0,0,0,1],
21 [1,0,0,0,0,1],
22 [1,1,1,1,1,1]];
23
24 var tileWidth:uint = 50;
25 var borderOffsetY:uint = 70;
26 var borderOffsetX:uint = 275;
27
28 var facing:String = "south";
29 var currentFacing:String = "south";
30 var hero:MovieClip=new herotile();
31 hero.clip.gotoAndStop(facing);
32 var heroPointer:Sprite;
33 var key:KeyObject = new KeyObject(stage);//Senocular KeyObject Class
34 var heroHalfSize:uint=20;
35
36 //the tiles
37 var grassTile:MovieClip=new TileMc();
38 grassTile.gotoAndStop(1);
39 var wallTile:MovieClip=new TileMc();
40 wallTile.gotoAndStop(2);
41
42 //the canvas
43 var bg:Bitmap = new Bitmap(new BitmapData(650,450));
44 addChild(bg);
45 var rect:Rectangle=bg.bitmapData.rect;
46
47 //to handle depth
48 var overlayContainer:Sprite=new Sprite();
49 addChild(overlayContainer);
50
51 //to handle direction movement
52 var dX:Number = 0;
53 var dY:Number = 0;
54 var idle:Boolean = true;
55 var speed:uint = 5;
56 var heroCartPos:Point=new Point();
57 var heroTile:Point=new Point();
58
59 //add items to start level, add game loop
60 function createLevel()
61 {
62 var tileType:uint;
63 for (var i:uint=0; i<levelData.length; i++)
64 {
65 for (var j:uint=0; j<levelData[0].length; j++)
66 {
67 tileType = levelData[i][j];
68 placeTile(tileType,i,j);
69 if (tileType == 2)
70 {
71 levelData[i][j] = 0;
72 }
73 }
74 }
75 overlayContainer.addChild(heroPointer);
76 overlayContainer.alpha=0.5;
77 overlayContainer.scaleX=overlayContainer.scaleY=0.5;
78 overlayContainer.y=290;
79 overlayContainer.x=10;
80 depthSort();
81 addEventListener(Event.ENTER_FRAME,loop);
82 }
83
84 //place the tile based on coordinates
85 function placeTile(id:uint,i:uint,j:uint)
86 {
87 var pos:Point=new Point();
88 if (id == 2)
89 {
90
91 id = 0;
92 pos.x = j * tileWidth;
93 pos.y = i * tileWidth;
94 pos = IsoHelper.twoDToIso(pos);
95 hero.x = borderOffsetX + pos.x;
96 hero.y = borderOffsetY + pos.y;
97 //overlayContainer.addChild(hero);
98 heroCartPos.x = j * tileWidth;
99 heroCartPos.y = i * tileWidth;
100 heroTile.x=j;
101 heroTile.y=i;
102 heroPointer=new herodot();
103 heroPointer.x=heroCartPos.x;
104 heroPointer.y=heroCartPos.y;
105
106 }
107 var tile:MovieClip=new cartTile();
108 tile.gotoAndStop(id+1);
109 tile.x = j * tileWidth;
110 tile.y = i * tileWidth;
111 overlayContainer.addChild(tile);
112 }
113
114 //the game loop
115 function loop(e:Event)
116 {
117 if (key.isDown(Keyboard.UP))
118 {
119 dY = -1;
120 }
121 else if (key.isDown(Keyboard.DOWN))
122 {
123 dY = 1;
124 }
125 else
126 {
127 dY = 0;
128 }
129 if (key.isDown(Keyboard.RIGHT))
130 {
131 dX = 1;
132 if (dY == 0)
133 {
134 facing = "east";
135 }
136 else if (dY==1)
137 {
138 facing = "southeast";
139 dX = dY=0.5;
140 }
141 else
142 {
143 facing = "northeast";
144 dX=0.5;
145 dY=-0.5;
146 }
147 }
148 else if (key.isDown(Keyboard.LEFT))
149 {
150 dX = -1;
151 if (dY == 0)
152 {
153 facing = "west";
154 }
155 else if (dY==1)
156 {
157 facing = "southwest";
158 dY=0.5;
159 dX=-0.5;
160 }
161 else
162 {
163 facing = "northwest";
164 dX = dY=-0.5;
165 }
166 }
167 else
168 {
169 dX = 0;
170 if (dY == 0)
171 {
172 //facing="west";
173 }
174 else if (dY==1)
175 {
176 facing = "south";
177 }
178 else
179 {
180 facing = "north";
181 }
182 }
183 if (dY == 0 && dX == 0)
184 {
185 hero.clip.gotoAndStop(facing);
186 idle = true;
187 }
188 else if (idle||currentFacing!=facing)
189 {
190 idle = false;
191 currentFacing = facing;
192 hero.clip.gotoAndPlay(facing);
193 }
194 if (! idle && isWalkable())
195 {
196 heroCartPos.x += speed * dX;
197 heroCartPos.y += speed * dY;
198 heroPointer.x=heroCartPos.x;
199 heroPointer.y=heroCartPos.y;
200
201 var newPos:Point = IsoHelper.twoDToIso(heroCartPos);
202 //collision check
203 hero.x = borderOffsetX + newPos.x;
204 hero.y = borderOffsetY + newPos.y;
205 heroTile=IsoHelper.getTileCoordinates(heroCartPos,tileWidth);
206 depthSort();
207 //trace(heroTile);
208 }
209 tileTxt.text="Hero is on x: "+heroTile.x +" & y: "+heroTile.y;
210 }
211
212 //check for collision tile
213 function isWalkable():Boolean{
214 var able:Boolean=true;
215 var newPos:Point =new Point();
216 newPos.x=heroCartPos.x + (speed * dX);
217 newPos.y=heroCartPos.y + (speed * dY);
218 switch (facing){
219 case "north":
220 newPos.y-=heroHalfSize;
221 break;
222 case "south":
223 newPos.y+=heroHalfSize;
224 break;
225 case "east":
226 newPos.x+=heroHalfSize;
227 break;
228 case "west":
229 newPos.x-=heroHalfSize;
230 break;
231 case "northeast":
232 newPos.y-=heroHalfSize;
233 newPos.x+=heroHalfSize;
234 break;
235 case "southeast":
236 newPos.y+=heroHalfSize;
237 newPos.x+=heroHalfSize;
238 break;
239 case "northwest":
240 newPos.y-=heroHalfSize;
241 newPos.x-=heroHalfSize;
242 break;
243 case "southwest":
244 newPos.y+=heroHalfSize;
245 newPos.x-=heroHalfSize;
246 break;
247 }
248 newPos=IsoHelper.getTileCoordinates(newPos,tileWidth);
249 if(levelData[newPos.y][newPos.x]==1){
250 able=false;
251 }else{
252 //trace("new",newPos);
253 }
254 return able;
255 }
256
257 //sort depth & draw to canvas
258 function depthSort()
259 {
260 bg.bitmapData.lock();
261 bg.bitmapData.fillRect(rect,0xffffff);
262 var tileType:uint;
263 var mat:Matrix=new Matrix();
264 var pos:Point=new Point();
265 for (var i:uint=0; i<levelData.length; i++)
266 {
267 for (var j:uint=0; j<levelData[0].length; j++)
268 {
269 tileType = levelData[i][j];
270 //placeTile(tileType,i,j);
271
272 pos.x = j * tileWidth;
273 pos.y = i * tileWidth;
274 pos = IsoHelper.twoDToIso(pos);
275 mat.tx = borderOffsetX + pos.x;
276 mat.ty = borderOffsetY + pos.y;
277 if(tileType==0){
278 bg.bitmapData.draw(grassTile,mat);
279 }else{
280 bg.bitmapData.draw(wallTile,mat);
281 }
282 if(heroTile.x==j&&heroTile.y==i){
283 mat.tx=hero.x;
284 mat.ty=hero.y;
285 bg.bitmapData.draw(hero,mat);
286 }
287
288 }
289 }
290 bg.bitmapData.unlock();
291 //add character rectangle
292 }
293 createLevel();
注册点
特别注意tile和英雄的注册点。(注册点可以看做每个特定sprite的原点)。这一般不会在图像内部,而是在边框左上角。
我们将不得不改变我们的绘制代码来调整正确的注册点,主要用于英雄。
碰撞检测
另一点要注意的是,我们计算碰撞检测是基于英雄所在的点。
但英雄具有体积,不可以准确的表示为一个单一的点,所以我们需要用一个矩形来代表英雄并检查这个矩形和每个角落的碰撞,所以,才没有和其他tile和深度的对象重叠。
捷径
在演示中,当英雄位置更新时,我只是重绘场景的每一帧。我们发现当遍历其他tile时,英雄tile会在地面tile前面。
但是,如果我们仔细看就会发现,这种情况下没必要遍历所有的tile.草tile和墙tile的顶部和左侧都先于英雄绘制,所以我们不需要重绘它们。另外,底部和右侧的tile总是在英雄的前面,所以英雄是后绘制的。
从本质上讲,我们只需要对墙内活动区域和英雄进行深度排序-也就是两个tile.注意到这些捷径将帮助你节省大量的处理时间,可能是性能的关键。
总结
现在,建立一个自己的等距游戏,你应该有了很好的基础:你可以创建世界和对象,用简单的二维数组表示水平数据,转换笛卡尔坐标和等距坐标,和处理的概念,如深度排序和角色动画。希望你喜欢建造等距世界。
原文链接:创建等距世界:游戏开发入门