《游戏设计之路》——给箱子注入灵魂

发布时间:2024-03-08 10:45:50 浏览量:230次

哈喽,又有好几天没见啦。前两天因为出差回家,所以文章更新慢了好多,在这里说一声抱歉啦。

回顾一下上次我们学习的内容,在上一篇文章中,我们主要了解到了一种图片的格式:dds图片,并且知道了这种格式的图片应该如何去读取并且最终成功地展示了出来。

那么我们今天就需要将之前的一些元素,如箱子,玩家,墙壁等都修改为图片来进行展示,这样我们的游戏就会有真正的画面啦。

首先还是说明一下,本系列的所有文章均属于学习《游戏开发:世嘉新人培训教材》一书的读书笔记,因此会重复使用到书中已经授权免费的代码。如果有感兴趣的读者,也可以自己去买一本来学习哦。当然,也可以看看我写的系列,我会简化书中的内容,通俗易懂的记录下知识点。

那么我们回顾一下之前的代码,所有代码都放在了一个cpp文件中,所以其实有很多地方都还可以改进,要想让我们的程序具有健壮性,可扩展性等优秀特质,我们这次就按照面向对象的思想来优化当前的代码。

一. 文件的读取

之前我们将文件读取单独封装成为了一个函数

readFile( char** buffer, int* size, const char* filename );
函数里面的代码我就不展示了
这个函数的作用主要是读取项目同级目录下,以filename命名的文件,
并将文件数据存储在buffer中,文件大小存储在size中

首先,我们需要生成一个file.h的头文件,并声明一个File类

class File{
public:
   File( const char* filename );
   ~File();
   int size() const;
   const char* data() const;
   unsigned getUnsigned( int position ) const;
private:
   int mSize;
   char* mData;
};

这样的作用是,不需要再手动去释放读取的File内存数据,增加了程序安全性。并且将getUnsigned()函数的声明包含了进来,方便直接对外提供读取dds图片指定位置的数据。

因此我们就需要有一个file.cpp来定义这个类

File::File( const char* filename ) : mSize( 0 ), mData( 0 ){
  // 与之前readFile函数一样的作用,所以只需要按照 File file("data.txt")的方式就可以直接读取data.txt中的数据了
	ifstream in( filename, ifstream::binary );
	if ( in ){
		in.seekg( 0, ifstream::end );
		mSize = static_cast< int >( in.tellg() );
		in.seekg( 0, ifstream::beg );
		mData = new char[ mSize ];
		in.read( mData, mSize );
	}
}

File::~File(){
  // 释放文件内存数据
	delete[] mData;
	mData = 0;
}

int File::size() const {
	return mSize;
}

const char* File::data() const {
	return mData;
}

//取出unsigned
unsigned File::getUnsigned( int p ) const {
  // 返回dds文件中指定位置的数据
	const unsigned char* up;
	up = reinterpret_cast< const unsigned char* >( mData );
	unsigned r = up[ p ];
	r |= up[ p + 1 ] << 8;
	r |= up[ p + 2 ] << 16;
	r |= up[ p + 3 ] << 24;
	return r;
}

二.数组模板类

在之前我们定义了一个Array2D类,主要是使用一维数组来存储2D游戏中的二维数据,因此我们也需要生成一个Array2D.h的头文件

template< class T > class Array2D{
public:
	Array2D() : mArray( 0 ){}
	~Array2D(){
		delete[] mArray;
		mArray = 0;  //将指针赋值为0是一种习惯。
	}
	void setSize( int size0, int size1 ){
		if ( mArray ){
			delete[] mArray;
			mArray = 0;
		}
		mSize0 = size0;
		mSize1 = size1;
		mArray = new T[ size0 * size1 ];
	}
	T& operator()( int index0, int index1 ){
		return mArray[ index1 * mSize0 + index0 ];
	}
	const T& operator()( int index0, int index1 ) const {
		return mArray[ index1 * mSize0 + index0 ];
	}
private:
	T* mArray;
	int mSize0;
	int mSize1;
};

这里面的声明与之前没有区别。

在这里请大家思考一下:知道为什么我们不用生成Array2D.cpp文件么?

三.图片类

这个类的主要作用就如同它的名字一样,和File类类似,提供了图片数据的加载与使用

class Image{
public:
	Image( const char* filename );
	~Image();
	int width() const;
	int height() const;
	void draw( 
		int dstX, 
		int dstY, 
		int srcX, 
		int srcY, 
		int width, 
		int height ) const;
private:
	int mWidth;
	int mHeight;
	unsigned* mData;
};

可以看到里面的构造函数与File类类似,直接给它xxx.dds名称,它就能将这个dds的数据加载进来并保存在mData中,并且使用mWidth,mHeight来记录了图片的宽度和高度。

而我们需要重点学习的便是这个draw()函数了,可以看到里面有6个参数

void Image::draw(
int destinationX, 
int destinationY, 
int sourceX, 
int sourceY, 
int width, 
int height ) const {
	unsigned* vram = Framework::instance().videoMemory();
	unsigned windowWidth = Framework::instance().width();

	for ( int y = 0; y < height; ++y ){
		for ( int x = 0; x < width; ++x ){
			unsigned* dst = &vram[ ( y + dstY ) * windowWidth + ( x + dstX ) ];
			*dst = mData[ ( y + srcY ) * mWidth + ( x + srcX ) ];
		}
	}
}

我们首先可以先花几分钟思考下这几行代码的含义,然后我会为大家讲解下具体的作用。

。。。

思考好了么?有没有理解这几行代码的含义呢?

我们来看函数的参数,其实从命名来看就可以大概理解它的意思:我们首先明确这个draw函数是在Image类中声明的,顾名思义,就知道它的作用是将图片绘制在屏幕上。因此我们需要两个坐标点:需要绘制的图片的起始坐标点(sourceX,sourceY),具体要我们绘制在哪个位置的目标坐标点(destinationX,destinationY),并且我们需要绘制多大的图片在目标位置(width,height)。

那么我们从参数就知道了这个函数的具体作用:


我用图形化方式来解读下这个函数:假如我们需要将一张图片绘制在窗口(3,1)的位置,并且只需要展示图片的上半部分,那么我们就可以这样来调用这个draw函数

draw(3,1,0,0,100,30);

然后我们来看下具体的代码,里面只有两个for循环

	for ( int y = 0; y < height; ++y ){
		for ( int x = 0; x < width; ++x ){
			unsigned* dst = &vram[ ( y + dstY ) * windowWidth + ( x + dstX ) ];
			*dst = mData[ ( y + srcY ) * mWidth + ( x + srcX ) ];
		}
	}
解读:
两个for循环控制了我们绘制的区域大小
首先,vram表示了当前我们的窗口的内存数据,windowWidth表示了当前窗口的宽度
意思就是我们需要从mData图片数据中将图片内容绘制在vram中
vram[ ( y + dstY ) * windowWidth + ( x + dstX ) ]表示当前需要绘制的像素在窗口的位置,
主要到这里假如是下一行的像素,是需要乘上窗口的宽度
mData[ ( y + srcY ) * mWidth + ( x + srcX ) ]表示取图片中哪个位置的像素数据,
这里的mWidth表示的是图片的宽度,同理,若是去图片下一行像素的数据,就需要乘上图片的宽度。
然后就是将图片中的像素数据复制给窗口指定位置的像素点数据了。

这样解读下来,是不是对这个函数就完全理解啦?

四.游戏状态类

还记得之前的State类么?里面记录的是实时的游戏数据,根据这些数据,我们在每次输入w,a,s,d之后,才能将画面更新并绘制出来。

同样先给出State.h的内容

class State{
public:
	State( const char* stageData, int size );
	~State();
	void update( char input );
	void draw() const;
	bool hasCleared() const;
private:
	enum Object{
		OBJ_SPACE,
		OBJ_WALL,
		OBJ_BOX,
		OBJ_PLAYER,

		OBJ_UNKNOWN,
	};
	
	enum ImageID{
		IMAGE_ID_PLAYER,
		IMAGE_ID_WALL,
		IMAGE_ID_BOX,
		IMAGE_ID_BOX_ON_GOAL,
		IMAGE_ID_GOAL,
		IMAGE_ID_SPACE,
	};
	void setSize( const char* stageData, int size );
	void drawCell( int x, int y, ImageID ) const;

	int mWidth;
	int mHeight;
	Array2D< Object > mObjects;
	Array2D< bool > mGoalFlags;
	Image* mImage; //图片
};

可以看到里面大部分代码和之前的一样,我们只需要关注新增的代码部分。

enum ImageID{
		IMAGE_ID_PLAYER,
		IMAGE_ID_WALL,
		IMAGE_ID_BOX,
		IMAGE_ID_BOX_ON_GOAL,
		IMAGE_ID_GOAL,
		IMAGE_ID_SPACE,
	};
Image* mImage; //图片

解读:
首先可以看到声明了一个图片类,里面包含了图片的数据以及绘制图片的函数
然后我们看到这里定义了一个图片ID枚举类,这个枚举类的作用便是该书采用了网格绘制图片的方式,
它将箱子,玩家,墙壁等图片全部放在了一个dds图片文件中,并且保证了他们的高度与宽度相同,
然后按照枚举类的定义,依此把他们横排。


mImage存储的表示上面image.dds的数据,并且其中按照枚举类的顺序,从左往右将每个元素的小图片排列在一起。所以可以看到玩家这个图片的序号便是0,墙壁的序号是1,以此类推。

下面看下状态类的其他函数,也有一些变化,这里我列出需要修改的函数部分:

void State::draw() const {
	for ( int y = 0; y < mHeight; ++y ){
		for ( int x = 0; x < mWidth; ++x ){
			Object o = mObjects( x, y );
			bool goalFlag = mGoalFlags( x, y );
			ImageID id = IMAGE_ID_SPACE;
			if ( goalFlag ){
				switch ( o ){
					case OBJ_SPACE: id = IMAGE_ID_GOAL; break;
					case OBJ_WALL: id = IMAGE_ID_WALL; break;
					case OBJ_BLOCK: id = IMAGE_ID_BLOCK_ON_GOAL; break;
					case OBJ_MAN: id = IMAGE_ID_PLAYER; break;
				}
			}else{
				switch ( o ){
					case OBJ_SPACE: id = IMAGE_ID_SPACE; break;
					case OBJ_WALL: id = IMAGE_ID_WALL; break;
					case OBJ_BLOCK: id = IMAGE_ID_BLOCK; break;
					case OBJ_MAN: id = IMAGE_ID_PLAYER; break;
				}
			}
			drawCell( x, y, id );
		}
	}
}
解读:
draw函数的变化不算太大,其中只是根据具体的位置信息,来给id赋予图片的顺序号
void State::drawCell( int x, int y, ImageID id ) const {
	mImage->draw( x*32, y*32, id*32, 0, 32, 32 );
}
解读
drawCell函数只保留一行代码了,这里可以看到绘制在draw函数中传入了每个位置的坐标信息以及
需要绘制的图片ID,然后进行了draw( x*32, y*32, id*32, 0, 32, 32 )这样的调用。
这个32是什么意思呢?其实很好理解,书中将每个元素的图片大小固定位了32*32,即玩家,墙,箱子等
这些元素在窗口中占据的像素大小就是32*32.
这里我在附上Image类中的draw函数的定义: draw(int dstX, int dstY, int srcX, int srcY, int width, int height )
其中dstX=x*32,dstY=y*32,根据之前的解读,dstX,dstY便是绘制目标起始坐标,
这里将x,y乘上32后再赋值其实是因为每个元素占据32*32的大小,
比如第一个元素从(0,0)开始绘制,那么下一个元素就要在其元素占据的位置外进行绘制了。
然后看到srcX=id*32,srcY=0,上面我们解读到图片数据是横排在一个dds文件中的,并且为他们编上了
序号,那么每个图片的起始绘制坐标,首先在Y轴上是不会变化的,都是0;在X轴上,由于是横着排列,
因此需要序号*32找到它的X起始绘制坐标。
最后两个参数就不用多说了,便是元素图片的宽度与高度,这里均为32

有没有理解上面这个drawCell函数呢?没有理解的没有关系,可以反复阅读几遍,并且结合之前Image类中的draw函数进行理解。

同样的,假如我们的元素图片大小是45*45,那么可以知道这里的32都需要修改为45.

那假如我们图片的大小是宽*高=100*60呢?那这个drawCell函数又会是怎样的呢?大家可以思考一下。

State状态类中的其他函数均没有变化,主要就是需要理解draw函数的变化以及drawCell函数的调用逻辑。

五.主函数

在主函数main.cpp中也并没有代码的新增,主要就是需要注意到文件读取的方式不再是调用readFile函数了,而是定义File类了。

最后

好啦,今天的内容就到这里啦。可以看到今天文章的内容还是很丰富的,光阅读一次可能无法完全理解,尤其是State类对Image类的调用。

但学习就是这样,不理解的地方我们就需要反复的去阅读,去思考。这样收获到的知识才会更加深刻。

如果觉得我的学习笔记系列还不错的话,可以关注我并点赞收藏转发文章三连哟(*╹▽╹*),我会继续努力为大家带来更多的高质量文章。

热门课程推荐

热门资讯

请绑定手机号

x

同学您好!

您已成功报名0元试学活动,老师会在第一时间与您取得联系,请保持电话畅通!
确定