SDL游戏开发之四-卡马克卷轴

上一篇实现了瓦片地图的绘制,但是单纯地使用上面的代码还是有些问题的,下面就来讨论一下上一篇代码的局限性。

假设游戏的分辨率为960*720,瓦片地图的大小也是960*720,瓦片大小为32,那么960/32 = 30, 720 / 32 = 22,即共有瓦片30*22=660个。一般的游戏的FPS在60左右,即15ms刷新一次,那么需要在这15ms之内最多要660次才能绘制出整个地图,这还只是一个图层的情况下;如果存在多个图层的话,仅仅是绘制地图就是一个很大的开销。

卡马克卷轴

对于使用到瓦片地图的游戏来说,如果地图向右移动若干个像素,那么屏幕右侧则会出现新的内容;相反屏幕左侧的部分就不再需要了,而屏幕中间的很大一部分都是不需要重新绘制的。显然,如果每次都重新绘制所有的瓦片的话,有大部分区域都是和上一次的屏幕区域是相同的,如此造成了资源的浪费。这里存在了一个思路,重用这两次绘制的相同的部分,很容易想到创建一个略大于屏幕的缓冲区。

图1-地图向右滚动(图片来源于网络)

 在图1中,地图向右移动,区域C是新出现的部分,区域A是被舍弃的部分,而区域B则是可以重用的部分。从上面不难看出,区域A的大小和区域C的大小是相同的,那么如果我直接在区域A上绘制新的内容,再把区域B和更新后的区域A绘制到屏幕上,不就可以减少绘制次数了吗?上面的思路就是卡马克卷轴。

思路是有了,那么具体该怎么实现呢?

要解决下面三个问题:

  1. 刷新缓冲区的时机。
  2. 如何刷新缓冲区。
  3. 如何把缓冲区的内容绘制到屏幕上。

依次解决上面的问题。

1.刷新缓冲区的时机

在地图发生移动超过一个tileSize的时候,就需要刷新缓冲区。

在我个人看来,卡马克卷轴的真正思想在于引入了“切割线”。以图1为例,在初始状态下切割线carmarkX = 0,假设每次移动不超过tileSize的大小。在地图向右移动超过一个tileSize的时候,区域A就废弃,右侧将会出现新的一列地图,此时直接把新增的内容绘制到carmarkX所在的那一列(那一列就是切割线,即carmarkX所在的那一列),然后在拼接的时候,把更新后的区域A绘制到区域C即可。

这就是之前说的为什么要创建一个略大于屏幕的缓冲区,假如要创建一个和屏幕一样大的缓冲区的话,当地图右移的时候,只有移动超过一个tileSize的时候,才会刷新缓冲区。图一右移时,左侧不再需要,则在左侧绘制出现的新内容,而又因为刷新是在移动超过一个tileSize的时候才会进行,所以当移动少于一个tileSize时,最右侧显示的是最左侧的内容(切割线的大小是tileSize的整数倍)。如下图:

 

下面的两个问题还是在代码中说明。

在TMXTiledMap类的基础上新增卡马克卷轴的功能。

//TMXTiledMap.h
public:
	void fastDraw(int x, int y);
	void scroll(int x, int y);
  private:	
	void drawRegion(int srcX, int srcY, int width, int height, int destX, int destY);
	void updateBuffer(int x, int y);
	//卡马克绘图,再调用前应该设置_buffer为target
	void carmarkDraw(int id, int destX, int destY);

	void copyBufferX(int indexMapX, int indexMapY, int tileColNum, int destX, int destY);
	void copyBufferY(int indexMapX, int indexMapY, int tileRowNum, int destX, int destY);

	//获得切割线所在的图块索引
	int getIndexCarmarkX() const;
	int getIndexCarmarkY() const;

	//获得切割线的在缓冲区的位置
	int getBufferCarmarkX() const;
	int getBufferCarmarkY() const;

	//获取缓冲区后面的索引
	int getIndexBufLastX() const;
	int getIndexBufLastY() const;

	//获得当前缓冲区去掉切割线的图块个数
	int getCarTileRowNum() const;
	int getCarTileColNum() const;
 private:

	//缓冲区大小尺寸 buffer width|height
	int _bufferWidth;
	int _bufferHeight;

	//缓冲区图块个数 buffer row|col tile num
	int _bufferRowTileNum;
	int _bufferColTileNum;

	//缓冲区增加的额外大小
	int _extraSize;

	//缓冲区
	SDL_Texture* _buffer;

	//地图尺寸 - 缓冲区尺寸
	int _deltaWidth;
	int _deltaHeight;

	//地图在缓冲区的X、Y的偏移量,限制在[0, deltaWidth|deltaHeight]
	int _offsetX;
	int _offsetY;

	//缓冲区切割线 必定是tileSize的整数倍
	int _carmarkX;
	int _carmarkY;
};

TMXTiledMap新增了很多函数和属性,这些都是为了实现卡马克卷轴而准备的。

TMXTiledMap::TMXTiledMap(const std::string& tmxPath,SDL_Renderer*ren, int width, int height)
{
	//打开地图文件
	bool ret = this->initWithFile(tmxPath);

	//稍微使得缓冲区大点
	_extraSize = _tileSize;

	//缓冲区要稍微比屏幕的尺寸大一些,并且能被tileSize整除
	int temp = 0;
	while (temp < _visibleWidth)
		temp += _tileSize;
	_bufferWidth = temp + _extraSize;

	temp = 0;
	while (temp < _visibleHeight)
		temp += _tileSize;
	_bufferHeight = temp + _extraSize;

	//缓冲区图块个数
	_bufferRowTileNum = _bufferWidth / _tileSize;
	_bufferColTileNum = _bufferHeight / _tileSize;

	//创建缓冲区
	_buffer = SDL_CreateTexture(_pRenderer, SDL_PIXELFORMAT_RGB444, SDL_TEXTUREACCESS_TARGET, _bufferWidth, _bufferHeight);

	//地图变量初始化
	_deltaWidth = _mapRowTileNum * _tileSize - _visibleWidth;
	_deltaHeight = _mapColTileNum * _tileSize - _visibleHeight;

	//渲染到缓冲区
	SDL_SetRenderTarget(_pRenderer, _buffer);
	SDL_RenderClear(_pRenderer);
	//完全绘制
	this->draw();
	SDL_SetRenderTarget(_pRenderer, nullptr);
}

TMXTiledMap类的构造函数新增了对缓冲区的管理的功能,首先要保证缓冲区可以被tileSize整除,其次缓冲区要比屏幕打上_extraSize(原因上面已经说明),_deltaWidth和_deltaHeight的值为地图的尺寸 - 屏幕的尺寸,他们的大小决定了切割线的最大值

由于用到了缓冲区,所以在初始时需要先把当前的内容完全绘制到缓冲区。

void TMXTiledMap::scroll(int x, int y)
{
	x += _offsetX;
	y += _offsetY;

	if (x < 0 || y < 0)
		return;

	//缓冲区的偏移
	if (x > _deltaWidth) 
	{
		_offsetX = _deltaWidth;
		return;
	}
	if (y > _deltaHeight)
	{
		_offsetY = _deltaHeight;
		return;
	}
	//更新缓冲区
	this->updateBuffer(x, y);
}

scroll方法用来控制地图的移动,如果当前移动合法的话,则会调用updateBuffer来更新缓冲区。

2.如何更新缓冲区

void TMXTiledMap::updateBuffer(int x, int y)
{
	_offsetX = x;
	_offsetY = y;

	//右移
	if (x > _carmarkX + _extraSize)
	{
		int indexMapLastX = getIndexBufLastX();
		//不会越界
		if (indexMapLastX < _mapRowTileNum)
		{
			copyBufferX(indexMapLastX, getIndexCarmarkY(),
				getCarTileColNum(),
				getBufferCarmarkX(), getBufferCarmarkY());
			_carmarkX += _tileSize;
		}
	}

	//左移
	if (x < _carmarkX)
	{
		_carmarkX -= _tileSize;
		copyBufferX(getIndexCarmarkX(), getIndexCarmarkY(),
			getCarTileColNum(),
			getBufferCarmarkX(), getBufferCarmarkY());
	}

	//下移
	if (y > _carmarkY + _extraSize)
	{
		int indexMapLastY = getIndexBufLastY();

		if (indexMapLastY < _mapColTileNum)
		{
			copyBufferY(getIndexCarmarkX(), indexMapLastY,
				getCarTileRowNum(),
				getBufferCarmarkX(), getBufferCarmarkY());
			_carmarkY += _tileSize;
		}
	}

	//上移
	if (y < _carmarkY)
	{
		_carmarkY -= _tileSize;
		copyBufferY(getIndexCarmarkX(), getIndexCarmarkY(),
			getCarTileRowNum(),
			getBufferCarmarkX(), getBufferCarmarkY());
	}
}

右移的情况在上面已经分析过了,当右移时,如果x > _carmark + _extraSize时,先绘制(即绘制x=0的那列),之后切割线右移一个tileSize;当地图左移超过一个tileSize的时候,此时的x < _carmarkX成立,先让_carmarkX -= _tileSize;即切割线先左移,然后重绘。假设此时地图仅仅右移了一个tileSize,此时的carmarkX = _tileSize,重绘的区域在x轴为0的列,而在左移后,carmarkX = 0,更新的还是横轴为0的列。这就是切割线在更新缓冲区的作用。

int TMXTiledMap::getIndexCarmarkX() const
{
	return _carmarkX / _tileSize;
}

int TMXTiledMap::getIndexCarmarkY() const
{
	return _carmarkY / _tileSize;
}

int TMXTiledMap::getBufferCarmarkX() const
{
	return _carmarkX % _bufferWidth;
}

int TMXTiledMap::getBufferCarmarkY() const
{
	return _carmarkY % _bufferHeight;
}

int TMXTiledMap::getIndexBufLastX() const
{
	return (_carmarkX + _bufferWidth) / _tileSize;
}

int TMXTiledMap::getIndexBufLastY() const
{
	return (_carmarkY + _bufferHeight) / _tileSize;
}

int TMXTiledMap::getCarTileRowNum() const
{
	return (_bufferWidth - _carmarkX % _bufferWidth) / _tileSize;
}

int TMXTiledMap::getCarTileColNum() const
{
	return (_bufferHeight - _carmarkY % _bufferHeight) / _tileSize;
}

以上的几个函数都是在updateBuffer()中用到的。getIndexBufLastX()和getIndexBufLastY()主要用于确定当前要绘制地图的哪一部分。

x轴移动影响的是一列(不一定是整列);y轴移动影响的是一行(同样不一定是整行)。

getCarTileRowNum()和getCarTileColNum()则用于控制x、y移动是更新的列和行数。

void TMXTiledMap::copyBufferX(int indexMapX, int indexMapY, int tileColNum, int destX, int destY)
{
	int vy = 0;
	SDL_SetRenderTarget(_pRenderer, _buffer);
	//局部刷新
	//拷贝地图上面到缓冲区的下面??
	SDL_Rect rect = {destX, 0, _tileSize, _tileSize * _bufferColTileNum};
	SDL_RenderFillRect(_pRenderer, &rect);

	for (int j = 0; j < tileColNum; j++)
	{
		vy = j * _tileSize + destY;
		int id = this->getTileGIDAt(indexMapX, indexMapY + j);
		//绘制
		this->carmarkDraw(id, destX, vy);
	}
	//拷贝地图到缓冲区的上面
	for (int k = tileColNum; k < _bufferColTileNum; k++)
	{
		vy = (k - tileColNum) * _tileSize;
		int id = this->getTileGIDAt(indexMapX, indexMapY + k);

		this->carmarkDraw(id, destX, vy);
	}
	SDL_SetRenderTarget(_pRenderer, nullptr);
}
void TMXTiledMap::copyBufferY(int indexMapX, int indexMapY, int tileRowNum, int destX, int destY)
{
	int vx = 0;
	SDL_SetRenderTarget(_pRenderer, _buffer);
	//局部刷新
	//拷贝地图上面到缓冲区的下面??
	SDL_Rect rect = {0, destY, _tileSize * _bufferRowTileNum, _tileSize};
	SDL_RenderFillRect(_pRenderer, &rect);

	//拷贝地图左边到缓冲的右边
	for (int i = 0; i < tileRowNum; i++)
	{
		vx = i * _tileSize + destX;
		int id = this->getTileGIDAt(indexMapX + i, indexMapY);

		this->carmarkDraw(id, vx, destY);
	}
	//拷贝地图右边到缓冲区的左边
	for (int k = tileRowNum; k < _bufferRowTileNum; k++)
	{
		vx = (k - tileRowNum) * _tileSize;
		int id = this->getTileGIDAt(indexMapX + k, indexMapY);

		this->carmarkDraw(id, vx, destY);
	}
	SDL_SetRenderTarget(_pRenderer, nullptr);
}

上面的两个函数代码类似,以copyBufferX()为例,先是设置当前的缓冲区为渲染目标,接着是通过SDL_RenderFillRect局部刷新,这里没有使用SDL_RenderClear()是因为这个函数是全部刷讯。

然后下面的两个函数则是绘制,至于为什么分为两个循环,我个人也不太理解,不过好像是因为卡马克点的存在,希望哪个大佬可以解惑。

void TMXTiledMap::carmarkDraw(int id, int destX, int destY)
{
	//0代表无图块
    if(id == 0)
    {
		return;
    }
    Tileset* tileset = getTilesetByID(id);
    id--;

    drawTile(tileset->name,tileset->margin,tileset->spacing
             ,destX, destY
             ,_tileSize,_tileSize
             ,(id - (tileset->firstGirdID - 1))/tileset->numColumns
             ,(id - (tileset->firstGirdID - 1))%tileset->numColumns);
}

这个函数只是简单的封装了一下drawTile。

3.如何把缓冲区的内容绘制到屏幕

void TMXTiledMap::fastDraw(int x, int y)
{
	int tempX = _offsetX % _bufferWidth;
	int tempY = _offsetY % _bufferHeight;

	//切割右下角的宽与高
	int rightWidth = _bufferWidth - tempX;
	int rightHeight = _bufferHeight - tempY;

	//绘制左上角
	drawRegion(tempX, tempY, rightWidth, rightHeight, x, y);

	//绘制右上角
	drawRegion(0, tempY, _visibleWidth - rightWidth, rightHeight, x + rightWidth, y);

	//绘制左下角
	drawRegion(tempX, 0, rightWidth, _visibleHeight - rightHeight, x, y + rightHeight);

	//绘制右下角
	drawRegion(0, 0, _visibleWidth - rightWidth, _visibleHeight - rightHeight, x + rightWidth, y + rightHeight);
}

fastDraw函数中分4次进行绘制,这个也不太理解。。。

void TMXTiledMap::drawRegion(int srcX, int srcY, int width, int height, int destX, int destY)
{
	//宽高度检测
	if (width <= 0 || height <= 0)
		return;

	//超出屏幕检测
	width = width > _visibleWidth ? _visibleWidth : width;
	height = height > _visibleHeight ? _visibleHeight : height;

	//渲染
	SDL_Rect srcRect = { srcX, srcY, width, height };
	SDL_Rect destRect = { destX, destY, width, height};
	SDL_RenderCopy(_pRenderer, _buffer, &srcRect, &destRect);
}

drawRegion()则相对较为简单,它主要就是真正的绘制,把缓冲区的部分内容绘制到屏幕的相应位置上。

最后则是main.cpp的更新

SDL_Point getScroll(SDL_Keycode keycode)
{
	SDL_Point speed = { 0, 0 };
	if (keycode != SDLK_UNKNOWN)
	{
		switch (keycode)
		{
		case SDLK_w:
		case SDLK_UP:
		case SDLK_KP_8:
			speed.y = -5;
			break;
		case SDLK_s:
		case SDLK_DOWN:
		case SDLK_KP_2:
			speed.y = 5;
			break;
		case SDLK_a:
		case SDLK_LEFT:
		case SDLK_KP_4:
			speed.x = -5;
			break;
		case SDLK_d:
		case SDLK_RIGHT:
		case SDLK_KP_6:
			speed.x = 5;
			break;
		case SDLK_KP_3:
			speed.x = 5;
			speed.y = 5;
			break;
		case SDLK_KP_7:
			speed.x = -5;
			speed.y = -5;
			break;
		case SDLK_KP_9:
			speed.x = 5;
			speed.y = -5;
			break;
		case SDLK_KP_1:
			speed.x = -5;
			speed.y = 5;
			break;
		default:
			break;
		}
	}
	return speed;
}

首先新建一个getScroll()函数用于处理按键。

   //循环
    while(running)
    {
        frameStart = SDL_GetTicks();

        SDL_RenderClear(gRen);
        //add code here..
        //pTiledMap->draw();
		pTiledMap->fastDraw(0, 0);
        SDL_RenderPresent(gRen);
		//update
		SDL_Point speed = getScroll(keycode);
		pTiledMap->scroll(speed.x, speed.y);

		//获取事件
        while(SDL_PollEvent(&event))
        {
			switch (event.type)
			{
			case SDL_QUIT:
				running = false;
				break;
			case SDL_KEYDOWN:
				keycode = event.key.keysym.sym;
				break;
			case SDL_KEYUP:
				keycode = SDLK_UNKNOWN;
				break;
			default:
				break;
			}
        }
        frameTime = SDL_GetTicks() - frameStart;
        if (frameTime < DELAY_TIME)
        {
            SDL_Delay(int(DELAY_TIME - frameTime));
        }
    }
	//释放内存
	delete pTiledMap;
	SDL_DestroyRenderer(gRen);
	SDL_DestroyWindow(gWin);
	SDL_Quit();

    return 0;
}

接着在主循环中,tiledMap的绘制函数由draw改为fastDraw(0, 0)即可。

运行结果:

待上传。

注:

网上的卡马克教程大多是比较古老的Java ME的教程,有点只是给出了思想而没有给出具体的代码,前几天在逛csdn的时候获得了卡马克卷轴的java me的完整代码,调试了一段时间后就把java代码改成了c++和SDL代码。

java me代码:

参考文档:http://www.360doc.com/content/15/0722/14/8279768_486644348.shtml

猜你喜欢

转载自blog.csdn.net/bull521/article/details/89575109