SDL游戏教程第七课 地图 (Maps)
本系列教程来自Dev Hub。
英文原文地址: http://www.sdltutorials.com/sdl-maps/
就像我上一次的课程陈述的那样,我们将要去看一看怎样去做一个基于块(tile based)的地图类。除了简单的地图,我们将要用地图去创建许多区域。虽然我们可以创建一个巨型的地图,但管理许多小地图跟容易,并且这样也使得制作块地图(tiling maps)变成可能。顺便说一句,跳到SDL Image tutorial,如果你还没有学习这个课程。我们将要停止使用SDL_LoadBMP,转而使用SDL Image。不说那么多废话了,让我们开始把。
已知的Bug: 在本课程里,镜头设定反了,但在SDL 碰撞检测 课程里是正常的。本课程里还没有跟正这个错误,要查看修正后的内容,看看SDL 碰撞检测 的课程。
我们将需要准备若干文件, Define.h, CArea.h, CArea.cpp, CMap.h, CMap.cpp, CTile.h, 和 CTile.cpp。因此,创建这些空文件。我要得到一个区域的对象,这个区域对象将会有许多的子地图被存在一个vector容器里(就像许多实体在一个vector容器里一样)。在这些地图对象里,每个地图对象各自有一个小地图快的vector容器。一大片的地图块组成了地图,并且一大片的地图组成了一个区域。
让我们从Define.h开始吧!这个文件将要存一些预定义常量,比如说地图的宽和高,屏幕的宽和高,和块的大小。这些值将会是我们制作地图和其他的东西要用的。把下列面的代码加到define.h里:
#define _DEFINE_H_
#define MAP_WIDTH 40
#define MAP_HEIGHT 40
#define TILE_SIZE 16
#define WWIDTH 640
#define WHEIGHT 480
#endif
我们等一会回来看看这些值的是什么意义,WWIDTH 和 WHEIGHT 是SDL_SetVideoMode用到的值。所以打开CApp_Init,改变这个函数像下面这样:
return false;
}
很简单吧。接下来,打开CTile.h。这个类将在地图上定一个块。在游戏术语里,一个块就是一个块我们画在屏幕上的图形。回忆一下那个动画课程,每一帧Yoshi都可以被认为是一个块。因此当我们说一个地图有许多块组成,我们就在使用这些块并在一序列的格子里重复。
因为我们有整个一个图像包含了我们需要的所有的块,我们只需要加载一个图像,而不是每个块一个图像。每个块因此需要有一些属性。最明显的是那个块要被使用。让我看一下一个地图块的例子:
你会注意到在最做上角有一个为0的ID,往左边增加了1。这个被叫做Tile ID。通过使用这个ID,和每个块的大小(TILE_SIZE),我们可以知道那个块要画在屏幕上。接下来一个比较重要的东西是TypeID。这个东西决定块是什么类型的。一些例子也许会,不可以见(块不画出来),障碍物(像一堵墙),或者是地面(玩家可以在上面走动)。你可以定义任何你想要的TypeID,但这是最基本的(其他一些例子可以是水,火,冰,等等)这是很基本的,这个块类仅仅有两个成员(对于现在,接下来它将会包含动画块的信息)。那么让我们,看看CTile.h里面吧:
#define _CTILE_H_
enum {
TILE_TYPE_NONE = 0,
TILE_TYPE_NORMAL,
TILE_TYPE_BLOCK
};
class CTile {
public:
int TileID;
int TypeID;
public:
CTile();
};
#endif
注意大正在被使用的枚举类型没,这让我们对TypeID同时进行赋值或检查。如果我用TypeID == TILE_TYPE_BLOCK,我可以注意到在我的代码里我在检测一个块的类型。TypeID == 2 不是那么好理解。如果你从来没有用过枚举类型,就把他们当做常量好了。常量值是不能改变。开始的这些值也定义了其他的东西,注意到没有,我把TILE_TYPE_NONE的值定义为0。剩下的这些量会自动被以1为增量的值依次赋值。
现在,打开CTile.cpp,完成下面的代码:
CTile::CTile() {
TileID = 0;
TypeID = TILE_TYPE_NONE;
}
很好。接下来,让我们看一下做一些地图吧。首先我们需要考虑一下我们的地图,一个文本文件。因此我们需要拿出一个文件格式来,那样women可以很容易的在程序外编辑我们的地图。也许以后你可以创建一个地图编辑器。
每个地图都有宽和高,块的数目。因此一个10X10,将会有100个地图块。 我们将会把我们所有的地图都做成宽和高都是相同的。因此我们不需在地图定义它们(MAP_WIDTH 和 MAP_HEIGHT)。我们需要增加的部分是为每个单独的块增加TileID和TypeID。我们将要使用我开始拿出来的文件格式,并且相对来说比较简单。看看5X5的地图例子:
0:0 0:0 0:0 0:0 0:0
1:0 1:0 1:0 0:0 0:0
1:0 1:0 1:0 0:0 0:0
1:0 1:0 1:0 0:0 0:0
1:0 1:0 1:0 0:0 0:0
每个在文件里的块包括0:0,有效的TitleID:TypeID。一个空格是块与块之间的分割符。 概念性的地图绘制会像这样:
对于本课程我们将会使用40X40的地图, 那是一个不错的大小。为了不让你去拷贝地图例子,打开下面的连接:
首先,创建一个一个文件夹和你的EXE文件相同文件名:maps。这个文件夹是所有你的地图存放的地方。以1.map的文件名保存地图。再创建另外一个叫做tilesets文件夹在你EXE文件所在的同一个目录下面,以1.png的文件保存。
现在,我们有了地图的文件格式了,让我们开始设计绘制类吧。打开CMap.h:
#define _CMAP_H_
#include <SDL.h>
#include <vector>
#include "CTile.h"
#include "CSurface.h"
class CMap {
public:
SDL_Surface* Surf_Tileset;
private:
std::vector<CTile> TileList;
public:
CMap();
public:
bool OnLoad(char* File);
void OnRender(SDL_Surface* Surf_Display, int MapX, int MapY);
};
#endif
一些基本的函数再这里,OnLoad和OnRender。OnLoad就像你期望的那样,从一个文件里加载地图, 构成TileList。OnRender也像你期望的那样,把每个块放到屏幕并且用Surf_Tileset绘制它们。让我们定义者秀儿函数怎么做吧,现在打开CMap.cpp:
CMap::CMap() {
Surf_Tileset = NULL;
}
bool CMap::OnLoad(char* File) {
TileList.clear();
FILE* FileHandle = fopen(File, "r");
if(FileHandle == NULL) {
return false;
}
for(int Y = 0;Y < MAP_HEIGHT;Y++) {
for(int X = 0;X < MAP_WIDTH;X++) {
CTile tempTile;
fscanf(FileHandle, "%d:%d ", &tempTile.TileID, &tempTile.TypeID);
TileList.push_back(tempTile);
}
fscanf(FileHandle, ""n");
}
fclose(FileHandle);
return true;
}
void CMap::OnRender(SDL_Surface* Surf_Display, int MapX, int MapY) {
if(Surf_Tileset == NULL) return;
int TilesetWidth = Surf_Tileset->w / TILE_SIZE;
int TilesetHeight = Surf_Tileset->h / TILE_SIZE;
int ID = 0;
for(int Y = 0;Y < MAP_HEIGHT;Y++) {
for(int X = 0;X < MAP_WIDTH;X++) {
if(TileList[ID].TypeID == TILE_TYPE_NONE) {
ID++;
continue;
}
int tX = MapX + (X * TILE_SIZE);
int tY = MapY + (Y * TILE_SIZE);
int TilesetX = (TileList[ID].TileID % TilesetWidth) * TILE_SIZE;
int TilesetY = (TileList[ID].TileID / TilesetHeight) * TILE_SIZE;
CSurface::OnDraw(Surf_Display, Surf_Tileset, tX, tY, TilesetX, TilesetY, TILE_SIZE, TILE_SIZE);
ID++;
}
}
}
Bug 修正:这里有一个循环的continue BUG,我们因该在执行continue语句之前对ID递增1。谢谢Zakalwe帮助找到了这个BUG。
让我们从最上面的开始吧。非常明显,构造函数将Tileset设置为NULL(空) 。接下来我们有了OnLoad函数。首先,我们清楚任何老的块,如果不那样的话就会加在两次,但没有两倍的块,不能有效的加载新地图。当我们打开了一个FileHandle(文件句柄),尝试着去打开需要加载的地图。现在,我们遍历地图文件并且取出每个块。最内层的循环是X坐标的循环,从最左边的块到最右边的块。注意一下这个循环是做什么的,因为它也被用在OnRender函数里。整个循环从最左上面的块遍历到最右底下的的块。一次一个快。在这两个循环里我们创建了临时块,并且加载文件信息到里面去。我们然后把这个块存到我们的TileList里。我们关闭这个文件句柄,那么我们完成了。
接下来,我们做一个绘制地图的函数。注意到了MapX和MapY参数。这两个参数告诉绘制地图的函数在屏幕的那个位置绘制。这个是为以后更方便的移动地图。函数一开始,我们检查tileset的有效性。因为我们要直接访问tileset并且不想引起崩溃。我们然后从块结构里读取TilesetWidth和TilesetHeight。这是很重要的,因为我们需要知道一个tileset包含有多少个块,不是它实际的宽和高。那种方式我们可以通过TileID匹配一个tileset。因此,一个包含了宽和高的Tileset,2X2将会有4个块在块集里,但实际上有32X32的像素。因此,一个TileID是0匹配的是第一个块,一个TileID是1将会是接下来的一个,以此类推。TileID在下一行从左到右继续重复。
OK,接下来我们再一次看看这个循环,遍历每个块(请注意在循环里的X和Y坐标也是指块的数量,不是指实际的像素)这一次,不管怎样我们定义了一个ID变量。这个ID每趟循环递增1,并且允许我们都去在地图里的没一个块。因此,我们通过TypeID首先检查这个块是不是要绘制。如果块是TILE_TYPE_NONE,我们跳过这个块并继续。之后我们计算出要在屏幕的哪个位置绘制这个块。这是通过用MapX和MapY来实现(有效的偏移坐标,因为他们从0,0偏移到地图的某一位置,就像是让地图移动一样),并且在循环里处理X和Y坐标。我们不得不转换这些X和Y坐标回像素坐标,通过乘以每个块的大小来实现。
除开那些,我们要对Tileset做一些事情了。我们要做的事情是计算在Tileset里得到合适的块。这是先通过获取块的TileID,然后转换到一个块的坐标来完成的。在这里给一点小小的解释吧。我们有2X2的块集,一个为1的TileID。计算出x坐标,我们会1%2,还是1。当我们把那个数乘以TILE_SIZE,我们得到了16。这个是那个块的正确坐标。同样的对于Y,我们是让1/2,得到0.5。由于这个是一个整数操作,.5被自动舍弃了。因此,我们只得到了0。同样是正确的行坐标。现在,我们有一个TileID是2的块。2%2=0, 和2/2=1。看看X是怎样重复的?0,1,0,1...并且,注意Y是怎样增长当它每一次穿过Tileset的Width的?0,0,0,1,1。我希望我的解释是清晰的,这些内容有时有点不好解释。
接下来,我们要实际的把块绘制到屏幕上通过使用我们刚才计算好的坐标,然后增加ID来访问下一个块。这里有一小点需要注意,我们可以为了追求速度,创建一个OnRender_Cache函数来执行同样的操作,但是会在CMap类定义的Surface里绘制图形。比如SDL_Surface* Surf_Map。然后,只有OnRender函数直接在Surf_Map里渲染操作。但也注意一下,那个方法在以后的工作里是没有呢必要的当我们想要让块动起来。
OK, 太酷了!这仅仅是一小点要一起做的事情,但请不要烦躁! 那真的不是很糟糕当你掌握了这些事情以后。我现在想要说我们可很好的创建地图对象并且绘制地图。这个些东西,非常适合于像Mario和Megaman这样的游戏,因为这样的游戏只有一个大地图。但是对像Zelda和Metroid这样的游戏,这种方式就不太适合了。这就是为什么要引入Areas来了。就像地图文件有格式一样,每个区域有它自己的文件和文件格式。每个区域有一个块集要加载,这个区域的尺寸,和区域里的地图也要被加载。下面是我们要使用的区域:
./tilesets/1.png
3
./maps/1.map ./maps/1.map ./maps/1.map
./maps/1.map ./maps/1.map ./maps/1.map
./maps/1.map ./maps/1.map ./maps/1.map
保存这些内容到一个叫做1.area的文件里, 并且保存到你的maps目录下面。就像地图,这个区域会是块地图。我不会在详细解释了因为我认为你现在应该有了主意了。
打开CArea.h并且:
#define _CAREA_H_
#include "CMap.h"
class CArea {
public:
static CArea AreaControl;
public:
std::vector<CMap> MapList;
private:
int AreaSize;
SDL_Surface* Surf_Tileset;
public:
CArea();
bool OnLoad(char* File);
void OnRender(SDL_Surface* Surf_Display, int CameraX, int CameraY);
void OnCleanup();
};
#endif
这个类将会和Map类在某些方面有些类似,在另一些地方又有不同。类似的,我们让MapList来存储我们的地图,就像Map类有一个块的列表一样。当然,也有Load和Render函数。现在说说不同的地方吧。我们定义了一个静态的AreaControl,很像Entities有一个静态控制变量一样。这样就允许我们用这个对象,在任何一个类里操作一块区域(Area)。接下来,我们定义了AreaSize,代表了地图的数量。我们假定区域总是正方形的,因此AreaSize为3,代表的是3X3的区域。如果你想要,但我认为是不必要的,你也可以定义AreaWidth和AreaHeight。接下来,我们为Tileset定义了一个表面(surface)。你也许注意到了在CMap类里,我们实际上从来不在里面加载地图。那是因为Area为我们做那件事情,然后在传那个加载地图的指针到CMap类。那中方式下,每个地图不自己为自己加载一个块集(tileset),但是事实上所有的地图共享同一个块集(tileset)。另一方面,如果你想要每个地图有它自己的块集,你可以很容易的修改这个类以达到这个目的。
OK, 现在打开CArea.cpp:
CArea CArea::AreaControl;
CArea::CArea() {
AreaSize = 0;
}
bool CArea::OnLoad(char* File) {
MapList.clear();
FILE* FileHandle = fopen(File, "r");
if(FileHandle == NULL) {
return false;
}
char TilesetFile[255];
fscanf(FileHandle, "%s"n", TilesetFile);
if((Surf_Tileset = CSurface::OnLoad(TilesetFile)) == false) {
fclose(FileHandle);
return false;
}
fscanf(FileHandle, "%d"n", &AreaSize);
for(int X = 0;X < AreaSize;X++) {
for(int Y = 0;Y < AreaSize;Y++) {
char MapFile[255];
fscanf(FileHandle, "%s ", MapFile);
CMap tempMap;
if(tempMap.OnLoad(MapFile) == false) {
fclose(FileHandle);
return false;
}
tempMap.Surf_Tileset = Surf_Tileset;
MapList.push_back(tempMap);
}
fscanf(FileHandle, ""n");
}
fclose(FileHandle);
return true;
}
void CArea::OnRender(SDL_Surface* Surf_Display, int CameraX, int CameraY) {
int MapWidth = MAP_WIDTH * TILE_SIZE;
int MapHeight = MAP_HEIGHT * TILE_SIZE;
int FirstID = -CameraX / MapWidth;
FirstID = FirstID + ((-CameraY / MapHeight) * AreaSize);
for(int i = 0;i < 4;i++) {
int ID = FirstID + ((i / 2) * AreaSize) + (i % 2);
if(ID < 0 || ID >= MapList.size()) continue;
int X = ((ID % AreaSize) * MapWidth) + CameraX;
int Y = ((ID / AreaSize) * MapHeight) + CameraY;
MapList[ID].OnRender(Surf_Display, X, Y);
}
}
void CArea::OnCleanup() {
if(Surf_Tileset) {
SDL_FreeSurface(Surf_Tileset);
}
MapList.clear();
}
很快的,从上面的开始,我们声明了静态对象,然后将AreaSize设成0。然后我们定义了Load函数。它就像是在CMap里的Load函数一样,除了有一小点不同。我们要先加载tileset。我们试着加载块集到表面(surface)里,并且如果失败了返回false。我们然后加载Area的尺寸。OK,我们有了两个循环,就像在刚才在操作地图一样,会遍历并且读取每个地图的文件名。然后它会创建一个地图对象,加载地图,设置好块集准备使用,然后push到列表里。这是非常简单和直接的。
接下来,我们定义了Render函数。我们先计算实际的地图的宽和高以像素为单位。这可以让我们找到第一个要被绘制的地图。一些事情我要首先试着去做一些说明吧。由于一块区域可以是任意尺寸的,就像100X100的地图,我们不想处理所有的麻烦事并且绘制每个单独的地图。我们仅仅想绘制在屏幕区域可见的地图。对于我们的屏幕尺寸,640X480,一次只能有4幅地图是可见的(就像在角上的4幅地图)。因此我们要做的事情是计算第一个要获取的Map ID。接下来是绘制第一个地图。从第一个ID,我们知道接下来要绘制的3幅地图。第一幅地图右边临近的一幅地图,第一幅地图底下的一幅地图,和第一幅地图右下角的地图。那是怎样做到的?让我们看看下面的图像:|
我们画了一个家伙,圆圈,在屏幕的正中央,用红方块表示。其他方块是每个地图,并且我们有一个镜头坐标为-700, -700。为什么是负方向?想想,屏幕自己不会正真的移动,所有其他的东西在动。所以,对于一个物体要向上移动,它必须沿着Y的负方向上增加坐标。对于X 坐标也是同样的。因此,为了到达第4幅地图,我们我们必须在负方向上移动。现在,注意标了灰色的地图,这些地图在用户视野里是不可见,所以它们不需要被绘制。找出第一个ID,在这种情况下是4,我们要用它来指定镜头坐标。我们转换这些镜头坐标到地图坐标。-(-700)/640(40*16, 回忆一下MapWidth的计算方法)我们达到了1(舍去小数)。这是在地图上的X坐标,但是我们还没有做完。我们然后计算在地图上的Y坐标,同样的-(-700)/640,但是我们把它乘以AreaSize。这是因为我们在用ID。所以,它变成了1*3, 就是3,并且加到第一次计算得到的1上,我们得到了4. 那么,你应该知道了在地图上的ID是怎么回事了吧。
OK, 这四幅地图我们都遍历了。由于我做了一个i < 4的循环,我们需要指明怎样加到第一个ID上去,真正的比标示出这四幅地图的ID。这首先是通过把第一个ID作为一个偏移。我们然后用i,我们在循环里的位置,处以2然后乘以区域的尺寸。这做了些什么呢?很想地图类,它创建了一个模式,0,0,1,1。同样的是使用了i%2的操作,创建了一个模式0,1,0,1。这给出了我们正确的模式,4,5,7,8。正好是要被绘制的地图。
我们做一点点检查以保证ID是好的,因为ID也许是不存在的。并且现在最后的计算(是的,我知道,一大堆的复杂计算)。它就像是我们计算如何从块集里获取每个块一样去做。它把一个ID转换成实际的像素坐标,然后偏移得到坐标通过使用镜头(使得它看起来像在移动)。最后我们绘制地图,传绘制的坐标过去以便去绘制它。
最后,我们定义了我们的清除函数,释放表面和地图。
多么棒的一个加载!我希望所有这些多种多样的计算没有让你迷惑。仅有2件基本的事情我们正要尝试去做,转换像素坐标到基于块的格子坐标。就和块那样,地图也是这样的。其他的事情是转换基于块的格子坐标回ID。
OK, 我们还没完成彻底的完工,仍然还有一些事情要去做(现在你看看到了为什本课程花了这么长的时间才完成!)。你也许注意到了,我把在CArea类里的Render函数的后两个参数叫做CameraX和CameraY。我们将要去做一个镜头类(camera class),它定义了可见的区域。这是我们将要去操纵移动地图的地方。
那么,我们创建2个新文件,CCamera.cpp和CCamera.h。先打开头文件:
#define _CCAMERA_H_
#include <SDL.h>
#include "Define.h"
enum {
TARGET_MODE_NORMAL = 0,
TARGET_MODE_CENTER
};
class CCamera {
public:
static CCamera CameraControl;
private:
int X;
int Y;
int* TargetX;
int* TargetY;
public:
int TargetMode;
public:
CCamera();
public:
void OnMove(int MoveX, int MoveY);
public:
int GetX();
int GetY();
public:
void SetPos(int X, int Y);
void SetTarget(int* X, int* Y);
};
#endif
首先,我们定义了一个控制成员对象,就像区域对象一样。然后我们定义了镜头在那里的坐标。我仍了一个额外的能力进去,那是聚焦物体的能力。例如,在Megaman游戏里镜头会聚焦Megaman自己。在那种情况下,当Megaman移动了镜头将会被自动的更新。因此,我们有两个指针,分别对应X坐标和Y坐标。如果这个两个指针是null,镜头会自动的回到镜头的位置。接下来,我们定义一下目标模式,对于现在,只有正常模式(镜头会在目标的做上角)或者是中心模式(将会让镜头指向目标的中心)。我认为这非常的简单。
我们然后定义了一些函数,第一个是OnMove,将会通过使用MoveX和MoveY来增加镜头的X和Y坐标。因此OnMove(0, -1)将会把镜头向上一个像素。然后我们定义了坐标的Get函数,并且能够去设置坐标和设置目标。
OK,现在打开CCamera.cpp:
CCamera CCamera::CameraControl;
CCamera::CCamera() {
X = Y = 0;
TargetX = TargetY = NULL;
TargetMode = TARGET_MODE_NORMAL;
}
void CCamera::OnMove(int MoveX, int MoveY) {
X += MoveX;
Y += MoveY;
}
int CCamera::GetX() {
if(TargetX != NULL) {
if(TargetMode == TARGET_MODE_CENTER) {
return *TargetX - (WWIDTH / 2);
}
return *TargetX;
}
return X;
}
int CCamera::GetY() {
if(TargetY != NULL) {
if(TargetMode == TARGET_MODE_CENTER) {
return *TargetY - (WHEIGHT / 2);
}
return *TargetY;
}
return Y;
}
void CCamera::SetPos(int X, int Y) {
this->X = X;
this->Y = Y;
}
void CCamera::SetTarget(int* X, int* Y) {
TargetX = X;
TargetY = Y;
}
从上到下,像我们以前一样,我们让静态的成员放在前面。我们然后让构造函数对一些成员变量赋初始值。就像我先前说的那样,OnMove函数会增加X和Y。然后我们定义GetX函数。它会检测看看我们是否有一个有效的目标,如果是有效的那么返回目标的坐标作为镜头的坐标,如果不是那么返回镜头的X坐标。对于中心模式,我们把屏幕的坐标处以2来寻找到屏幕的中心,然后从目标的坐标减去它。这将会让镜头对那个目标聚焦。我们然后定义了SetPos和SetTarget函数,它们的代码已经就能解释清楚了它们的功能了。
那么让我们把这些杂乱的东西整理到一起吧。打开CApp.h修改它使之包含新的头文件:
#include "CArea.h"
#include "CCamera.h"
再增加下面一个新的函数原型:
我们将要去检查让我们用键盘来移动地图的事件。那么,打开CApp_OnEvent.cpp增加下面的函数:
switch(sym) {
case SDLK_UP: CCamera::CameraControl.OnMove( 0, 5); break;
case SDLK_DOWN: CCamera::CameraControl.OnMove( 0, -5); break;
case SDLK_LEFT: CCamera::CameraControl.OnMove( 5, 0); break;
case SDLK_RIGHT: CCamera::CameraControl.OnMove(-5, 0); break;
default: {
}
}
}
我们检查SDL 的按键状态,根据所按的键决定镜头的移动方向。接下,打开CApp_OnCleanup.cpp并且增加区域的cleanup函数:
打开CApp_OnRender.cpp并且也增加渲染调用:
注意,我们传递镜头的坐标到了OnRender函数。最后,打开CApp_OnInit.cpp:
return false;
}
SDL_EnableKeyRepeat(1, SDL_DEFAULT_REPEAT_INTERVAL / 3);
SDL_EnableKeyRepeat调用设置键盘的字符重复率。因此当我们按住一个键盘上的按键的时候,按键消息是被持续的发到的。但我们已经完成了。编译这些代码并且运行调试它。我希望那可以以期望的方式工作,这是一个非常长的课程。如果你有一些麻烦的话,请检查一下面的课程文件。并请让我知道如果由于某种原因,我漏掉了一些重要的代码。
SDL Maps - Tutorial Files:
Win32: Zip, Rar
Linux: Tar (Thanks Gaten), Binary (Thanks Thomas)