Re0:从零开始的C++游戏开发 (下)
这是蒟蒻观看B站upVoidmatrix的课程从零开始的提瓦特幸存者的个人笔记【自用】
前言:采用适用于小白的easyx图形库。
第三集 提瓦特の幸存者(下)
3.1 用户界面实现和设计模式基础
3.1.1 导言
假设这样一个场景:在一个游戏中,出现在你的视野中的树木数以千计,虽然我们会惊叹建模师和贴图美术师们逼真的还原水平,但程序并不在乎。它只关心如何从磁盘中加载这些数据,并将其高效地渲染在游戏窗口。
我们随意挑出一棵树,若这棵树是绘制在3D场景中,构成它的资源可以笼统的分为模型和贴图两类。在许多3A大作的游戏资源包中,模型和贴图相关的资源所占的比例是极高的。它们不仅占据了大量的硬盘空间,也占据了游戏启动时加载的大部分时间。如果我们把一棵树在内存中所占用的资源为10MB计算,场景中1000棵树就需要10000MB,也就是说,只是为了把屏幕上把这些树绘制出来就需要占用电脑9.8GB左右的内存。这对于玩家显然是不合理的,况且想要从磁盘上加载1000个模型,也需要十分恐怖的加载时间。
那么我们可能会问,我只需要加载一棵树的模型,然后再游戏里把他绘制1000次不就好了?确实如次,虽然在现代的游戏技术中,对于树木这种大批量出现的渲染任务已有许多成熟的解决方案,但他们都离不开一个设计模式——“享元模式”。
3.1.2 享元模式
“享元”即“共享元素”的意思。”享元模式“是设计模式中使用热度极高的模式之一。(设计模式是一套被反复使用 多数人知晓的 经过分类编码的 代码设计经验的总结),他不像C++
等编程语言的语法那样白纸黑字,但也是一套自成体系的方法论。
若我们把算法比作功夫中的内功,那么设计模式就是外功招式。
就像在引言所讲述的树林场景,我们在设计对应代码结构时,直截了当的思路是:
// 树结构体
struct tree
{
Model model; // 树的模型
Texture texture; // 树的贴图
int x,y,z; // 树的位置
}
而在使用享元模式进行重新设计后:
// 树的资产结构体
struct TreeAsset
{
Model model; // 树的模型
Texture texture; // 树的贴图
}
// 树结构体
struct Tree
{
TreeAsset* asset; // 资产指针
int x,y,x; // 树的位置
}
再重新设计的代码中,我们把绘制一棵树所需的数据里面最庞大的部分挑出来。1000个Tree对象中模型和贴图均使用同一个TreeAsset
对象中的数据,这样就可以节省大量的内存空间。
回看我们的代码,这时我们可以看到:在Animation
的设计中,每一个Animation
对象都拥有自己的动画帧列表;而在Enemy
类中,每一个敌人,都拥有两个Animation
对象,这就意味着我们在游戏中每次随机刷新一个野猪,都会从磁盘中加载两套动画的图片到内存中,虽然我们所使用的图片不如3D模型那般恐怖,并不会导致严重的内存爆满问题,但是从磁盘上读取数据的这个I/O操作本身就是十分耗时的工作,尤其是在一些机械硬盘上磁盘速度较慢的情况时,刷新敌人的时候便会有明显的卡顿感。在主循环中动态的从磁盘中加载数据,这本身也违背了我们之前认识到的:“主循环中应尽量避免耗时过长的任务”这一设计准则。加载数据的工作应该放置到我们游戏框架中初始化的部分去做。毕竟从游戏体验角度,对玩家来说,比起在游戏过程中出现卡帧和掉帧等情况,更愿接受在加载时稍微多等一会儿。
所以这里我们要对Animation
类进行重新的拆分和设计。我们思考一下:游戏画面中的野猪们在动画方面可以共享的元素有哪些呢?
那当然是IMAGE
对象构成的vector
了;而动画当前正在播放第几帧等状态信息就各异了,所以就不能放在共享的数据里面。
因此,我们重新定义Atlas
类来表示动画所使用的“图集”,其所需的成员变量,构造和析构函数都是从Animation
中“拆分”下来的。而在整个游戏中,我们只需要用四个共用的Atlas
对象,也就是玩家和敌人分别向左和向右的动画。我们将它们的指针定义为全局变量,稍后进行初始化。
class Atlas
{
public:
Atlas(LPCTSTR path, int num)
{
TCHAR path_file[256];
for (size_t i = 0; i < num; i++)
{
_stprintf_s(path_file, path, i);
IMAGE* frame = new IMAGE();
loadimage(frame, path_file);
frame_list.push_back(frame);
}
}
~Atlas()
{
for (size_t i = 0; i < frame_list.size(); i++)
delete frame_list[i];
}
public:
std::vector<IMAGE*> frame_list;
};
而将图片序列拆分出去的Animation
类,就需要持有ATlas
类对象的指针了,在初始化时,将它保存在成员变量中。
这里需要注意:由于Atlas
是Animation
之间共享的公共资产,所以千万不能在Animation
的析构函数中使用delete
将Atlas
指针释放掉,Atlas
的生命周期应由更上一层的代码进行控制。
这样敌人刷新时可能的卡顿就一去不复返了。这里蒟蒻注意到在每个对象中阴影图片的绘制也是都要读取、再加载渲染。可以试着用类似的思想实现一下。
3.1.3 用户界面
众所周知,EasyX
作为2D图形库,它与GUI库是有区别的。我们可以十分便利的调用函数绘制点线面各种图形。但是想要在窗口中实现一个带有交互效果的按钮,这就需要我们自己实现了。
Qt作为GUI程序开发框架的定位,决定了它必然会屏蔽太多底层设计。例如我们在目前程序中所使用的“主循环”,这些封装和屏蔽从工程角度讲是再合适不过的,但是我们在探索游戏开发的初期也就是以学习为目的进行实践的过程中,我们更希望有一个功能简单直接容易上手的图形库来让我们选取,而不是直接使用GUI库。当然,在游戏开发中,Qt这些有着明确定位的GUI框架一般也不会直接参与到游戏程序本身的制作中,而是作为游戏开发工具链上的一环。想要在游戏这种即时渲染的框架中渲染更具有通用性的GUI,imGUI
等技术是在合适不过的了。那么想要实现GUI组件,在现有程序中该如何编写呢?
这里,有一句GUI设计哲学“一个按钮之所以是一个按钮,不是因为它长得像一个按钮,而是因为它能够对交互事件做出响应”。无论是文本还是图片,如果能够对玩家的点击事件进行捕获,并修改对应的数据进行响应,那么它就是一个按钮。
这里我们每个按钮提供了3张图片,分别对应了按钮的iale
、hovered
、push
形态。
现在回到代码,来考虑按钮类该如何设计。
按钮必然需要一个RECT变量来描述自己的位置和大小,这在判断鼠标响应时是必须的。然后是3张IMAGE
图片变量。最后我们还应定义按钮当前的状态枚举变量。,这是因为按钮的悬停、按下等状态实在消息处理时进行判断的 ,而在主循环的每一帧画面渲染时,我们都需要根据现有状态选择对应图片进行绘制。
然后就是内部成员函数的编写,绘制函数、事件处理函数。注意在开始编写之前一定要理清代码逻辑。
class Button
{
public:
Button(RECT rect, LPCTSTR path_img_idle, LPCTSTR path_img_hovered, LPCTSTR path_img_pushed)
{
region = rect;
loadimage(&img_idle, path_img_idle);
loadimage(&img_hovered, path_img_hovered);
loadimage(&img_pushed, path_img_pushed);
}
~Button() = default;
void ProccessEvent(const ExMessage& msg)
{
switch (msg.message)
{
case WM_MOUSEMOVE:
if (status == Status::Idle && CheckCursoHit(msg.x, msg.y))
status = Status::Hovered;
else if (status == Status::Hovered && !CheckCursoHit(msg.x, msg.y))
status = Status::Idle;
break;
case WM_LBUTTONDOWN:
if (CheckCursoHit(msg.x, msg.y))
status = Status::Pushed;
break;
case WM_LBUTTONUP:
if (status == Status::Pushed)
OnClick();
break;
default:
break;
}
}
void Draw()
{
switch (status)
{
case Status::Idle:
putimage(region.left, region.top, &img_idle);
break;
case Status::Hovered:
putimage(region.left, region.top, &img_hovered);
break;
case Status::Pushed:
putimage(region.left, region.top, &img_pushed);
break;
}
}
protected:
virtual void OnClick() = 0;
private:
enum class Status
{
Idle = 0,
Hovered,
Pushed
};
private:
RECT region;
IMAGE img_idle;
IMAGE img_hovered;
IMAGE img_pushed;
Status status = Status::Idle;
private:
// 检测鼠标点击
bool CheckCursoHit(int x, int y)
{
return x >= region.left && x <= region.right && y >= region.top && y <= region.bottom;
}
};
接下来,便可以此为基类编写特殊按钮类了
class QuitGameButton :public Button
{
public:
QuitGameButton(RECT rect,LPCTSTR path_img_idle, LPCTSTR path_img_howered, LPCTSTR path_img_pushed)
:Button(rect,path_img_idle,path_img_howered,path_img_pushed){}
~QuitGameButton() = default;
protected:
void OnClick() override
{
running = false;
}
};
class StartGameButton :public Button
{
public:
StartGameButton(RECT rect, LPCTSTR path_img_idle, LPCTSTR path_img_howered, LPCTSTR path_img_pushed)
:Button(rect, path_img_idle, path_img_howered, path_img_pushed) {}
~StartGameButton() = default;
protected:
void OnClick() override
{
is_game_started = true;
mciSendString(_T("play bgm repeat from 0"), NULL, 0, NULL);
}
};
注意我们在这里,将主循环的播放音乐移了过来。
另外还设置了2个全局变量running
和is_game_start
。
再对主函数内代码稍加修改,此次的项目完成了。。。