卡马克算法:由约翰·卡马克(John Carmack)开发的一种游戏地图处理方法,被广泛运用到2D卷轴式游戏和手机游戏中。
约翰·卡马克:id Software创始人之一,技术总监。享誉世界的著名程序员,以卡马克算法和3D游戏引擎开发而闻名世界,被奉为游戏行业偶像。同时他也是个全面型的技术天才,现在致力于民用航天器开发,是民用航天器开发小组Armadillo Aerospace的主要创办人和技术骨干。
约翰·卡马克(百度百科):
约翰·卡马克(维基百科):
地图是游戏中必不可少的一种预算元素,尤其是在RPG、ACT等类型的游戏中作用更为重要,一个漂亮的地图效果和一个流畅的卷动速度会大大增加玩家的游戏体验。而游戏中地图滚动的重绘有多种算法,由于手机性能的限制和开发周期等其他非技术条件,需要根据情况灵活选择所需的技术。本文将主要介绍如何使用OPhone API来绘制2D游戏中的场景,也即地图的绘制方法。
地图绘制及滚动的常用算法无缝图片滚动画法
最简单的一种画地图方法,无需使用数组,只需要使用一张无缝的背景图片,在屏幕上绘制两次,以此来实现最简单的地图滚动效果和图片的重复使用以节约资源。
如下图,红色虚线部分为屏幕,使用一个偏移量在屏幕中错开位置贴上两次图片,通过不断改变偏移量的大小来实现动画效果。
代码举例:
//imgBack图片对象
//posX图片在X轴方向上的偏移量
canvas.drawBitmap(imgBack, -posX, 0, paint);
canvas.drawBitmap(imgBack, imgBack.getHeight()+posX, 0, paint);
if(posX==-imgBack.getHeight())
posX=0; //imgBack图片对象 //posX图片在X轴方向上的偏移量 canvas.drawBitmap(imgBack, -posX, 0, paint); canvas.drawBitmap(imgBack, imgBack.getHeight()+posX, 0, paint); if(posX==-imgBack.getHeight()) posX=0;
优点与局限:此算法非常简单,由于是单张图片反复滚动生成的背景图片,所以对于美术人员的限制较少,利于发挥,而且外观效果好。但因为不是地图Tile组成的,资源复用率不高,只能用于生成不太复杂的地图。而且由于没有Tile的存在,无法针对不同的Tile计算碰撞。最终使得这种画法只能用于绘制简单屏幕背景图片,而无法用在有复杂物理碰撞的地图层。
裁剪区画法 我们平时所玩的游戏一般场景都是大于屏幕的尺寸的,也就是说在游戏中的主角移动的时候,后面的地图将会随着主角的位置变化而发生移动,我们称之为地图的卷轴效果。而对诸如RPG,ACT这类地图场景比较大的类型的游戏来说,地图都不是一整张的背景图直接使用,而是采用一种“拼接”的方式,这样做既能节省内存的占用,同时也能使图片资源的利用率达到最大化。下图就是2D游戏常用的图片样式:
从图中我们能够看出,我们可以把整张图片进行分割,并将分割后的图片进行编号,如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
为每块图素编号之后,就可以设计自己的地图了。这里需要使用一种叫做“地图编辑器”的工具软件。我们这里使用“mapwin”进行地图的设计,使用步骤如下图所示:
上面的四个输入框分别代表地图小块的宽度和高度,以及我们要创建的整个场景的水平和垂直的地图块数,输入后点击“OK”如下图所示:
下面需要引入一张图片,引入方法为“File——Import”,选取一张图片并点击确定,随后就能看到如下的图片:
剩下的工作想必你就可以想到了,用鼠标在右边区域选取一个图块,然后将其放到左边黑色区域中即可,拼接完的效果如下图:
接下来要把地图数据导出,导出放下如下图:
最后我们需要的数据是这样的:
const short ss_map0[10][10] = {
{ 1, 1, 1, 1, 1, 1, 1, 5, 1, 1 },
{ 10, 10, 10, 1, 1, 1, 1, 1, 1, 1 },
{ 8, 8, 8, 1, 1, 1, 1, 1, 1, 1 },
{ 9, 9, 9, 1, 1, 1, 1, 14, 15, 1 },
{ 1, 1, 1, 1, 1, 1, 1, 16, 17, 1 },
{ 1, 1, 1, 6, 11, 1, 1, 1, 1, 1 },
{ 1, 1, 1, 1, 11, 1, 1, 1, 21, 1 },
{ 1, 4, 1, 1, 1, 1, 1, 1, 1, 1 },
{ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 },
{ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 }
};
实际上就是一个二维数组,数组中的数字即为地图块的索引号。
使用二维数组保存地图信息,另外有一张图片素材,根据地图数组的不同下标,配合public boolean clipRect(float left, float top, float right, float bottom,Region.Op op) 裁剪区方法,将对应的Tile显示在正确的位置上。
如下图所示,红色虚线部分为屏幕,红色实线为裁剪区,通过读取地图数组,将相应的位置设置为裁剪区,并用将图片素材相对于裁剪区偏移一定x,y位置的方法,使得要绘制的Tile正好对应出现在裁剪区中。
代码举例:
// 绘制切割图片
public void drawClipImg(int XDest, int YDest, int Width, int Height,
int XSrc, int YSrc, Bitmap img, Paint g,Canvas canvas)
{
canvas.clipRect(XDest, YDest, XDest + Width, YDest + Height,
Region.Op.REPLACE);
canvas.drawBitmap(img, XDest - XSrc, YDest - YSrc, g);
canvas.clipRect(0, 0, Const.SCREEN_WIDTH, Const.SCREEN_HEIGHT,
Region.Op.REPLACE);
} // 绘制切割图片 public void drawClipImg(int XDest, int YDest, int Width, int Height, int XSrc, int YSrc, Bitmap img, Paint g,Canvas canvas) { canvas.clipRect(XDest, YDest, XDest + Width, YDest + Height, Region.Op.REPLACE); canvas.drawBitmap(img, XDest - XSrc, YDest - YSrc, g); canvas.clipRect(0, 0, Const.SCREEN_WIDTH, Const.SCREEN_HEIGHT, Region.Op.REPLACE); }
相对于前一种画法,图片资源的利用率提高了很多,可以绘制很复杂的地图。由于Tile的存在,可以针对不同的Tile计算碰撞,可以用于地图物理层的绘制。
最常见的地图绘制优化——只绘制当前屏幕
上面的绘制方法都是将整个地图的数据全部画出来的,这样做实际上也存在很大的浪费,因为玩家实际上只能看见屏幕中的一块区域,其他大部分的地图即使被绘制也不能反映到屏幕上,反而因为这个不必要的步骤大大增加了CPU的负担,从而影响了游戏的流畅程度。因此,在实际开发中,常用的优化方法就是只绘制当前屏幕的地图块。代码如下:
//计算单元格起始位置下标
int startIndexX =leftTopY/ MAP_TILE_SIZE;
int startIndexY =leftTopX/ MAP_TILE_SIZE;
//再使用上面得到的数据修改双循环绘制的条件即可,
for (int i = startIndexX; i 1; i++)
for (int j = startIndexY; j 1; j++) //计算单元格起始位置下标 int startIndexX =leftTopY/ MAP_TILE_SIZE; int startIndexY =leftTopX/ MAP_TILE_SIZE; //再使用上面得到的数据修改双循环绘制的条件即可, for (int i = startIndexX; i
卡马克卷轴算法的引入
上面的算法虽然在一定程度上解决了地图绘制的效率问题,但对于某些资源严重不足的手机,或者由于地图块比较小、循环次数过多的情况,仍然会造成画图时屏幕闪烁。因此,在这种情况下,仍然需要对上述算法做进一步的优化。
不论采用哪种优化算法,一个基本的思路就是尽量减少绘制的次数,从而减少对系统资源的消耗。卡马克卷轴算法就是这样算法的一个经典例子。
单方向卷轴 对于横版游戏来说,如果角色向右侧移动,则地图向左侧滚动。由于角色每次移动若干个步长,因此地图中新画出的区域宽度也为若干个像素,那么如果让系统重绘所有屏幕区域,很明显,大部分区域都是和上一屏幕区域相同的,如此造成成了资源的浪费。而卡马克算法的思路就是——如果上一次绘制过的地图也能够部分重用到本次地图绘制上来就好了。那么很容易想到在内存中建立一个和屏幕一样大或略大的缓冲区即可很好的完成这个设想。
由上图可以看到,区域B为相同的地图区域,这个区域在下一次屏幕重绘时,可以被重新利用。区域A是在下一次屏幕重绘中不被采用的区域,这区域应当被舍弃,但是如果稍微留意一下的话,不难发现区域A和区域C的面积大小其实居然是一样的。 那么如果建立一个和屏幕大小相同的缓冲,在其被舍弃掉的绘制区域A中画上新的区域C,再把区域B和区域C拼合到屏幕上,是不是就能达到减少系统资源消耗的目的了呢?卡马克卷轴的基本原理正是如此。
图显示了卡马克卷轴的最基本原理,首先在内存中建立一块和屏幕一样大小(或略大)的缓冲区。然后在本应由于地图移动而被舍弃掉的区域1上面绘制,由于地图滚动而出现的新地图区域。最后把两个区域按照地图的实际位置拼合到屏幕上。
双轴滚动的卡马克卷轴 对于俯视游戏,或者有Y轴卷动的游戏来说,单单一个方向的地图卷动并不够用。那么如果是出现两个方向的卷动会如何呢。不必担心,上面的思路算法一样能适应这种情况。
由上图可以看到,区域D为相同的地图区域,这个区域在下一次屏幕重绘时,可以被重新利用。区域ABC是在下一次屏幕重绘中不被采用的区域,可以在这个3个区域上绘制上下一次需要重绘的区域A’B’C’。再将绘制好的四个区域拼合到屏幕的对应位置。
上图显示了双轴滚动的卡马克卷轴的基本绘制原理,需要特别注意的是:在缓冲区的绘制顺序和在屏幕上拼合的顺序是完全相反的。
卡马克算法的实现
卡马克卷轴缓冲画法的一般步骤如下:
1. 初始化所有地图数据,并且全屏绘制初始的地图
2. 若人物移动,则调用摄像机算法,修正地图偏移量
3. 地图偏移量不满足地图的边界条件,就重绘缓冲区
4. 重绘缓冲区
5. 后台缓冲区的四个子区按照顺序画到屏幕上
地图类——Map的设计字段定义
//地图数据
public byte mapData[][];
//移动缓冲区的当前坐标窗口
public int sx,sy;
//地图图片
private Bitmap imgMap;
public GameView m_View;
//常量
public final static int MAP_TILE_SIZE = 24;
private int bufWidth, bufHeight;
private int carTileWidth, carTileHeight;
private int scrWidth, scrHeight;
private int carx, cary;
private int mapOffx, mapOffy;
public Bitmap carBuffer;
private Canvas carGp;
private int buffSize;
private int imageTileWidth;
private Bitmap mapImage;
Paint paint=new Paint();
private byte mapArray[][];
private int tileSize;
private int tileW, tileH;
private int mapLastx, mapLasty; //地图数据 public byte mapData[][]; //移动缓冲区的当前坐标窗口 public int sx,sy; //地图图片 private Bitmap imgMap; public GameView m_View; //常量 public final static int MAP_TILE_SIZE = 24; private int bufWidth, bufHeight; private int carTileWidth, carTileHeight; private int scrWidth, scrHeight; private int carx, cary; private int mapOffx, mapOffy; public Bitmap carBuffer; private Canvas carGp; private int buffSize; private int imageTileWidth; private Bitmap mapImage; Paint paint=new Paint(); private byte mapArray[][]; private int tileSize; private int tileW, tileH; private int mapLastx, mapLasty;
方法定义
CarMapBuffer(int, int, int, int)构造器
CarMapBuffer(int, int, int)构造器的代理
setMap(Image, byte[][])设置地图参数
initBuffer()初始化绘制地图
scroll(int, int)卷动地图算法
updateBuffer(int, int)绘制缓冲区
getIndexCarX()获得切割线所在的图块索引X
getIndexCarY()获得切割线所在的图块索引Y
getBufferCarX()获得切割线在Buffer中的X位置
getBufferCarY()获得切割线在Buffer中的Y位置
getIndexBuffLastX()获得缓冲区后面的X索引
getIndexBuffLastY()获得缓冲区后面的Y索引
getTitleHeight()获得当前要绘制的图块高度的数量
getTitelWidth()获得当前要绘制的图块宽度的数量
copyBufferX(int, int, int, int, int) 由于x方向卷动造成的重绘
copyBufferY(int, int, int, int, int) 由于y方向卷动造成的重绘
getMapX(int, int) 获得地图图片的X坐标偏移
getMapY(int, int) 获得地图图片的Y坐标偏移
paint(Graphics, int, int)将缓冲区的内容分成4块依次拼合到屏幕上
drawBuffer(Graphics, int, int)绘制缓冲区方法
drawRegion(Graphics, Image, int, int, int, int, int, int, int, int)封装的drawRegion()方法
getGraphics()获得缓冲区画笔
getImage()获得缓冲区Image对象
步骤一的实现初始化所有地图数据,并且全屏绘制初始的地图,代码如下:
private void initBuffer()
{
int x, y, cx, cy;
for (int i = 0; i
{
for (int j = 0; j
{
x = getMapX(i, j);
y = getMapY(i, j);
cx = j * tileSize;
cy = i * tileSize;
m_View.drawClipImg(cx, cy, tileSize, tileSize, x, y, mapImage, paint, carGp);
}
}
} private void initBuffer() { int x, y, cx, cy; for (int i = 0; i
步骤二、三的实现若人物移动,则调用摄像机算法,修正地图偏移量,若偏移量在[0,maplast]移动范围内移动,则有可能发生重绘
private void scroll(int x, int y)
{
try
{
x += mapOffx;
y += mapOffy;
// *************************************************
// 边界检测
if (x 0 || y 0)
{
return;
}
if (x > mapLastx)
{
mapOffx = mapLastx;
return;
}
if (y > mapLasty)
{
mapOffy = mapLasty;
return;
}
updateBuffer(x, y);
// *************************************************
}
catch (ArrayIndexOutOfBound***ception e)
{
}
}
private void scroll(int x, int y) { try { x += mapOffx; y += mapOffy; // ************************************************* // 边界检测 if (x mapLastx) { mapOffx = mapLastx; return; } if (y > mapLasty) { mapOffy = mapLasty; return; } updateBuffer(x, y); // ************************************************* } catch (ArrayIndexOutOfBound***ception e) { } }
步骤四的实现
重绘缓冲区,地图的x方向卷动会造成列方向上的重绘(调用copyBufferX()方法),地图的y方向上的卷动会造成行方向上的重绘(调用copyBufferY()方法)。updateBuffer()方法用于针对不同的四个方向上的卷动进行copyBuffer()参数的初始化。
private void updateBuffer(int x, int y)
{
mapOffx = x;
mapOffy = y;
// 右移
if (x > carx + buffSize)
{
// while (carx
int indexMapLastX = getIndexBuffLastX();
if (indexMapLastX
{
copyBufferX(indexMapLastX, getIndexCarY(), getTileHeight(),
getBufferCarX(), getBufferCarY());
carx += tileSize;
}
// }
}
// 左移
if (x
{
// do {
carx -= tileSize;
copyBufferX(getIndexCarX(), getIndexCarY(), getTileHeight(),
getBufferCarX(), getBufferCarY());
// } while (carx > mapOffx);
}
// 下移
if (y > cary + buffSize)
{
// while (cary
int indexMapLastY = getIndexBuffLastY();
if (indexMapLastY
{
copyBufferY(getIndexCarX(), indexMapLastY, getTitelWidth(),
getBufferCarX(), getBufferCarY());
cary += tileSize;
}
// }
}
// 上移
if (y
{
// do {
cary -= tileSize;
copyBufferY(getIndexCarX(), getIndexCarY(), getTitelWidth(),
getBufferCarX(), getBufferCarY());
// } while (cary > mapOffy);
}
} private void updateBuffer(int x, int y) { mapOffx = x; mapOffy = y; // 右移 if (x > carx + buffSize) { // while (carx mapOffx); } // 下移 if (y > cary + buffSize) { // while (cary mapOffy); } }
重绘缓冲区的具体方法,该方法涉及到大量的坐标运算,而且由于卡马克点的存在经常会分成两个区域分两次进行重绘。见下图:
下面以x方向卷动为例举例
private void copyBufferX(int indexMapx, int indexMapy, int tileHeight,
int destx, int desty)
{
int mapImagex, mapImagey, vy;
// 拷贝地图上面到缓冲的下面
int timer=0;
for (int j = 0; j
{
mapImagex = getMapX(indexMapy + j, indexMapx);
mapImagey = getMapY(indexMapy + j, indexMapx);
vy = j * tileSize + desty;
m_View.drawClipImg(destx, vy, tileSize, tileSize, mapImagex, mapImagey, mapImage, paint, carGp);
timer++;
}
// 拷贝地图下面到缓冲的上面
for (int k = tileHeight; k
{
mapImagex = getMapX(indexMapy + k, indexMapx);
mapImagey = getMapY(indexMapy + k, indexMapx);
vy = (k - tileHeight) * tileSize;
m_View.drawClipImg(destx, vy, tileSize, tileSize, mapImagex, mapImagey, mapImage, paint, carGp);
timer++;
}
System.out.println("x:"+timer);
} private void copyBufferX(int indexMapx, int indexMapy, int tileHeight, int destx, int desty) { int mapImagex, mapImagey, vy; // 拷贝地图上面到缓冲的下面 int timer=0; for (int j = 0; j
步骤五的实现 将后台缓冲区的四个子区按照顺序画到屏幕上:
public void paint(Canvas g, int x, int y)
{
// 地图在缓冲中的坐标
int tempx = mapOffx % bufWidth;
int tempy = mapOffy % bufHeight;
// 切割线右下角的宽与高
int rightWidth = bufWidth - tempx;
int rightHeight = bufHeight - tempy;
// 画左上
drawRegion(g, carBuffer, tempx, tempy, rightWidth, rightHeight, 0, x, y);
// 画右上
drawRegion(g, carBuffer, 0, tempy, scrWidth - rightWidth, rightHeight, 0, x + rightWidth, y);
// 画左下
drawRegion(g, carBuffer, tempx, 0, rightWidth, scrHeight - rightHeight, 0, x, y + rightHeight);
// 画右下
drawRegion(g, carBuffer, 0, 0, scrWidth - rightWidth, scrHeight
- rightHeight, 0, x + rightWidth, y + rightHeight);
}
private void drawRegion(Canvas g, Bitmap img, int x_src, int y_src,
int width, int height, int transform, int x_dest,
int y_dest)
{
// 作宽度检测
if (width 0 || height 0)
{
return;
}
// 作超屏幕宽度检测
if (width > scrWidth)
{
width = scrWidth;
// 作超屏幕高度检测
}
if (height > scrHeight)
{
height = scrHeight;
}
m_View.drawClipImg(x_dest, y_dest, width, height, x_src, y_src, img, paint, g);
} public void paint(Canvas g, int x, int y) { // 地图在缓冲中的坐标 int tempx = mapOffx % bufWidth; int tempy = mapOffy % bufHeight; // 切割线右下角的宽与高 int rightWidth = bufWidth - tempx; int rightHeight = bufHeight - tempy; // 画左上 drawRegion(g, carBuffer, tempx, tempy, rightWidth, rightHeight, 0, x, y); // 画右上 drawRegion(g, carBuffer, 0, tempy, scrWidth - rightWidth, rightHeight, 0, x + rightWidth, y); // 画左下 drawRegion(g, carBuffer, tempx, 0, rightWidth, scrHeight - rightHeight, 0, x, y + rightHeight); // 画右下 drawRegion(g, carBuffer, 0, 0, scrWidth - rightWidth, scrHeight - rightHeight, 0, x + rightWidth, y + rightHeight); } private void drawRegion(Canvas g, Bitmap img, int x_src, int y_src, int width, int height, int transform, int x_dest, int y_dest) { // 作宽度检测 if (width scrWidth) { width = scrWidth; // 作超屏幕高度检测 } if (height > scrHeight) { height = scrHeight; } m_View.drawClipImg(x_dest, y_dest, width, height, x_src, y_src, img, paint, g); }
当然,地图的卷动和精灵的移动是分不开的,在本文中我们只阐述了游戏的地图绘制方法,关于精灵的绘制以及地图随精灵的位移而卷动,我们会在另一篇文章中做以介绍。
总结 卡马克算法是在进行2D游戏地图卷动的算法中内存痕迹最小、效率适中的算法之一。其核心的思想就是把地图卷动过程中移出屏幕(不需要在显示的部分)所占用的buffer区域,绘制上新的需要图块,在往屏幕上绘制的时候,通过四次绘制buffer把完整的地图重现。
我们在实际的代码编写中按以下的方式进行。根据上面的基本思想,把地图分为四个块(十字形的将buffer划分为四块),用carx和cary来记录十字分区的中心坐标(相对于buffer的坐标,我把这个点叫卡马克分区点)。
当地图向右移动的时候这时把卡马克分区点的坐标X方向加上一个tile的width,然后在从现在的卡马克分区点的坐标Y开始绘制提取出来的tileID对应的图象,注意是从当前的卡马克分区点的坐标Y开始绘制,当超出carHeight时在从0开始绘制直到结束,这样就完成了在水平方向上的更新。
还有就是在水平移动卡马克分区点的时候是在buffer中循环的,也就是从0到carWidth的一个循环过程,Y方向上完全一致。最后是绘制过程,也就是将四个分区绘制出来,口诀就是左变右上变下,掌握好卡马克算法对手游开发很有帮助的。
注:本文参考了网上关于卡马克算法的一些介绍并引用了其中的部分文字和图片。