目录
此项目大约有两千余行代码且知识点繁多,所以分为两篇来写。这一篇是上半部分,介绍整个项目的概况,在关键步骤的内容上则侧重框架部分的设计和实现。
概览
技术栈:C++ + EasyX
项目目标:使用多个头文件的项目组织结构,引入“场景”这一设计来划分游戏的不同阶段,实现功能间的解耦合。了解简单的物理模拟实现思路,并完成单向碰撞平台的功能。封装 摄像机、定时器 和 粒子系统 等在游戏开发中热度很高的功能,进一步推进项目作品水准的专业化。同时,实现更灵动的动画效果和更丰富的音效,提升项目的完成度。
课程来源:B站-Voidmatrix
核心玩法
两位玩家可以使用不同的角色进行本地双人对战,在平台间跳跃和移动,使用效果各异的普通攻击和特殊攻击给对方造成伤害,并获取能量奖励,能量蓄满后可以释放不同技能。生命值归零或坠落到平台下方的玩家会被击败。
设计思路
多个头文件的使用
随着项目体量的增大,如果将全部的代码都放到 main.cpp 中会显得十分臃肿,不仅会导致命名空间和依赖关系混乱,在调试和修改时也不容易定位具体的代码位置,所以需要将它们“分而治之”。所以本项目开始会将游戏不同阶段的代码放到不同的类中进行封装,并将不同的类代码存放在不同的头文件中。
雷点——重复引用:
当我们使用 #include 去包含头文件时,编译器会将所有的这个头文件中的内容原封不动地替换在 #include 地位置,这个过程是一个纯粹的文本内容的复制粘贴。所以,我们一个头文件 A.h 中包含了头文件B.h,main.cpp又同时包含了A.h和B.h,由于A.h中已经有了B.h的内容,所以在main.cpp中会有两份B.h的内容,如果涉及到类定义等代码,就会出现重复定义的问题,导致编译器无法编译。
避坑方法:
这两种方法在绝大多数情况下是可以互相替换的。
两者的区别:
| 特性 |
#ifndef / #define |
#pragma once |
| 兼容性 |
所有标准 C/C++ 编译器都支持 |
大多数现代编译器支持,但不是标准 |
| 实现方式 |
通过宏定义判断是否已包含 |
编译器内部记录是否已处理该文件 |
| 文件名依赖 |
不依赖文件名,只依赖宏名 |
依赖文件路径,可能受硬链接或符号链接影响 |
| 冲突风险 |
宏名需唯一,命名冲突可能导致问题 |
无需命名,避免冲突 |
| 编译速度 |
稍慢(需解析宏) |
更快(直接跳过) |
游戏主框架的设计
创建窗口、创建游戏主循环和稳定帧率,这个写法基本是游戏开发框架的固定格式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
int main()
{
//========= 初始化数据 ==========
initgraph(1280, 720);
bool is_running = true;
const int FPS = 60;
ExMessage msg;
BeginBatchDraw();
while (is_running) // 游戏主循环
{
DWORD start_time = GetTickCount();
//========= 处理输入 =========
while (peekmessage(&msg))
{
}
//======== 处理更新 =========
cleardevice();
//======== 处理渲染 =========
FlushBatchDraw();
//========= 稳定帧率 =========
DWORD end_time = GetTickCount();
DWORD delta_time = end_time - start_time;
if (delta_time < 1000 / FPS)
{
Sleep(1000 / FPS - delta_time);
}
}
EndBatchDraw();
return 0;
}
|
各个对象/类的设计
本项目开始引入架构概念,从分析功能入手,设计良好的程序结构,让项目扩展性更好且更利于在开发中调试。
- 场景基类
- 场景管理器
- 图集类
- 动画类
- 二维向量类
- 摄像机类
- 定时器类
- 平台类
- 玩家基类
- 玩家ID枚举类
- 子弹基类
- 玩家状态栏类
- 粒子类
开发流程 - 框架部分
- 游戏框架的设计
- 场景框架的设计
- 解决思路:使用各个游戏子类继承基类Scene的方式,对场景进行更灵活的管理
- 问题:如何实现场景间的切换
- 资源加载的实现
- 问题1:如何方便动画管理,并实现资源的可复用性
- 问题2:敌人死亡时的动画逻辑应该怎么写才能播放死亡动画?
- 问题:怎样让游戏画面更加灵活和自然
- 解决思路:实现摄像机类
- 问题:如何更精确地记录摄像机的位置
- 解决思路:不使用EasyX自带的POINT类(坐标数据类型为整型),封装一个Vector2类,其坐标位置为浮点数类型
- 问题:如何表达打击的视觉特效
- 问题:除了动画和摄像机抖动外,还有很多场景需要用到定时器(比如玩家特殊技能、攻击冷却时间等)
- 解决思路:为了复用的方便,封装定时器类,对有时效性的功能提供相对统一的管理模式
关键步骤和解决思路 - 框架部分
场景设计
如果将场景比喻成舞台上的一幕,那么在不同的慕中会有不同的“剧本”逻辑和不同的角色登场,这些角色就是游戏开发中常说的 GameObject,玩家、敌人、子弹、道具……等等这些从概念上都属于GameObject的范畴,接受着不同的场景剧本的指挥、进行不同逻辑的演出。
所以一个游戏从程序的流程上可以划分出 游戏主菜单、玩家角色选择界面、游戏局内界面,由此可以定义Scene这个基类,主菜单、角色选择场景和局内游戏场景分别可以继承自Scene这个基类,实现不同的事件处理和绘图逻辑。
Scene基类
将所有的成员方法都定义为虚方法,那么具体的游戏场景类在继承了这个基类后,就可以通过重写自己相应的方法实现自己的逻辑。Scene基类在这个过程中,就像是其他具体用于实例化的场景子类的模板。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
class Scene
{
public:
Scene() = default;
~Scene() = default;
virtual void on_enter() {}; // 进入场景
virtual void on_update(int delta) {}; // 处理更新
virtual void on_draw() {}; // 处理渲染
virtual void on_input(const ExMessage& msg) {}; // 处理玩家输入
virtual void on_exit() {}; // 退出场景
private:
};
|
可以重写所需要的成员方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
class MenuScene : public Scene
{
public:
MenuScene() = default;
~MenuScene() = default;
// 重写Scene基类中必要的虚函数
void on_enter()
{
cout << "进入主菜单" << endl;
}
void on_update(int delta)
{
cout << "主菜单正在运行......" << endl;
}
void on_draw()
{
outtxtxy(10, 10, _T("主菜单绘制内容"));
}
void on_input(const ExMessage& msg)
{
}
void on_exit()
{
cout << "主菜单退出" << endl;
}
private:
};
|
在 main.cpp 中实例化:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
int main()
{
initgraph(1280, 720, EW_SHOWCONSOLE); // 保留控制台界面
BeginBatchDraw();
Scene* scene = new MenuScene(); //在主循环前实例化
while (running)
{
DWORD frame_start_time = GetTickCount();
while (peekmessage(&msg))
{
scene->on_input(msg);
}
// 游戏逻辑的更新
scene->on_update();
cleardevice();
// 绘制游戏页面
scene->on_draw();
FlushBatchDraw();
DWORD frame_end_time = GetTickCount();
DWORD frame_delta_time = frame_end_time - frame_start_time;
if (frame_delta_time < 1000 / FPS)
{
Sleep(1000 / FPS - frame_delta_time);
}
}
EndBatchDraw();
return 0;
}
|
所有的场景子类的设计逻辑都一样。
场景管理器的实现
游戏程序是一个巨大的死循环,也是一个巨大的状态机。不同的游戏场景代表着不同状态,管理着这些状态的状态机,在游戏开发中被称为 场景管理器。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
|
class SceneManager
{
public:
enum class SceneType // 标记当前场景状态
{
Menu,
Game,
Selector
};
public:
SceneManager() = default;
~SceneManager() = default;
// 设置当前场景
void set_current_scene(Scene* scene)
{
current_scene = scene;
current_scene->on_enter(); // 确保场景执行流程完整
}
// 切换场景(先退出现在的场景,然后选择、进入新的场景)
void switch_to(SceneType type)
{
current_scene->on_exit(); // 先退出当前场景
switch (type)
{
case SceneType::Menu:
current_scene = menu_scene;
break;
case SceneType::Game:
current_scene = game_scene;
break;
case SceneType::Selector:
current_scene = selector_scene;
break;
default:
break;
}
current_scene->on_enter(); // 找到需要的新的场景后,进入
}
void on_update(int delta)
{
current_scene->on_update(delta);
}
void on_draw()
{
current_scene->on_draw();
}
void on_input(const ExMessage& msg)
{
current_scene->on_input(msg);
}
private:
Scene* current_scene = nullptr;
};
|
on_enter 和 on_exit在功能上和构造函数和析构函数类似,都是用来初始化和释放资源的,所以为什么不直接使用构造函数和析构函数呢?原因是,构造函数和析构函数决定着场景对象在内存中的生命周期。如果直接使用构造函数和析构函数来执行进入和退出的全部逻辑,那么我们在场景跳转时就需要不断地构造新的对象、释放旧的对象,这并不是性能友好的行为。同时,随着程序逻辑越来越复杂,可能会存在有些资源在不同场景之间都有引用,也就是说,场景内对象的生命周期可能会长于场景对象本身的生命周期 的情况,这也对内存管理提出了更高的要求。所以采用这种方式是更通用且简明的设计思路:场景对象的生命周期与游戏的生命周期相同。也就是说,在游戏初始化时创建所有的场景对象,在游戏退出时释放所有的场景对象,而在游戏内部的场景跳转过程中,我们避免对场景类构造函数和析构函数的调用,转而提供语义明确的 on_exit 和 on_enter 方法。在这两个方法中,我们也要尽可能避免对场景内部的成员的对象进行构造和析构,而是取而代之,重置它们的状态。
举个例子:在游戏场景对象中有一个玩家对象成员,当玩家生命值归零时,程序从游戏场景跳转到主菜单场景。如果我们使用构造函数和析构函数的设计思路,那么就需要在这时 delete 游戏场景对象并且 new 一个主菜单场景对象,这样才能确保当我们再次回到游戏场景时,游戏场景中的玩家对象生命值是正常的,而不是上次退出时的0。而如果我们使用on_exit 和 on_enter 的思路,就不需要频繁地构造和释放场景对象:在场景进入时只需重置玩家生命值变量的值,也就是重置场景内部的状态,就可以同样达到“焕然一新”的效果。
为什么在设置当前场景时参数是场景指针,而跳转场景时用场景枚举呢?
采用这样设计的原因:设置当前场景的方法一般只在游戏初始化时设置场景管理器的入口场景时才被调用,与子场景实例化几乎同时进行,所以直接使用指针更加方便。而跳转场景的方法一般是在不同的子场景内部更新时被调用,子场景之间持有彼此的引用,如果也用指针容易出现内存问题,所以使用枚举来屏蔽管理器内部的指针操作。
至此,main 中的场景部分代码就可以完全交给场景管理器执行了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
menu_scene = new MenuScene();
game_scene = new GameScene();
selector_scene = new SelectorScene();
scene_manager.set_current_scene(menu_scene);
BeginBatchDraw();
while (running)
{
DWORD frame_start_time = GetTickCount();
while (peekmessage(&msg))
{
scene_manager.on_input(msg);
}
scene_manager.on_update();
cleardevice();
scene_manager.on_draw(main_camera);
FlushBatchDraw();
|
具体的场景跳转逻辑则放到各个场景的on_input 消息处理逻辑中完成:
1
2
3
4
5
6
7
8
|
// MenuScene.h
void on_input(const ExMessage& msg)
{
if (msg.message == WM_KEYUP)
{
scene_manager.switch_to(SceneManager::SceneType::Selector);
}
}
|
资源加载部分的设计
Atlas类的实现
Atlas类可以看作是有一个装载具有相关性的一系列图片资源的容器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
|
class Atlas
{
public:
Atlas() = default;
~Atlas() = default;
void load_from_file(LPCTSTR path_template, int num)
{
// 注意:在加载前,先清空图片对象列表
// 并将列表长度设置为指定大小
// 这样可以避免load_from_file函数在重复调用时出现内部数据与预期不符的问题
img_list.clear();
img_list.resize(num);
TCHAR path_file[256];
for (int i = 0; i < num; i++)
{
_stprintf_s(path_file, path_template, i + 1);
loadimage(&img_list[i], path_file);
}
}
void clear() // 清空图集中已加载的图片对象
{
img_list.clear();
}
size_t get_size() // 获取图集中图片的数量
{
return img_list.size();
}
IMAGE* get_image(int idx) // 获取实际渲染的动画帧
{
if (idx < 0 || idx >= img_list.size())
return nullptr;
return &img_list[idx]; // 返回对应索引图片的地址
}
// 向图集中添加已有的图片对象
// 看似和从文件加载图片的方法功能上有重复
// 但在生成水平翻转的动画图集时很有用
void add_image(const IMAGE& img)
{
img_list.push_back(img);
}
private:
vector<IMAGE> img_list;
};
|
资源的加载
在正式开始写Animation类前,还需要一个处理动画帧的水平翻转,这样就无需准备两套动画素材了。图片素材的翻转需要对图片像素进行逐个处理,是一个比较耗时的操作,所以需要放在 游戏初始化阶段 进行,不要放到游戏已经开始的帧更新中进行。这个函数是一个工具类函数,我们放到 util.h 中方便所有工程中的成员调用。具体的实现在本站另一篇文章 提瓦特幸存者 中有详细讲解,此处不再赘述。在 main.cpp 中,我们把 flip_atlas作为全局函数,这样方便在游戏主循环前直接调用:
1
2
3
4
5
6
7
8
9
10
11
12
|
// main.cpp
void flip_atlas(Atlas& src, Atlas& dst)
{
dst.clear(); // 避免重复使用同一个容器产生问题
for (int i = 0; i < src.get_size(); i++)
{
IMAGE img_flipped;
flip_image(src.get_image(i), &img_flipped);
dst.add_image(img_flipped);
}
}
|
此项目暂时在 main.cpp 中通过一个全局函数加载了全部资源。需要注意的是,资源的名称务必要做到“有意义”、“有规律”。可以这样命名:文件类型_角色名_朝向,比如 Atlas atlas_peashooter_idle_left; 力求 见名知意,虽然看着有些冗长,但实际上将大大提高我们在开发时借助编辑器查找的效率,并方便我们在出现问题时根据它们的语义进行排查。资源加载部分的逻辑包括三个部分:加载游戏字体、加载和处理图片素材、加载音效。音效的处理同样使用 mciSendString,不要忘记包含对应的库。
Animation类的实现
Animation类可以看作是决定实际渲染图集的轻量控制器。它的所有功能都是在Atlas类之上实现的。关于这个类的设计同样从 成员变量 和 成员函数 两方面入手。成员变量决定这个类的数据属性,成员函数则是根据这些数据属性选择对外提供何种的 增删查改 接口。需要注意的是,由于动画在播放时,帧索引的推进是自动进行的,不需要外部进行set设置,所以成员方法对帧相关的方法只提供了 get_idx_frame 和 get_frame 两个get方法。动画类最重要的更新和绘制两个函数 on_update 和 on_draw,也在本站文章《提瓦特幸存者》中有详细讲解,此处也不再赘述。
游戏中诸如敌人和子弹等物体在生命周期结束时消失的动画逻辑应该怎么写呢?
要解决这个问题,我们不能在敌人死亡时直接删除Enemy对象,而是延后这些存在消失动画的物体被删除的时间。也就是说,当敌人生命值归零时进入死亡状态,同时播放死亡动画,而当死亡动画播放结束后,再将敌人对象从内存中移除。这就需要 动画层面提供一个动画播放结束的消息。这里提供一个思路:使用 回调函数。
回调函数
回调函数就是一个使用参数传递的函数对象,我们可以把它保存起来,然后再合适的时候调用它。这样就可以让函数内部的逻辑在这个“合适的时候”才被执行。比如,处理敌人死亡的情景为例,我们可以把删除敌人的逻辑定义为函数,以回调函数的形式保存在动画对象内部。当死亡动画播放结束后,这个函数被调用,删除敌人的逻辑被执行,这样就达成了我们的目的。记得先在开始处包含头文件 #include <functional>,具体的实现可以这样写:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
class Animation
{
public:
......
void on_update(int delta)
{
timer += delta;
if (timer > interval)
{
timer = 0;
idx_frame++;
if (idx_frame >= atlas->get_size())
{
idx_frame = is_loop ? 0 : atlas->get_size() - 1;
if (!is_loop && callback) // 如果动画无需循环播放,且回调函数存在,那么调用回调函数
{
callback();
}
}
}
}
void set_callback(function<void()> callback)
{
this->callback = callback;
}
private:
......
function<void()> callback; // 利用回调函数实现玩家在播放完动画后再死亡(销毁)
};
|
Animation类的完整代码是这样的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
|
class Animation
{
public:
Animation() = default;
~Animation() = default;
void reset()
{
timer = 0;
idx_frame = 0;
}
void set_atlas(Atlas* new_atlas)
{
reset();
atlas = new_atlas;
}
void set_loop(bool flag)
{
is_loop = flag;
}
void set_interval(int ms)
{
interval = ms;
}
int get_idx_frame()
{
return idx_frame;
}
IMAGE* get_frame()
{
return atlas->get_image(idx_frame);
}
bool check_finished()
{
if (is_loop)
return false;
return (idx_frame == atlas->get_size() - 1);
}
void on_update(int delta)
{
timer += delta;
if (timer > interval)
{
timer = 0;
idx_frame++;
if (idx_frame >= atlas->get_size())
{
idx_frame = is_loop ? 0 : atlas->get_size() - 1;
if (!is_loop && callback) // 如果动画无需循环播放,且回调函数存在,那么调用回调函数
{
callback();
}
}
}
}
void on_draw(const Camera& camera, int x, int y) const
{
putimage_alpha(camera, x, y, atlas->get_image(idx_frame));
}
void set_callback(function<void()> callback)
{
this->callback = callback;
}
private:
Atlas* atlas = nullptr;
bool is_loop = true; // 是否循环播放
int timer = 0; // 计时器
int interval = 0; // 帧间隔
int idx_frame = 0; // 帧索引
function<void()> callback; // 利用回调函数实现玩家在播放完动画后再死亡(销毁)
};
|
摄像机的设计
窗口坐标系和世界坐标系
EasyX的窗口原点在平面左上角,它的窗口坐标系就是这样的:
EasyX窗口坐标系
而世界坐标系则是更广阔的坐标系。就像我们把场景中包括玩家角色在内的诸多物体放置到一个虚拟的世界中一样。这个世界有多大,那么世界坐标系的取值范围就有多大。玩家的移动、碰撞,各类机关、道具的触发,乃至于整个游戏世界的运行逻辑,都是在世界坐标系这套框架下运转的。而只有在需要绘制游戏画面时,我们才需要考虑将它们放置到窗口坐标系下进行绘图等操作。而摄像机可以看作是一个世界坐标系和窗口坐标系之间转换的媒介。这个思路也和游戏开发的设计理念 数据和渲染分离 一致。
那么,在不考虑画面缩放的情况下,也就是摄像机的宽高和窗口的宽高一致时,我们就可以将摄像机看作是整个世界中的一个点,当我们需要实现摄像机跟随玩家移动的横板卷轴游戏时,只需要让摄像机跟随玩家移动,也就是这个点的坐标和玩家坐标保持一致。在渲染游戏内其他内容时,我们只需要把场景中其他物体的世界坐标与这个摄像机位点的世界坐标作差,得出的坐标就是我们传递给绘图函数的窗口坐标。注意,这是一个非常重要的概念:窗口坐标 = 世界坐标 - 摄像机坐标。
Vector2类的实现
为了使用浮点数来更精确地控制摄像机的位置,我们先来封装一个在游戏中极其常用的二维向量类。为了方便我们和常见的数字类型进行相似的运算操作,我们在这个类里进行了运算符重载:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
|
class Vector2
{
public:
Vector2() = default;
~Vector2() = default;
Vector2(float x, float y) // 再写一个带参的构造函数,方便直接初始化x和y的值
: x(x),y(y){ }
//======== 运算符重载 ========
// 方便执行和常见运算符类似的操作
Vector2 operator+(const Vector2& vec) const
{
return Vector2(x + vec.x, y + vec.y);
}
void operator+=(const Vector2& vec)
{
x += vec.x, y += vec.y;
}
Vector2 operator-(const Vector2& vec) const
{
return Vector2(x - vec.x, y - vec.y);
}
void operator-=(const Vector2& vec)
{
x -= vec.x, y -= vec.y;
}
Vector2 operator*(const Vector2& vec) const
{
return Vector2(x * vec.x, y * vec.y);
}
Vector2 operator*(float val) const
{
return Vector2(x * val, y * val);
}
void operator*=(float val)
{
x *= val, y *= val;
}
float length()
{
return sqrt(x * x + y * y);
}
Vector2 normalize()
{
float len = length();
if (len == 0)
{
return Vector2(0, 0);
}
return Vector2(x / len, y / len);
}
public:
float x = 0.0f;
float y = 0.0f;
};
|
摄像机类的实现
摄像机抖动效果
在游戏中的应用十分常见。在使用枪械射击或者表达爆炸或冲击波时,游戏设计者们通常会让玩家的画面快速震动一段时间,来透过屏幕表达一种力量感。这是一种实现起来比较简单但表现效果很不错的视觉特效。
实现思路
摄像机的抖动只会持续一段时间,在抖动一段时间后我们需要结束这种效果。所以和动画的实现类似,我们需要一个定时器来控制抖动特效开始和结束的时刻。
定时器类的实现
通用定时器类的设计思路有两种:一种是 继承,另一种是 回调。
继承的实现思路:
提供一个定时器基类,在 on_update 中执行定时器时间到达时的逻辑,这部分具体的逻辑封装在 callback 成员方法中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
class Timer
{
public:
Timer() = default;
~Timer() = default;
void on_update(int delta)
{
// ......
callback();
}
protected:
virtual void callback()
{
// 执行定时器时间
}
};
|
如果我们想实现特定的定时器逻辑,那么只需要将这个特定的定时器继承自Timer基类,并重写 callback 方法。在使用时就能借助多态特性,执行重写后的定时器逻辑。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
class MyTimer : public Timer
{
public:
MyTimer() = default;
~MyTimer() = default;
protected:
void callback() override
{
// 执行自定义的定时器逻辑
}
};
Timer* my_timer = new MyTimer();
|
回调的实现思路:
和Animation类播放结束的回调函数类似。通过 set_call_back 成员方法将回调函数保存在对象内部,并在合适的时候调用它。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
class Timer
{
public:
Timer() = default;
~Timer() = default;
void on_update(int delta)
{
// ......
callback();
}
void set_callback(function<void()> callback)
{
this->callback = callback;
}
protected:
function<void()> callback;
}
|
在使用时只需向对象中注册自己的回调函数即可。
1
2
3
4
5
|
Timer my_timer;
mu_timer.set_back([]()
{
// 执行自定义的定时器逻辑
});
|
两相对比,使用回调函数的定时器实现在代码上更加简洁。如果我们需要多个执行不同逻辑的定时器,如果使用继承的思路,那么就需要写多个不同的定时器子类。而使用回调的方法,我们只需要实例化定时器对象后编写一个lambda函数。所以从代码设计的角度,像通用定时器这种只需扩展回调方法逻辑而无需扩展数据成员内容的类,我们就会更倾向于使用回调函数的思路而不是使用类继承的思路去处理。这不仅让代码编写的量更少更轻松、在语义上也更加明确。
定时器类的完整代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
|
// 通用定时器类
// 不同的定时后的逻辑用回调函数实现
class Timer
{
public:
Timer() = default;
~Timer() = default;
void restart() // 重置定时器
{
pass_time = 0;
shotted = false;
}
void set_wait_time(int val)
{
wait_time = val;
}
void set_one_shot(bool flag)
{
one_shot = flag;
}
void set_callback(function<void()> callback)
{
this->callback = callback;
}
void pause()
{
paused = true;
}
void resume()
{
paused = false;
}
void on_update(int delta)
{
if (paused)
return;
pass_time += delta;
if (pass_time >= wait_time)
{
if (!one_shot || (one_shot && !shotted) && callback)
callback();
shotted = true;
pass_time = 0;
}
}
private:
int pass_time = 0; // 已经过去的时间
int wait_time = 0; // 等待时间
bool paused = false; // 是否暂停
bool shotted = false; // 是否触发
bool one_shot = false; // 是否单次触发
function<void()> callback; // 触发回调函数
};
|
包含通用定时器的摄像机类
摄像机抖动效果的设计思路:
如果想让整个世界,即屏幕上所有的内容都进行抖动的话,只需要让摄像机坐标抖动即可。简而言之,摄像机抖动的特效只需要快速改变Camera对象的坐标即可。那么我们以什么样的规律进行改变呢?一种相对简单的思路是,我们可以在摄像机仍在抖动的帧中,在以抖动强度为半径的圆内随机设置摄像机的位置,因为帧更新的速度很快,所以在宏观上就可以做到抖动的效果。除此之外,如果希望在更大幅度的抖动时更加平滑,我们可以使用 柏林函数 等噪声算法取代随机数,这部分内容涉及到更多算法的实现。在本项目这种小幅度摄像机抖动的场景下画面效果提升并不明显,所以此项目采用随机数的实现思路。
摄像机类的完整代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
|
class Camera
{
public:
Camera()
{
timer_shake.set_one_shot(true);
timer_shake.set_callback([&]()
{
is_shaking = false;
reset();
}
);
}
~Camera() = default;
const Vector2& get_position() const // 前一个const:返回一个不可修改的 Vector2 引用;后一个const:表示该函数不会修改类成员,可用于 const 对象
{
return position;
}
void reset() // 将摄像机的位置归零
{
position.x = 0;
position.y = 0;
}
void on_update(int delta)
{
timer_shake.on_update(delta);
if (is_shaking)
{
position.x = (-50 + rand() % 101) / 50.0f * shaking_strength;
position.y = (-50 + rand() % 101) / 50.0f * shaking_strength;
}
}
// 设置摄像机开始抖动的属性
// 参数:strength-抖动强度;duration-抖动持续时间
void shake(float strength, int duration)
{
is_shaking = true;
shaking_strength = strength;
timer_shake.set_wait_time(duration);
timer_shake.restart();
}
private:
Vector2 position; // 摄像机的位置
// 实现摄像机的抖动效果
Timer timer_shake; // 摄像机抖动定时器
bool is_shaking = false;
float shaking_strength = 0; // 摄像机抖动幅度
};
|
在 on_update 中,将摄像机的位置在抖动强度为半径的圆内进行随机设置。在抖动强度 shaking_strength 前乘以的这个随机系数是描述了一个单位圆的范围,取值范围为 -1.0 到 1.0 的浮点数。
至此,游戏框架部分的搭建完成,这一项目的下半部分笔记请继续阅读本站文章《植物明星大乱斗之Gameplay层实现》。