经典的黑客帝国的开头,有一波非常酷炫的数字雨特效,漆黑的背景加上绿色的数字看起来给人一种高大上的感觉,在开发小游戏之前,咱们先用这个数字雨练练手,熟悉一下流程。

1. 需求分析

首先咱们分析一下特效的样式,从中抽取出数字雨的基础结构。数字雨本身是由若干个字符串构成,每个字符串从窗口上方的不同水平位置开始逐步下落,且字符不断的发生变化,直到超出屏幕外为止。每个字符串的速度可能有所不同,字符串的亮度从上到下一次增加。

注意观察我在文字中的粗体部分,它们包含了数字雨特效的属性和动作。在以后的开发中,我们都会先进行分析操作,然后根据分析的内容定义数据结构,它们将用来保存游戏中的各种属性,这些属性一般表达的都是游戏中的变量,再有就是更改这些变量的操作,会被定义为函数。

数据的操作一般可以分为两种,一种是数据的更新操作,可以是鼠标的一次点击事件,也可以是某项数据达到了特殊阈值,更新操作主要体现在写上面,并且更新操作一般不会涉及到任何显示代码。第二种就是数据的显示操作,显示操作本身和更新操作是相互独立的,它们之间的交流全部通过游戏的属性,也可以说是保存这些属性的数据结构,整个架构看起来有点类似于 MVC 那种模式,简单起见可以认为更新和显示分别对应的数据的写与读。

2. 数据结构

说了这么多,先让我们根据上面的需求分析来定义数字雨的数据结构。先定义数字雨中的基本单位字符串:

#define MAX_STRING_LEN 255

//字符串
typedef struct String
{
	char list[MAX_STRING_LENGTH];	  //字符列表
	int col;						//屏幕中的第几列
	int y;							//字符串的纵坐标
	int vy;							//字符串的速度
	int start;						//起始位置
	int count;						//字符串字符个数
} String;

因为数字雨的核心是下落的字符串,所以字符串的属性较多,包括字符串包含的所有字符,这里用一个数组表示,虽然浪费了内存,但是使用起来比动态分配要方便得多。剩下的分别是字符串在屏幕的水平列数、当前字符串的纵坐标、下落的速度和当前字符串中字符的起始位置以及当前字符串中字符的个数。

有了字符串的结构体,我们就可以组建数字雨程序本身:

//数字雨
typedef struct DigitalRain
{
	TTF_Font* pFont;					//数字雨字体
	int chW, chH;						//字体宽高
	int winW, winH;						//窗口宽高
	String* pCols;						//字符串列
	int count;							//当前字符串数
} DigitalRain;

数字雨中的成员包括绘制数字需要的字体指针、字体的宽高、窗口的宽高、字符串的所有列以及字符串的总数量。

事实上这些属性并不是一次性定义完成的,而是编写程序的过程中逐步完善得出的,所以不要想一下子把所有的成员全部确定,逐步迭代完善才是现实。

3. 初始化

定义好数据结构,接下来我们开始定义操作这些数据结构的函数。其它先不去分析,先把初始化函数定义出来。

//初始化数字雨
DigitalRain* initDigitalRain(int width, int fontSize);

为了简化操作,程序对外的接口只提供了数字雨程序本身的初始化操作,对于字符串的初始化定义为静态成员函数,并不会提供给外部调用:

//初始化字符串
static void initRandString(String* s, int col, int y);

这里先介绍一下字符串初始化。字符串初始化主要是将字符串的所有字符随机生成,并填充当前字符串的位置,包括字符串所在列(不是水平坐标)和字符串的垂直坐标。字符的随机生成可以使用下面的语句:

(rand() % ('~' - '!') + '!')

上面的随机预计可以得到所有 ASCII 码的可见字符,因为 ~ 是最后一个可见字符,而 ! 则是首个可见字符,取模后增加可以得到它们之间的某个随机字符,这是一种常见的随机区间取值用法。

除了上面这些,这里还需要注意二两个成员:start 和 list。list 可以看作是一个环形队列,start 可以看作是队列的队首,这个队首在显示的时候被绘制到窗口的上方,换句话说,在窗口上显示的字符串,从上到下,一次显示队列的成员,从队首到队尾。

整个字符串的初始化过程如下:

//初始化字符串
static void initRandString(String* s, int col, int y)
{
	if (s)
	{
		s->vy = rand() % (MAX_SPEED_Y - MIN_SPEED_Y) + MIN_SPEED_Y;
		s->y = y;
		s->col = col;

		s->start = 0;
		s->count = rand() % (MAX_STRING_LENGTH - MIN_STRING_LENGTH) + MIN_STRING_LENGTH;
		for (int j = 0; j < s->count; j++)
		{
			s->list[j] = (rand() % ('~' - '!') + '!');
		}
	}
}

而数字雨结构体的初始化也是类似,只不过增加了字体的创建和内存的分配等基本内容:

//初始化
DigitalRain* initDigitalRain(int width, int height, int fontSize)
{
	//创建字体
	SDL_RWops* rwOps = getResourceData(NULL, MAKEINTRESOURCE(IDR_DEFAULT_FONT), RT_FONT);
	if (rwOps == NULL) {
		return NULL;
	}

	TTF_Font* font = TTF_OpenFontRW(rwOps, 1, fontSize);
	if (font == NULL) {
		return NULL;
	}


	//创建结构体
	DigitalRain* dr = malloc(sizeof(DigitalRain));
	if (dr == NULL) {
		TTF_CloseFont(font);
		return NULL;
	}

	//求字符宽度
	char* chW = "W";
	TTF_SizeText(font, chW, &(dr->chW), &(dr->chH));


	//初始化数据
	dr->pFont = font;
	dr->winW = width;
	dr->winH = height;
	dr->count = width / dr->chW;
	dr->pCols = malloc(sizeof(String) * dr->count);
	if (dr->pCols)
	{
		for (int i = 0; i < dr->count; i++)
		{
			initRandString(&(dr->pCols[i]), i, -dr->winH);
		}
	}

	return dr;
}

这里因为用的是等宽字体,所以字体的宽度只计算了其中的一个字符,如果不是等宽字体,理论上应该将字符的宽度设为所有可见字符的最大值为好。

4. 反初始化

初始化之后不要忘记销毁操作。销毁的过程就不赘述了,直接上代码:

//反初始化
void uninitDigitalRain(DigitalRain* dr)
{
	if (dr == NULL) {
		return;
	}

	if (dr->pCols) {
		free(dr->pCols);
		dr->pCols = NULL;
	}

	if (dr->pFont) {
		TTF_CloseFont(dr->pFont);
		dr->pFont = NULL;
	}

	free(dr);
}

5. 更新逻辑

有了已经初始化的数据,接下来就是重要的更新逻辑。这里更新包括几个方面:

  • 更新所有字符串下落的位置。
  • 更新所有字符串最新的字符。
  • 删除窗口之外的字符串,并创建新的字符串。

首先,更新字符的位置非常简单,就是当前的位置加上速度即可:

//更新字符串下落位置
dr->pCols[i].y += dr->pCols[i].vy;

而之所以更新字符串的最新字符是因为每次字符串下落的时候,字符串的队首都会插入新的字符,而队尾则会删除旧的字符。这个操作可以用下面的语句描述:

//DigitalRain* dr;
//删除字符串最下面的旧字符,插入最上面的新字符
int idx = ((dr->pCols[i].start - 1) + MAX_STRING_LENGTH) % MAX_STRING_LENGTH;
dr->pCols[i].list[idx] = (rand() % ('~' - '!') + '!');
dr->pCols[i].start = idx;

最后一个删除越界的字符串,创建新的字符串可以合并为一条语句:

if (dr->pCols[i].y - (dr->pCols[i].count * dr->chH) > dr->winH) {
	initRandString(&(dr->pCols[i]), i, -dr->winH);
}

整个函数代码如下:

//更新
void Update(DigitalRain* dr)
{
	if (dr == NULL) {
		return;
	}

	//枚举所有列
	for (int i = 0; i < dr->count; i++)
	{
		//更新字符串下落位置
		dr->pCols[i].y += dr->pCols[i].vy;

		//删除字符串最下面的旧字符,插入最上面的新字符
		int idx = ((dr->pCols[i].start - 1) + MAX_STRING_LENGTH) % MAX_STRING_LENGTH;
		dr->pCols[i].list[idx] = (rand() % ('~' - '!') + '!');
		dr->pCols[i].start = idx;

		
		//如果字符串已经越界
		if (dr->pCols[i].y - (dr->pCols[i].count * dr->chH) > dr->winH) {
			initRandString(&(dr->pCols[i]), i, -dr->winH);
		}
	}
}

6. 显示画面

显示画面有两个细节需要注意,一个是字符串的最下方的字符,也就是上面提到的队尾,显示的颜色是白色,而从下到上的其它字符,颜色一次降低,代码表示如下:

SDL_Color color = { 0, 255, 0, 255 };
if (j == s->count - 1) {
	color.r = 255;
	color.g = 255;
	color.b = 255;
} else {
	color.r = 0;
	color.g = 255 / s->count * j;
	color.b = 0;
}

其中 j 表示字符串中字符的索引,而 s->count 则表示字符串的长度,255 / s->count * j 会将字符从下到上一次变暗。

第二个注意的地方是在显示字符的时候,需要获取当前显示字符的索引位置,这个位置需要取模,防止越界:

s->list[(s->start + j) % MAX_STRING_LENGTH]

注意上面所讲的内容,其它的代码基本没什么难度,就是简单字体绘制而已,参考代码如下:

//绘制
void Draw(DigitalRain* dr, SDL_Renderer* renderer)
{
	if (dr == NULL || renderer == NULL) {
		return;
	}

	
	//枚举所有列
	for (int i = 0; i < dr->count; i++)
	{
		String* s = &(dr->pCols[i]);

		//窗口之外
		if (s->y < -(s->count * dr->chH)) {
			continue;
		}

		//枚举字符串所有字符
		for (int j = 0; j < s->count; j++)
		{
			SDL_Color color = { 0, 255, 0, 255 };
			if (j == s->count - 1) {
				color.r = 255;
				color.g = 255;
				color.b = 255;
			} else {
				color.r = 0;
				color.g = 255 / s->count * j;
				color.b = 0;
			}

			SDL_Rect dst = { s->col * dr->chW, s->y + dr->chH * j, dr->chW, dr->chH };
			
			//显示字母
			char ctmp[2] = { 0 };
			ctmp[0] = s->list[(s->start + j) % MAX_STRING_LENGTH];

			SDL_Surface* surf = TTF_RenderText_Blended(dr->pFont, ctmp, color);
			if (surf) {
				SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surf);
				if (texture) {
					SDL_RenderCopy(renderer, texture, NULL, &dst);
					SDL_DestroyTexture(texture);
				}
				SDL_FreeSurface(surf);
			}
		}
	}
}

至此,数字雨特效算是介绍完毕,整体上略显粗糙,如果大家对代码有兴趣,可以去下载界面查看源码,如果有其它问题欢迎留言讨论。