通过前面的 SDL2 相关知识的学习,现在你已经具备了搭建一个基本游戏框架的能力,而这篇文章就准备从零开始,运用我们之前所讲解的知识,搭建一个开发小游戏使用的基本框架,为以后的小游戏开发做好准备。

1. 创建项目工程

首先按照 第一个 Windows 项目 中介绍的内容,新建一个基本的 Windows 桌面工程:

桌面工程项目

创建一个 main.c 的文件,将我们 WinMain 的入口函数代码填上,点击 F5 确保整个工程可以顺利编译运行。

2. 配置项目属性

因为我们工程需要用到 SDL 相关接口,所以这里需要先去 SDL 官网下载编译好的开发库。其中包括 SDL2 本体,以及官方的扩展库 SDL2_image、SDL2_ttf、SDL2_mixer 。其中 SDL2 负责窗口、渲染和事件、SDL2_image 负责图片、SDL2_ttf 负责文字、SDL2_mixer 负责音频。这里推荐直接去我的博客下载以及编译好的 SDL2 全家桶,可以将它们统一放到工程目录下:

//game 是工程目录
game
|
|--- 3rdParty
|		|
|		|--- SDL2
|		|	  |--- include
|		|	  |--- lib
|		|
|		|--- SDL2_image
|		|	  |--- include
|		|	  |--- lib
|		|
|		|--- SDL_mixer
|		|	  |--- include
|		|	  |--- lib
|		|
|		|--- SDL2_ttf
|			  |--- include
|			  |--- lib
|
|--- main.c

文件放好之后,需要配置工程属性。事实上我在早期的文章中简单的介绍过常用的工程属性,这一次是引入第三方库,所以需要配置 附加包含目录附加库目录 以及 附加依赖项

首先将属性窗口左上角的 配置 的下拉列表,改为所有配置,并确保平台是 x64 位:

所有配置

如果你是 32 位的电脑需要自己重新下载或编译静态库。

接着配置附加包含目录如下:

包含配置

其中宏 $(ProjectDir) 表示当前项目目录,点击确认后就已经配置好了头文件。接下来需要配置库文件,不过在此之前推荐将运行库改为 /MT 和 /MTd ,具体原因可以参考运行时库配置。请确保 Debug 版 和 Release 版本都配置正确。

Debug 版本:

MTd

Release 版本:

MT

配置好运行库,接下来就是配置库文件。和配置头文件的过程基本相同,只不过 Debug 和 Release 链接的库目录有所区别,无法同时配置。为了方便,这里推荐使用另一种代码中引用库文件的方式引用链接库。

首先在工程中新建一个名字为 libs.h 的头文件,并填入下面的代码:

#pragma once

// 链接依赖库

#pragma comment(lib, "Setupapi.lib")
#pragma comment(lib, "Winmm.lib")
#pragma comment(lib, "Imm32.lib")
#pragma comment(lib, "Version.lib")

#ifdef _DEBUG
#pragma comment(lib, ".\\3rdParty\\SDL2\\lib\\Debug\\MTd\\SDL2d.lib")
#pragma comment(lib, ".\\3rdParty\\SDL2\\lib\\Debug\\MTd\\SDL2maind.lib")
#pragma comment(lib, ".\\3rdParty\\SDL2_ttf\\lib\\Debug\\MTd\\SDL2_ttf.lib")
#pragma comment(lib, ".\\3rdParty\\SDL2_image\\lib\\Debug\\MTd\\SDL2_image.lib")
#pragma comment(lib, ".\\3rdParty\\SDL2_mixer\\lib\\Debug\\MTd\\SDL2_mixer.lib")
#else
#pragma comment(lib, ".\\3rdParty\\SDL2\\lib\\Release\\MT\\SDL2.lib")
#pragma comment(lib, ".\\3rdParty\\SDL2\\lib\\Release\\MT\\SDL2main.lib")
#pragma comment(lib, ".\\3rdParty\\SDL2_ttf\\lib\\Release\\MT\\SDL2_ttf.lib")
#pragma comment(lib, ".\\3rdParty\\SDL2_image\\lib\\Release\\MT\\SDL2_image.lib")
#pragma comment(lib, ".\\3rdParty\\SDL2_mixer\\lib\\Release\\MT\\SDL2_mixer.lib")
#endif

代码中 #pragma comment 其实是 vs2019 支持的预编译指令,作用就是使用代码的方式引入链接库,和在工程中配置链接库的效果一致。

为了使这些预编译指令生效,我们需要引用这个头文件,修改 main.c 代码,在头部加上一条包含语句:

#include "libs.h"

因为 vs2019 默认不会链接未使用的库,所以在头文件上再加上 SDL.h 头文件,最后代码如下:

#include "libs.h"
#include <Windows.h>
#include "SDL.h"

INT WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
	PSTR lpCmdLine, INT nCmdShow)
{

	return 0;
}

接着点击编译按钮,查看效果。

1>------ 已启动生成: 项目: game, 配置: Debug Win32 ------
1>main.c
1>C:\Users\W_Z_C\source\repos\game\game\main.c(3,10): fatal error C1083: 无法打开包括文件: “SDL.h”: No such file or directory
1>已完成生成项目“game.vcxproj”的操作 - 失败。
========== 生成: 成功 0 个,失败 1 个,最新 0 个,跳过 0 个 ==========

如果显示没有找到 SDL.h 头文件,请确保已经添加前文提到 附加包含目录,并且编译的平台为 x64。

工具栏

如果你以上全部配置正确,在 Debug 版本下编译不会出现任何错误,但在 Release 下编译可能会有一个警告信息:

1>------ 已启动全部重新生成: 项目: game, 配置: Release x64 ------
1>main.c
1>正在生成代码
1>Previous IPDB not found, fall back to full compilation.
1>All 1 functions were compiled because no usable IPDB/IOBJ from previous compilation was found.
1>已完成代码的生成
1>game.vcxproj -> C:\Users\W_Z_C\source\repos\game\x64\Release\game.exe
========== 全部重新生成: 成功 1 个,失败 0 个,跳过 0 个 ==========

如果你有强迫症,不想看到这条警告,只需要去 Release 版本的工程配置属性中,修改优化模块的链接时间代码生成 的选项为 /LTCG 即可。

优化模块

再次重新编译,Debug 和 Release 版本全部正常,工程属性相关的配置就此告一段落。

3. 定义游戏接口

配置好工程,接下来我们就开始写框架代码。在原来的文章中,我介绍过如何使用 SDL2 搭建窗口,具体内容这里就不再重复了,直接将最后的代码摘抄过来,方便快捷美滋滋!

运行起来之后,你细细品读,研究哪里是未来可能发生变化的地方,因为只有知道哪里容易发生变化,哪里就是我们需要对外提供的接口或者参数,框架是封装不变的特征,并将容易改变的特征对外开放,这样就可以做到“程序千千万,框架永不变”的终极目标。

不过有一点需要强调,框架没有最好,只有更好,不要抱着一个框架不放手,任何框架都有其自身的局限,没有任何框架是所谓的银弹,我更希望你可以参考我的代码封装出你自己独有的框架,最后可以给我讲讲你的原理,让我学习学习!

这里抛砖隐喻,说一下我发现的未来可能有所变化的地方:

  • 游戏窗口的属性。包括窗口大小、标题、位置等信息。
  • 游戏运行帧数。游戏运行不一定使用最大帧数运行,可能存在将帧数限制在一定数值的情况。
  • 游戏资源加载。不同的游戏有不同的资源,所以每个游戏理论上资源加载的代码各不相同。
  • 输入事件。由于游戏类型和内容有所区别,每个游戏产生的输入事件肯定不同。
  • 游戏更新和显示。每个游戏内在逻辑不同,所以内部更新的逻辑以及显示的画面肯定不同。

上面每一个地方其实都是未来游戏中可能会变化的地方,所以这些最好都要对外,也就是需要用户设置或者实现这部分容易变化的部分。

说完可能变化的部分,再说一下不变的部分:

  • 游戏入口函数。Windows 窗口程序总是从 WinMain 进入。
  • SDL 各种初始化反初始化流程。
  • 游戏窗口创建销毁过程。
  • 游戏渲染器创建销毁过程。
  • 字体创建销毁过程。
  • Windows 消息循环。
  • 帧率的计算方法。

还有很多可以固定的东西,其实这些大部分以及被 SDL 简化为一两个函数了,你直接使用它们创建游戏其实已经省去了很多工作,但是我们可以进一步优化,因为例如 SDL 的初始化、窗口的创建、帧数的计算等等这些工作每个游戏都是需要的,将这些工作封装为一个一个的函数,然后按照固定的流程组装,这个最终的产物就是我们将要封装的游戏框架。

根据前面提到的内容,我们可以封装出两个结构体,它们分别命名为 SystemModuleUserModule

SystemModule 是框架为用户提供的数据,这些数据是用户在编写游戏中可能用到的参数,这些参数理论上用户是不可以随意更改的,具体结构如下:

//系统模块
typedef struct SystemModule
{
	HINSTANCE hinstance;			//应用实例句柄
	SDL_Window* pWin;				//窗口指针
	SDL_Renderer* pRenderer;		//渲染指针
	TTF_Font* pDefFont;				//默认字体指针
} SystemModule;

这里解释下为什么封装这四个成员。首先 hinstance 是为了以后资源加载使用的,如果你想要从资源中加载相关数据,hinstance 是必须提供的。接着 pWin 和 pRenderer 分别是使用 SDL 创建的窗口指针和渲染器指针,在未来的游戏中,你可能通过使用 pWin 来获取当前窗口的大小,使用 pRenderer 渲染图像,所以它们也是不可或缺的。最后一个参数 pDefFont 是框架默认的字体指针,是为了显示帧率才创建的相关资源,之所以提供是为了以后在程序中调试使用,不用为了显示某些调试文字再单独创建加载另一个字体资源,不过因为字体在加载的时候已经确认了字体显示的大小,所以如果你需要其它大小的字体或者需要其它类型的字体,你还是需要新建自己的字体指针。

接着说一下 UserModule。UserModule 是用户提供给框架的数据,这些数据是框架在创建窗口或者其它资源之前需要了解的相关信息,例如游戏的标题,游戏的大小等等,目前该结构体成员如下:

//标题最大长度
#define TITLE_MAX_LEN 512

//用户模块
typedef struct UserModule
{
	char title[TITLE_MAX_LEN];		//游戏标题
	SIZE winSize;					//窗口大小
	unsigned int FPS;				//FPS
	int showFPS;					//是否显示 FPS
} UserModule;

目前的程序不多,主要是窗口创建相关的信息。

确定了框架和用户之间的通讯结构,接下来就是通讯接口:

//游戏初始化
int initGame(UserModule* pModule);

//反初始化
void uninitGame(SystemModule* pModule);

//加载游戏资源
int loadGameResources(SystemModule* pModule);

//触发事件
void processGameEvent(SDL_Event* evt);

//游戏更新
int updateGame(SystemModule* pModule, float ms);

//游戏渲染
void renderGame(SystemModule* pModule, float lag);

这些接口其实不用多说,一般都可以想到是为了什么。其中 initGame 和 uninitGame 不用多说,就是游戏框架的初始化和反初始化。单独的 loadGameResources 函数用来加载游戏相关资源,processGameEvent 用来将框架捕获的事件传给用户,updateGame 和 renderGame 用来更新和渲染游戏逻辑。

上面的接口是最常见的一组,你也可以根据自己的需求封装其它的接口,不过一般情况上述函数以及足够你开发常见的游戏了。

4. 实现框架代码

定义完用户接口,接下来进入重点,那就是如何实现框架代码。其实框架代码的相关知识以及在过去的文章中介绍过了,这里就是穿针引线,让大家感受游戏以前的知识是怎么被用起来的!

首先我们确认一下代码文件结构:

game
 |
 |--- main.c			//主函数
 |--- sgfinterface.h	 //接口定义
 |--- sgfgame.h
 |--- sgfgame.c			//框架主要代码
 |--- sgfhelper.h
 |--- sgfhelper.c		//常见助手函数

上面是需要实现的文件。首先 sgfinterface.h 就不再介绍了,内容就是第三节所介绍的相关结构体和接口函数的声明定义,主要用于用户和框架之间的通讯。

这里先介绍一下 sgfgame.h 文件的实现:

#pragma once

#include "sgfinterface.h"

//游戏模块
typedef struct GameModule
{
	SystemModule sys;				//系统模块,系统自动初始化
	UserModule usr;					//用户模块,用户负责初始化
} GameModule;


//初始化
GameModule* initGameModule(HINSTANCE hinst);

//运行
void runGame(GameModule* pModule);

sgfgame.h 头文件中主要定义了两个函数,用于游戏框架的初始化和运行。这些函数最终在 main.c 文件中被使用:

#include "lib.h"
#include "sgfgame.h"

int WINAPI WinMain(
	HINSTANCE hInstance,
	HINSTANCE hPrevInstance,
	LPSTR lpCmdLine,
	int nCmdShow
)
{
	GameModule* pGame = initGameModule(hInstance);
	if (!pGame) {
		return -1;
	}

	runGame(pGame);

	return 0;
}

在 sgfgame.c 文件中,主要是定义窗口、渲染器以及消息循环。具体的代码可以参考 SDL2 游戏窗口搭建详情SDL2 游戏开发之时间和帧率 文章中的内容。

sgfhelper.h 文件主要是提供一些常用的工具函数,这里目前只有两个函数:

//GBK2312 转 UTF8
int GBKToUTF8(const char* gbk, int gbklen, char* utf8, int utf8len);
//获取资源数据
SDL_RWops* getResourceData(HINSTANCE hinst, LPCWSTR lpName, LPCWSTR lpType);

getResourceData 函数以及在SDL2 游戏开发之资源加载 中介绍过了,这里贴一下 GBK2312 转 UTF8 的函数,以供参考:

/**
 * GBK 转 UTF8
 * 参数:
 *		gbk 需要转换的 GBK 编码字符串指针
 *		gbklen 字符串长度(需要计算结束字符 strlen(gbk) + 1),推荐填写 -1,自动计算长度
 *		utf8 转换的结果指针
 *		utf8len 转换结果的空间大小
 *
 * 返回:
 *		返回转换后字符串的长度 (> 0)
 */

int GBKToUTF8(const char* gbk, int gbklen, char* utf8, int utf8len)
{
	if (!(gbk && utf8)) {
		return 0;
	}

	//转换为宽字符
	int bufWLen = MultiByteToWideChar(CP_ACP, 0, gbk, gbklen, NULL, 0);
	if (bufWLen == 0) {
		return -1;
	}

	WCHAR* bufW = malloc(sizeof(WCHAR) * bufWLen);
	if (!bufW) {
		return -2;
	}

	int ret = MultiByteToWideChar(CP_ACP, 0, gbk, gbklen, bufW, bufWLen);
	if (ret == 0) {
		free(bufW);
		return -3;
	}

	//转换为 UTF8
	int bufMLen = WideCharToMultiByte(CP_UTF8, 0, bufW, -1, NULL, 0, NULL, NULL);
	if (bufMLen == 0) {
		free(bufW);
		return -4;
	}

	CHAR* bufM = malloc(bufMLen);
	if (!bufM) {
		free(bufW);
		return -5;
	}

	ret = WideCharToMultiByte(CP_UTF8, 0, bufW, -1, bufM, bufMLen, NULL, NULL);
	if (ret == 0) {
		free(bufW);
		free(bufM);
		return -6;
	}

	//空间不够,不截取...
	if (bufMLen > utf8len) {
		free(bufW);
		free(bufM);
		return -7;
	}

	//复制
	memset(utf8, 0, utf8len);
	memcpy(utf8, bufM, bufMLen);

	free(bufW);
	free(bufM);

	return bufMLen;
}

具体的框架代码,可以去下载页面下载,如果大家发现不正确或者有疑问的地方欢迎随时留言联系我。