一.瓦片地图
1)瓦片地图简介
瓦片地图(Tiled Map),又称为瓷砖地图,是在游戏开发中经常使用到的技术,它是由少量的尺寸相同的、小的瓦片图片拼接而成的很大的地图。相对于使用一张张图片来绘制地图而言,瓦片地图不仅大大地节省了内容,而且增加了图片的重用性和绘制性能。
使用一般的背景图会面临很多问题:
①:OpenGL ES对于纹理有大小限制,最大支持2048*2048像素,且超出这个范围则会无法显示。这个问题就可以使用瓦片地图来解决,因为瓦片地图是拼接而成的大图,从理论上说可以设置一个无限大的地图,故不存在此类问题。
②:地图中的有些位置是玩家不能进入的,比如说障碍,另外就是地图与角色之间的遮挡处理,如果使用整张的背景图,则需要额外的工作(图片和代码)来支持障碍处理和遮挡处理等问题;而使用瓦片地图的话,对于一般的碰撞能精确到瓦片级别,而对于遮挡处理也是能够做到。
瓦片地图的设计较为复杂,通常情况下,瓦片地图都是使用一些编辑器来完成的,这里使用的软件是tiled,tiled地图编辑器功能强大,除了能创建绘制层外,还可以创建对象层。对象层中包括了对象的相关数据,除了具有坐标、名称、类型等基本属性外,还可以额外添加任意的键值对,这些数据可以在代码中解析来达到特定的功能。瓦片地图主要负责代替背景图,以及处理碰撞。
2)解析及绘制原理
一般情况下,为使得文件占用体积变小,都会对tmx(tmx文件内部是xml格式)文件的绘制层的数据进行zlib压缩和base64加密;而在使用前时则要先解压,然后再base64解密,之后得到的数据即可在程序中使用(zlib是对数据进行压缩,base64是为了把压缩后的二进制数据可以通过ASCII字符串显式地表达出来)。
简单地说,瓦片地图就是从瓦片图片中找到对应的瓦片并绘制到相应的位置。
若新建一个瓦片地图,对应的瓦片图片为:

在上图中,每个瓦片都有一个唯一的ID,左上角为1,依次递增为1、2、3...(在绘制时需要id-1,如果id-1 == 0,则不绘制)。
创建的瓦片地图的显示和对应数据如下:

该图的数据如下:
37,29,29,29
29,24,25,26
45,32,33,34
29,40,41,42
单纯地使用瓦片地图是相对来说比较简单的,只需要解析一个外部的地图文件,或者直接写在程序里面。
瓦片地图的原理也比较简单,思路大致如下图:

总地来说,瓦片地图的基本思路就是把瓦片图中的瓦片绘制到屏幕上对应的位置。
3)示例代码
//TMXTiledMap.h
/*图块集*/
struct Tileset
{int firstGirdID;int tileWidth;int tileHeight;int spacing;int margin;int width;int height;int numColumns;std::string name;
};
Tileset结构体用来保存地图文件(*.tmx)中使用到的图块的信息,其在tmx文件的结构如下:
<tileset firstgid="1" name="blocks1" tilewidth="32" tileheight="32" spacing="2" margin="2" tilecount="198" columns="18"><image source="blocks1.png" width="614" height="376"/><tileset firstgid="199" name="blocks2" tilewidth="32" tileheight="32" spacing="2" margin="2" tilecount="198" columns="18"><image source="blocks2.png" width="614" height="376"/>
本示例的tmx文件中使用到了两个瓦片集,所以会有两个tileset标签,为了保证在一个瓦片地图中的用到的瓦片的id是唯一的,所以在tileset标签中有一个属性firstgid,它用来标识本图块的第一个瓦片的id是多少。
class TMXTiledMap
{public:TMXTiledMap(const std::string& tmxFile, SDL_Renderer* ren, int width, int height);~TMXTiledMap();void draw();private:bool initWithFile( const std::string& filepath);//解析layer中的datavoid parseTileLayer(TiXmlElement*pRoot);//解析tilesetvoid parseTilesets(TiXmlElement*pTilesetRoot);//根据id获得相应的图块集Tileset* getTilesetByID(unsigned int tileID);//根据坐标获取对应的gidint getTileGIDAt(int tileCoordinateX, int tileCoordinateY);//内部的draw,封装了SDL_RenderCopyExvoid drawTile(std::string id,int margin,int spacing,int x,int y,int width,int height,int currentRow,int currentFame);
TMXTiledMap类除了构造函数和析构函数外,目前仅有一个公有函数draw函数,它的作用就是绘制地图,其他的私有函数则是解析文件和辅助地图的绘制。
private://保存图块集std::vector<Tileset*> _tilesets;//保存地图数据std::vector<unsigned> _data;//tmx文件的宽/高瓦片个数int _mapRowTileNum;int _mapColTileNum;//tileset sizeint _tileSize;//save picturestd::map<std::string,SDL_Texture*> _textures;//rendererSDL_Renderer*_pRenderer;//可视大小,可以认为是屏幕大小int _visibleWidth;int _visibleHeight;
一维数组_data用于保存地图数据,目前仅仅用到了一个_data,因此当前的TMXTiledMap类不支持多个图层(扩展也比较简单,可以新建一个TMXLayer类,每个TMXLayer对象保存着本层的地图数据);一维数组可以用来保存二维数据,只不过在存取数据时需要一个映射(也可以使用二维数组)。
_tileSize为整型,其实瓦片的宽和高是可以不同的,不过这里为方便而使用了_tileSize同时表示瓦片的宽度和高度。
//TMXTiled.cpp
TMXTiledMap::TMXTiledMap(const std::string& tmxPath,SDL_Renderer*ren, int width, int height):_mapRowTileNum(0),_mapColTileNum(0),_tileSize(0),_pRenderer(ren),_visibleWidth(width),_visibleHeight(height)
{bool ret = this->initWithFile(txtPath);
}
构造函数除了初始化变量外,还会调用initWithFile函数来读取tmx文件并把数据赋给对应的变量。
bool TMXTiledMap::initWithFile(const std::string& filepath)
{TiXmlDocument doc;//加载tmxif(!doc.LoadFile(filepath.c_str())){std::cout<<"error:"<<doc.ErrorDesc()<<std::endl;return false;}//获得根节点TiXmlElement*pRoot = doc.RootElement();pRoot->Attribute("width",&_mapRowTileNum);pRoot->Attribute("height",&_mapColTileNum);pRoot->Attribute("tilewidth",&_tileSize);//parse the tilesetsfor(TiXmlElement*e = pRoot->FirstChildElement();e != NULL;e = e->NextSiblingElement()){std::string value = e->Value();if(value == "tileset"){parseTilesets(e);}else if (value == "layer"){this->parseTileLayer(e);}}return true;
}
tmx文件内部是xml格式,而解析tmx文件采用了第三方库tinyxml。需要注意的是,本次示例代码使用tinyxml自带的功能来加载文件,这样的使用不便于移植,若想要移植的话建议使用SDL_RWread等函数。
本示例的tmx文件内容如下:
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.2" tiledversion="1.2.3" orientation="orthogonal" renderorder="right-down" width="30" height="20" tilewidth="32" tileheight="32" infinite="0" nextlayerid="4" nextobjectid="1"><tileset firstgid="1" name="blocks1" tilewidth="32" tileheight="32" spacing="2" margin="2" tilecount="198" columns="18"><image source="blocks1.png" width="614" height="376"/></tileset><tileset firstgid="199" name="blocks2" tilewidth="32" tileheight="32" spacing="2" margin="2" tilecount="198" columns="18"><image source="blocks2.png" width="614" height="376"/></tileset><layer id="3" name="块层 1" width="30" height="20"><data encoding="base64" compression="zlib">eJzN08lKxEAQgOEOit68Org8hcsbuLyBK+6CO6LeZiau49WDD+Hu0eVF1Js7qHhxBcGDfzBCKNLdyWCLBR+hu6u7K1TSo5TqRZ8qPs4s42KizlOq3jPnPFjGtjjAIY5wbMkdxBCG8SnW5NhVVHjmcRDROpPEBjaxhW3sYBd72NfsSdvffgyIuaylt3GRtr9J4uc7a0CjpiYX/W3irma0oFVzb7S/V7jGjeHMeSxgEUtYxgoKkZw2zmxHBzrRhW5DL17xhnfrG5ljlDvGMI6JhL0vt+Sd4wKXmvVb9f2d5ZCHj48U987ynIup4QnPeInZa/ufKjkv4+nzg5wC66thzglOw9wS5kpFPcF5WZ455OH/kjvcI4MqVKMGtSgL79RZE/VMhnwxlnNyzWRdjB9T7HVpRjM//Q9qc/l+I47uS3KurGnKUutf+gLLXZ7t</data></layer>
</map
initWithFile函数的功能就是依次读取上面的标签的属性和值,并赋给对应的变量(这里的layer标签就是地图的图层,因为代码原因,目前只能有一个图层)。
initWithFile在解析标签时,碰到map的子节点tileset标签和layer标签时,会分别调用parseTileset()和parseTileLayer()函数。
void TMXTiledMap::parseTilesets(TiXmlElement*pTilesetRoot)
{//create a tileset objectTileset* tileset = new Tileset();pTilesetRoot->FirstChildElement()->Attribute("width",&tileset->width);pTilesetRoot->FirstChildElement()->Attribute("height",&tileset->height);pTilesetRoot->Attribute("firstgid",&tileset->firstGirdID);pTilesetRoot->Attribute("tilewidth",&tileset->tileWidth);pTilesetRoot->Attribute("tileheight",&tileset->tileHeight);pTilesetRoot->Attribute("spacing",&tileset->spacing);pTilesetRoot->Attribute("margin",&tileset->margin);tileset->name = pTilesetRoot->Attribute("name");tileset->numColumns = tileset->width / (tileset->tileWidth + tileset->spacing);_tilesets.push_back(tileset);//TODO:load texture在这里需要注意路径的修改std::string filePath = std::string("assets/") + pTilesetRoot->FirstChildElement()->Attribute("source");SDL_Texture*tex = IMG_LoadTexture(_pRenderer,filePath.c_str());_textures[tileset->name] = tex;
}
在parseTileset()中,除了获取用到的值之外,还会加载对应的瓦片图。注意这里的文件路径为当前路径下的assets文件夹下,可根据需要自行修改。
void TMXTiledMap::parseTileLayer(TiXmlElement*pTilesetRoot)
{std::string decodedIDs;TiXmlElement*pDataNode = nullptr;for(TiXmlElement*e = pTilesetRoot->FirstChildElement();e != NULL;e = e->NextSiblingElement()){if(e->Value() == std::string("data")){pDataNode = e;}}for(TiXmlNode*e = pDataNode->FirstChild(); e != NULL; e = e->NextSibling()){//解码数据,并用string保存TiXmlText*text = e->ToText();std::string t = text->Value();decodedIDs = base64_decode(t);}//uncompress解压uLongf numGids = _mapRowTileNum * _mapColTileNum * sizeof(int);std::vector<unsigned> gids(numGids);uncompress((Bytef*)&gids[0],&numGids,(const Bytef*)decodedIDs.c_str(),decodedIDs.size());std::vector<int> layerRow(_mapRowTileNum);//_data = gids;_data = std::vector<unsigned>(_mapRowTileNum * _mapColTileNum);//拷贝数组std::copy(gids.begin(), gids.begin() + _mapRowTileNum * _mapColTileNum, _data.begin());
}
parseTileLayer会获取到<layer>的子标签<data>中的数据,先对这些数据进行解压缩,然后再使用base64进行解密,最后把得到的数据赋给_data变量。
现在默认认为采用了zlib压缩和base64加密,如果tmx文件未压缩和加密的话,则可以省略掉上述的解压和解密过程。
Tileset* TMXTiledMap::getTilesetByID(unsigned int tileID)
{for(unsigned int i = 0;i < _tilesets.size();i++){/*这里的判断是如果tileID不在前m_tilesets.size()-1里面,就必定是最后一个*/if(i + 1 <= _tilesets.size() - 1){if(tileID >= _tilesets[i]->firstGirdID && tileID < _tilesets[i + 1]->firstGirdID){return _tilesets[i];}}else{return _tilesets[i];}}std::cout<<"did not find tileset,rerturning empty tileset\n";return nullptr;
}
getTilesetByID()函数会根据瓦片的id来获取它所对应的Tileset对象,如果未找到则返回空指针。
int TMXTiledMap::getTileGIDAt(int tileCoordinateX, int tileCoordinateY)
{if (tileCoordinateX >= 0 && tileCoordinateX < _mapRowTileNum &&tileCoordinateY >= 0 && tileCoordinateY < _mapColTileNum){int z = (int)(tileCoordinateX + tileCoordinateY * _mapRowTileNum);return _data[z];}return 0;
getTileGIDAt函数会根据提供的图块坐标来获取所对应的瓦片id。
在瓦片地图中,一般包含两种坐标:第一种是像素级坐标,这个是较为常用的坐标,无论是绘制地图,还是逻辑处理等一般都是使用的此类坐标;第二类则是图块坐标,图块坐标是以瓦片的大小为一个单位的坐标。
以上两种坐标是可以相互转换的,像素坐标除以瓦片尺寸就会得到瓦片坐标(整型的除法)。
void TMXTiledMap::drawTile(std::string name,int margin,int spacing,int x,int y,int width,int height,int currentRow,int currentFrame)
{SDL_Rect srcRect;SDL_Rect destRect;srcRect.x = margin + (spacing + width) * currentFrame;srcRect.y = margin + (spacing + height) * currentRow;srcRect.w = destRect.w = width;srcRect.h = destRect.h = height;destRect.x = x;destRect.y = y;SDL_RenderCopyEx(_pRenderer, _textures[name], &srcRect, &destRect, 0, 0, SDL_FLIP_NONE);
}
drawTile封装了一个专门用于绘制瓦片的函数:name是用到的SDL_Texture纹理名称(纹理是存储在_textures中的);marrgin和spacing是保存在Tileset中的两个属性,margin是瓦片图的外边距,而spacing则是瓦片图的内边距;currentRow、currentFrame、width和height用来控制从某行某列的尺寸为(width, height)的矩形绘制到(x,y,width,height)上,即SDL中的srcRect;x、y、width、height用来控制绘制到哪里,即SDL中的destRect。
void TMXTiledMap::draw()
{//对偏移位置进行取反SDL_Point pos = { 0, 0 };//从哪开始绘制int startX = pos.x / _tileSize;int startY = pos.y / _tileSize;//绘制到哪 多绘制一个int endX = startX + _visibleWidth / _tileSize + 1;int endY = startY + _visibleHeight / _tileSize + 1;endX = endX > _mapRowTileNum ? _mapRowTileNum : endX;endY = endY > _mapColTileNum ? _mapColTileNum : endY;//只绘制屏幕for(int i = startY;i < endY;i++){for(int j = startX;j < endX;j++){int id = this->getTileGIDAt(j, i);//0代表无图块if(id == 0){continue;}Tileset* tileset = getTilesetByID(id);id--;drawTile(tileset->name,tileset->margin,tileset->spacing,j * _tileSize,i * _tileSize,_tileSize,_tileSize,(id - (tileset->firstGirdID - 1))/tileset->numColumns,(id - (tileset->firstGirdID - 1))%tileset->numColumns);}}
}
draw函数用于完全绘制。
接着则是主函数:
#include<iostream>#include "SDL.h"
#include "TMXTiledMap.h"using namespace std;
//全局常量
const int FPS = 60;
const int DELAY_TIME = 1000/FPS;
//窗口和渲染器
SDL_Window* gWin = nullptr;
SDL_Renderer* gRen = nullptr;bool init();
SDL_Point getScroll(SDL_Keycode keycode);int main(int argc,char** argv)
{//地图显示TMXTiledMap* pTiledMap = nullptr;//Uint32 frameStart = 0, frameTime = 0;SDL_Event event;bool running = true;SDL_Keycode keycode = SDLK_UNKNOWN;//初始化SDL环境if (init()){cout << "初始化成功" << endl;}else{return -1;}pTiledMap = new TMXTiledMap("assets/map1.tmx",gRen, 640, 480);//循环while(running){frameStart = SDL_GetTicks();SDL_RenderClear(gRen);//add code here..pTiledMap->draw();SDL_RenderPresent(gRen);//update//获取事件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对象,之后开始进入游戏循环。
bool init()
{//初始化SDLif (SDL_Init(SDL_INIT_EVERYTHING) == 0){if((gWin = SDL_CreateWindow("TileMapTest", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,640, 480,SDL_WINDOW_SHOWN)) == NULL){cout<<"error:"<<SDL_GetError()<<endl;return false;}else if((gRen = SDL_CreateRenderer(gWin, -1,SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC | SDL_RENDERER_TARGETTEXTURE)) == NULL){cout<<"error:"<<SDL_GetError()<<endl;return false;}SDL_SetRenderDrawColor(gRen,210,250,255,255);}return true;
}
运行结果如下: