这章我们不用任何第三方库,从零实现一个类似Flappy Bird的简单游戏,提供完整的代码和素材。从整体思路到细节都会有讲解,很适合游戏或游戏引擎新手学习。

游戏场景简单,但是很完整,有开始结束、得分、游戏层级、粒子系统、碰撞检测。对新手而言是个非常友好的练习项目,甚至你可以发挥想象,基于这个简单的Demo拓展出更复杂的玩法。
准备好了么~~开始我们的游戏之旅了
游戏概览
如下图所示,Bird我们换成了一个小车,柱子障碍物我们用三角形代替,另外增加了尾部火焰和烟尘的粒子效果。
玩起来的效果如下:
实现思路
界面元素分析
如下图所示,整个游戏一共有6种元素:
其中状态提示和得分是文字,基于Imgui实现,小车和三角形用到了素材,即整个游戏的素材只用到了1个字体库,两张图片。
游戏工作流
所有的逻辑封装在GameLayer中。游戏启动后,将GameLayer入栈,之后在while循环中,依次执行:
- OnEvent():处理事件,鼠标键盘点击、界面窗口缩放等
- OnUpdate():更新游戏元素的状态,游戏是否结束、碰撞检测
- OnRender():绘制游戏元素
模块划分

参考上图,罗列了最终要的模块属性。
- GameLayer:类似photoShop中的图层的概念。负责计算这个层级的Camera,游戏状态。
- Level:游戏场景,很多游戏引擎中都有这个概念,可以理解为游戏中的Scene,游戏玩家、地形、robot、房屋都是Level中的元素,由Level统一管理、更新。碰撞也在Level中计算。
- Player:游戏中最重要的对象,由用户的输入控制移动。
- ParticleSystem:粒子系统,理论上可以挂载于任何一个游戏元素,这个游戏中只有小车能喷火焰和尾气,所以只有Player中有ParticleSystem实例。
代码实现
准备素材
将需要的素材,字体、图片等copy到assets文件加下,文件从github上down下来:
- Sandbox/assets/OpenSans-Regular.ttf
- Sandbox/assets/textures/Ship.png
- Sandbox/assets/textures/Triangle.png
代码地址:
github.com/summer-go/H...
接下来,我们逐个拆解游戏中每一个元素的实现。先创建好以下类文件,之后根据实现顺序一一讲解。
bash
Sandbox/src/
├── GameLayer.cpp
├── GameLayer.h
├── Level.cpp
├── Level.h
├── ParticleSystem.cpp
├── ParticleSystem.h
├── Player.cpp
├── Player.h
├── Random.cpp
└── Random.h
GameLayer
GameLayer是整个游戏实现的入口。
GameLayer需要实现Layer的接口。
Sandbox/src/GameLayer.h
c++
#pragma once
#include "Hazel.h"
#include "Level.h"
#include <imgui.h>
class GameLayer : public Hazel::Layer{
public:
GameLayer();
~GameLayer() override = default;
void OnAttach() override;
void OnDetach() override;
void OnUpdate(Hazel::Timestep ts) override;
void OnImGuiRender() override;
void OnEvent(Hazel::Event &event) override;
bool OnMouseButtonPressed(Hazel::MouseButtonPressedEvent& e);
bool OnWindowResize(Hazel::WindowResizeEvent& e);
private:
void CreateCamera(uint32_t width, uint32_t height);
private:
// 相机
Hazel::Scope<Hazel::OrthographicCamera> m_Camera;
// 场景
Level m_Level;
// 字体
ImFont* m_Font{};
// 更新时间
float m_Time = 0.0f;
// 控制界面是否闪烁
bool m_Blink = false;
// 游戏状态
enum class GameState
{
Play = 0, MainMenu = 1, GameOver = 2
};
// 默认游戏状态是主菜单
GameState m_State = GameState::MainMenu;
};
看看GameLayer的实现。
Sandbox/src/GameLayer.cpp
- 构造函数中创建Camera
正交Camera上下高为16,宽为16 * ratio,即只能看到场景中间camera高 * camera宽的范围。

c++
#include "GameLayer.h"
#include "Random.h"
#include "Hazel/Core/Core.h"
using namespace Hazel;
GameLayer::GameLayer()
: Layer("GameLayer")
{
auto& window = Application::Get().GetWindow();
CreateCamera(window.GetWidth(), window.GetHeight());
// 初始化随机种子,后面用到的时候再介绍
Random::Init();
}
// Camera高为16.0,是个相对的概念,没有尺度单位。上下边的坐标是8.0和-8.0,
// 左右长度基于高,和窗口等比例缩放
void GameLayer::CreateCamera(uint32_t width, uint32_t height) {
float aspecRatio = (float)width / (float)height;
float camWidth = 8.0f;
float bottom = -camWidth;
float top = camWidth;
float left = bottom * aspecRatio;
float right = top * aspecRatio;
m_Camera = CreateScope<OrthographicCamera>(left, right, bottom ,top);
}
- GameLayer初始化
初始化场景Level和字题,这步其实也可以放到构造函数中处理。Level::Init()的实现先空着,稍后实现Level再介绍。
c++
void GameLayer::OnAttach() {
m_Level.Init();
ImGuiIO io = ImGui::GetIO();
m_Font = io.Fonts->AddFontFromFileTTF("../assets/OpenSans-Regular.ttf", 120.0f);
}
// 空实现,暂不需要做额外的处理
void GameLayer::OnDetach() {
}
- GameLayer update 最重要的一步,更新游戏状态的入口。
c++
void GameLayer::OnUpdate(Hazel::Timestep ts) {
// 记录当前游戏运行的相对时间
m_Time += ts;
// 计算闪烁的状态,用来控制当前帧是否绘制提示文字
// m_Time是秒,(m_Time*10)%8 > 4表示以0.08秒为周期,一个周期内,0.08s * 3/8的时间打开闪烁标记
// %8控制闪烁的频率,>4控制闪的时间占比
if ((int)(m_Time * 10.0f) % 8 > 4) {
m_Blink = !m_Blink;
}
// 记录游戏状态
if (m_Level.IsGameOver()) {
HZ_INFO("GameLayer::OnUpdate IsGameOver: true");
m_State = GameState::GameOver;
}
// 更新相机的位置,相机会以Player为中心,一直跟拍Player
const auto& playerPos = m_Level.GetPlayer().GetPosition();
m_Camera->SetPosition({playerPos.x, playerPos.y, 0.0f});
// 只有处于Play状态时,才更新场景Level
switch (m_State) {
case GameState::Play:
{
m_Level.OnUpdate(ts);
break;
}
}
// 调用Level的Render
Hazel::RenderCommand::SetClearColor({0.0f, 0.0f, 0.0f, 1});
Hazel::RenderCommand::Clear();
Hazel::Renderer2D::BeginScene(*m_Camera);
m_Level.OnRender();
Hazel::Renderer2D::EndScene();
}
- 绘制提示文案 用imGui的接口来绘制文案,根据游戏的三种状态绘制文字:
- MainMenu:游戏启动界面显示"Click to Play!"
- Play:正常play时,左上角显示当前得分"Score:"
- GameOver:在MainMenu的基础上,显示总得分"Score"



c++
void GameLayer::OnImGuiRender() {
switch (m_State) {
case GameState::Play:
{
HZ_INFO("GameLayer::OnImGuiRender Play");
uint32_t playerScore = m_Level.GetPlayer().GetScore();
std::string scoreStr = std::string("Score: ") + std::to_string(playerScore);
// 得分显示在左上角,取窗口的Position,在左上角
ImGui::GetForegroundDrawList()->AddText(m_Font, 48.0f, ImGui::GetWindowPos(), 0xffffffff, scoreStr.c_str());
break;
}
case GameState::MainMenu:
{
HZ_INFO("GameLayer::OnImGuiRender MainMenu");
auto pos = ImGui::GetWindowPos();
auto width = Application::Get().GetWindow().GetWidth();
// 计算居中文字的起始位子,中间往左偏300开始绘制文字
// 高度取起始高度往下偏移50,PC窗口坐标原点在左上角,右边为x正,下边为y正
pos.x += width * 0.5f - 300.0f;
pos.y += 50.0f;
if (m_Blink) {
ImGui::GetForegroundDrawList()->AddText(m_Font, 120.0f, pos, 0xffffffff, "Click to Play!");
}
break;
}
case GameState::GameOver:
{
HZ_INFO("GameLayer::OnImGuiRender GameOver");
auto pos = ImGui::GetWindowPos();
auto width = Application::Get().GetWindow().GetWidth();
pos.x += width * 0.5f - 300.0f;
pos.y += 50.0f;
if (m_Blink) {
ImGui::GetForegroundDrawList()->AddText(m_Font, 120.0f, pos, 0xffffffff, "Click to Play!");
}
pos.x += 200.0f;
pos.y += 150.0f;
uint32_t playerScore = m_Level.GetPlayer().GetScore();
std::string scoreStr = std::string("Score: ") + std::to_string(playerScore);
ImGui::GetForegroundDrawList()->AddText(m_Font, 48.0f, pos, 0xffffffff, scoreStr.c_str());
break;
}
}
}
- 处理事件
这个游戏中,我们只需要处理鼠标事件和窗口缩放事件,游戏为GameOver状态时点击鼠标会重置,窗口缩放时重置Camera。
c++
void GameLayer::OnEvent(Hazel::Event &e) {
EventDispatcher dispatcher(e);
dispatcher.Dispatch<WindowResizeEvent>(HZ_BIND_EVENT_FN(GameLayer::OnWindowResize));
dispatcher.Dispatch<MouseButtonPressedEvent>(HZ_BIND_EVENT_FN(GameLayer::OnMouseButtonPressed));
}
bool GameLayer::OnMouseButtonPressed(MouseButtonPressedEvent &e) {
if(m_State == GameState::GameOver) {
m_Level.Reset();
}
m_State = GameState::Play;
return false;
}
bool GameLayer::OnWindowResize(WindowResizeEvent &e) {
CreateCamera(e.GetWidth(), e.GetHeight());
return false;
}
GameLayer中处理的是和窗口相关的输入,后面在Player中也有输入处理,是处理和Player相关的事件。
场景Level
Level中声明最重要的两个元素:Player和障碍物柱子Pillar,实现了简单的2D碰撞计算。
Sandbox/src/Level.h
c++
#pragma once
#include "glm/vec3.hpp"
#include "glm/vec2.hpp"
#include "Timestep.h"
#include "Player.h"
#include "Core.h"
#include "Renderer/Texture.h"
#include <vector>
struct Pillar {
glm::vec3 TopPosition {0.0f, 10.f, 0.0f};
glm::vec2 TopScale {15.0f, 20.0f};
glm::vec3 BottomPosition {10.0f, 10.0f, 0.0f};
glm::vec2 BottomScale {15.0f, 20.0f};
};
class Level {
public:
void Init();
void OnUpdate(Hazel::Timestep ts);
void OnRender();
void OnImGuiRender();
bool IsGameOver() const {return m_GameOver;}
void Reset();
Player& GetPlayer() {return m_Player;}
private:
void CreatePillar(int index, float offset);
bool CollisionTest();
void GameOver();
private:
Player m_Player;
bool m_GameOver = false;
float m_PillarTarget = 30.0f;
int m_PillarIndex = 0;
glm::vec3 m_PillarHSV {0.0f, 0.8f, 0.8f};
std::vector<Pillar> m_Pillars;
Hazel::Ref<Hazel::Texture2D> m_TriangleTexture;
};
Level的实现
Sandbox/src/Level.cpp
- 实现工具方法:HSV到RGB的转换
HSV色彩空间更容易精准的控制颜色。
不了解HSV可以参考《三分钟带你快速学习RGB、HSV和HSL颜色空间》: zhuanlan.zhihu.com/p/67930839
可以把精力放在整个游戏的主流程上,此处可以先放过去,回头有兴趣再深究
c++
#include "Level.h"
#include "Renderer/Renderer2D.h"
#include "Random.h"
#include <glm/gtc/matrix_transform.hpp>
using namespace Hazel;
static glm::vec4 HSVtoRGB(const glm::vec3& hsv) {
int H = (int)(hsv.x * 360.0f);
double S = hsv.y;
double V = hsv.z;
double C = S * V;
double X = C * (1 - abs(fmod(H/60.0,2) -1));
double m = V - C;
double Rs = 0, Gs = 0, Bs = 0;
if (H >= 0 && H < 60) {
Rs = C;
Gs = X;
Bs = 0;
}
else if (H >= 60 && H < 120) {
Rs = X;
Gs = C;
Bs = 0;
}
else if (H >= 120 && H < 180) {
Rs = 0;
Gs = C;
Bs = X;
}
else if (H >= 180 && H < 240) {
Rs = 0;
Gs = X;
Bs = C;
}
else if (H >= 240 && H < 300) {
Rs = X;
Gs = 0;
Bs = C;
} else {
Gs = C;
Gs = 0;
Bs = X;
}
return {(Rs + m), (Gs + m), (Bs + m), 1.0f};
}
- 实现工具方法:判断点是否在三角形内
计算点p和三角形各边围成的面积,加和后和该三角形面积相比,如果前者大则点p在三角形外,反之在三角形内。用三角形的两条边叉乘可以得到三角形的面积。
如下图:p点在三角形p0p1p2内,三个小三角形面积之和和大三角形面积相等。
p在三角形外,三个小三角形面积之和 > 大三角形面积
对叉乘的几何意义不了解的同学,可以网上查一下叉乘.
c++
// 计算三角形p p0 p1的面积是否 > p0 p1 p2的面积
static bool PointInTri(const glm::vec2& p, glm::vec2& p0, const glm::vec2& p1, const glm::vec2& p2)
{
// 叉乘计算三角形p0 p2 p的面积和方向
float s = p0.y * p2.x - p0.x * p2.y + (p2.y - p0.y) * p.x + (p0.x - p2.x) * p.y;
// 叉乘计算三角形p0 p1 p的面积和方向
float t = p0.x * p1.y - p0.y * p1.x + (p0.y - p1.y) * p.x + (p1.x - p0.x) * p.y;
// 判断这两个三角形面积的方向是否一致,不一致直接返回false
if ((s < 0) != (t < 0))
return false;
// 叉乘计算三角形p0 p1 p2的面积
float A = -p1.y * p2.x + p0.y * (p2.x - p1.x) + p0.x * (p1.y - p2.y) + p1.x * p2.y;
// 两个三角形面积s t之和 < A时,则点p在三角形内
// 注意考虑A的方向
return A < 0 ?
(s <= 0 && s + t >= A) :
(s >= 0 && s + t <= A);
}
进入Level的主流程
- Level初始化
主要是资源的初始化
- 加载场景本身需要的障碍物纹理Triangle.png
- 触发Player加载资源,小汽车
- 障碍物默认是5个,每间隔10.0创建一对柱子
c++
void Level::Init() {
m_TriangleTexture = Texture2D::Create("../assets/textures/Triangle.png");
m_Player.LoadAssets();
m_Pillars.resize(5);
for (int i = 0; i < 5; i++) {
CreatePillar(i, i * 10.0f);
}
}
- 更新OnUpdate
先判断是否GameOver,Over了直接返回。否则会动态的创建障碍物柱子,因为Player汽车一直往前走,所以柱子也需要持续的创建。
Pillar可以缓存起来反复使用,退出camera视野的柱子之后会被重新启用,而且要防止柱子突然的出现,需要提前20米时预创建好。

c++
void Level::OnUpdate(Hazel::Timestep ts) {
m_Player.OnUpdate(ts);
if (CollisionTest()) {
HZ_INFO("Level::OnUpdate:true");
GameOver();
return;
}
// HSV.x通道表示颜色的种类,所有的颜色围成一个圆形色盘
m_PillarHSV.x += 0.1f * ts;
if (m_PillarHSV.x > 1.0f) {
m_PillarHSV.x = 0.0f;
}
// 小汽车的位置超过m_PillarTarget时,就要赶紧创建一个准备着
if (m_Player.GetPosition().x > m_PillarTarget) {
HZ_INFO("Level::OnUpdate m_PillarIndex = {0}; m_PillarTarget = {1}; m_PillarTarget + 20.0f = {2}",m_PillarIndex, m_PillarTarget, m_PillarTarget + 20.0f);
CreatePillar(m_PillarIndex, m_PillarTarget + 20.0f);
// 同时,更新m_PillarIndex,通过取模,一直能循环使用缓存的Pillar
m_PillarIndex = ++m_PillarIndex % m_Pillars.size();
// 更新小汽车前面的柱子的坐标,表示为m_PillarTarget
m_PillarTarget += 10.0f;
}
}
- 创建障碍物柱子Pillar
先引入随机数工具类:Random。用std::uniform_int_distribution类来生成均匀分布的随机数,用std::mt19937生成随机数种子。
Sandbox/src/Random.h
c++
#pragma once
#include <random>
class Random {
public:
static void Init()
{
s_RandomEngine.seed(std::random_device()());
}
static float Float()
{
return (float)s_Distribution(s_RandomEngine) / (float) std::numeric_limits<uint32_t>::max();
}
private:
static std::mt19937 s_RandomEngine;
static std::uniform_int_distribution<std::mt19937::result_type> s_Distribution;
};
Sandbox/src/Random.cpp
c++
#include "Random.h"
Sandbox/src/Random.cpp
std::mt19937 Random::s_RandomEngine;
std::uniform_int_distribution<std::mt19937::result_type> Random::s_Distribution;
pillar已经提前创建好了5个,Create只是从缓存中按照index取出来,绘制到offset的地方。防止两个柱子的z深度冲突,设置成了不同的值,其实设置一样也没有问题,逻辑上上下两个柱子不会重叠。

c++
void Level::CreatePillar(int index, float offset) {
HZ_INFO("Level::CreatePillar index = {0}; offset = {1}",index, offset);
// 按index取出pillar
Pillar& pillar = m_Pillars[index];
// 绘制到偏移值offset处
pillar.TopPosition.x = offset;
pillar.BottomPosition.x = offset;
// 上下两个柱子深度设置成不同值
pillar.TopPosition.z = index * 0.1f - 0.5f;
pillar.BottomPosition.z = index * 0.1f - 0.5f + 0.5f;
// center指上下两个柱子的中间位置,范围为[-17.5,17.5],随机取值
float center = Random::Float() * 35.0f - 17.5f;
// gap值两个柱子中间留的缝隙
float gap = 2.0f + Random::Float() * 5.0f;
// 参考上图说明
// 按照 10.0f - ((10.0f - center) * 1) + gap * 0.5f 就好理解了
// pillar高20,默认中心在原点,往上挪10,则三角的尖刚好落在y=0处
// (10.0-center)表示三角形柱子和实际要放置的中心的差距,即10.0f-(10.0-center)。
// + gap*0.5f表示在往上偏移0.5gap,留出小汽车通过的空间
// 最后 * 0.2f,是增加一个系数,控制下pillar实际上下移动的范围,这个值可以调
pillar.TopPosition.y = 10.0f - ((10.0f - center) * 0.2f) + gap * 0.5f;
// pillar.BottomPosition.y计算同上面一行,初始位置向下即-10.0f
pillar.BottomPosition.y = -10.0f - ((-10.0f - center) * 0.2f) - gap * 0.5f;
}
- OnRender:绘制场景Level中的元素
需要绘制的元素有:
- 灰色的背景
- 地板和天花板
- 障碍物pillar
- 主玩家小汽车
重点看下地板和天花板的绘制,地板和天花板都是50 * 50,也是预留足够的大小,防止露底,如下图所示:

c++
void Level::OnRender() {
const auto& playerPos = m_Player.GetPosition();
glm::vec4 color = HSVtoRGB(m_PillarHSV);
// Background,背景的尺寸是50 * 50,比Camera的视野(16*16)要大很多,因为小汽车可以上下移动,背景要预留足够的尺寸,不然就漏底了。
Renderer2D::DrawQuad({playerPos.x, 0.0f, -0.8f}, {50.0f, 50.0f}, {0.3f, 0.3f, 0.3f, 1.0f});
// Floor and ceiling,天花板和地板也是50 * 50,天花板往上挪34,地板往下挪34
// 中间预留18的空间,即比相机视野多1
Renderer2D::DrawQuad({playerPos.x, 34.0f}, {50.0f, 50.0f}, color);
Renderer2D::DrawQuad({playerPos.x, -34.0f}, {50.0f, 50.0f}, color);
// pillar成对绘制
for(auto& pillar : m_Pillars)
{
Renderer2D::DrawQuad(pillar.TopPosition, pillar.TopScale, glm::radians(180.0f), m_TriangleTexture, 1.0f, color);
Renderer2D::DrawQuad(pillar.BottomPosition, pillar.BottomScale, 0.0f, m_TriangleTexture, 1.0f, color);
}
// 最后更新Player
m_Player.OnRender();
}
- 碰撞检测
判断小车是否撞到障碍物了。依次作为依据结束游戏。
碰撞检测的流程:
- abs(player.y) > 8.5,撞到天花板或地板了,
- 检测小车和所有的三角形是否有碰撞
小车是个矩形,有四个点,所以实际上是检测4个点是否在三角形内,只要和其中任意一个三角形碰撞则返回true。
实际上只需要判断小车是否和前后两对三角形是否碰撞即可,这里简单起见,和所有的三角形都计算碰撞
详细实现参看下面代码注释
c++
bool Level::CollisionTest() {
// 碰到天花板或地板
if (glm::abs(m_Player.GetPosition().y) > 8.5f){
return true;
}
// 小汽车的四个点
glm::vec4 playerVertices[4] = {
{-0.5f, -0.5f, 0.0f, 1.0f},
{0.5f, -0.5f, 0.0f, 1.0f},
{0.5f, 0.5f, 0.0f, 1.0f},
{-0.5f, 0.5f, 0.0f, 1.0f},
};
const auto& pos = m_Player.GetPosition();
glm::vec4 playerTransformedVerts[4];
// 算出小汽车4个点当前的位置
for (int i = 0; i < 4; i++) {
playerTransformedVerts[i] = glm::translate(glm::mat4(1.0f), {pos.x, pos.y, 0.0f})
* glm::rotate(glm::mat4(1.0f), glm::radians(m_Player.GetRotation()), {0.0f, 0.0f, 1.0f})
* glm::scale(glm::mat4(1.0f), {1.0f, 1.3f, 1.0f})
* playerVertices[i];
}
// 三角形图片周边有一圈淡淡的边,所以计算时,往里偏移0.1
// To match triangle.png (each corner is 10% from the texture edge)
glm::vec4 pillarVertices[3] = {
{-0.5f + 0.1f, -0.5f + 0.1f, 0.0f, 1.0f},
{0.5f - 0.1f, -0.5f + 0.1f, 0.0f, 1.0f},
{0.0f + 0.0f, 0.5f - 0.1f, 0.0f, 1.0f},
};
// 遍历所有三角形障碍物,和小汽车的4个点计算碰撞
for (auto& p : m_Pillars) {
glm::vec2 tri[3];
// 计算top三角形的三个点的当前坐标
for (int i = 0; i < 3; i++) {
tri[i] = glm::translate(glm::mat4(1.0f), {p.TopPosition.x, p.TopPosition.y, 0.0f})
* glm::rotate(glm::mat4(1.0f), glm::radians(180.0f), {0.0f, 0.0f, 1.0f})
* glm::scale(glm::mat4(1.0f), {p.TopScale.x, p.TopScale.y, 1.0f})
* pillarVertices[i];
}
// top三角行和小汽车的四个点计算碰撞
for (auto& vert : playerTransformedVerts) {
if (PointInTri({vert.x, vert.y}, tri[0], tri[1], tri[2])) {
return true;
}
}
// 底部三角形障碍物碰撞,和上面的碰撞逻辑一样
for (int i = 0; i < 3; i++) {
tri[i] = glm::translate(glm::mat4(1.0f), {p.BottomPosition.x, p.BottomPosition.y, 0.0f})
* glm::scale(glm::mat4(1.0f), {p.BottomScale.x, p.BottomScale.y, 1.0f})
* pillarVertices[i];
}
for (auto& vert : playerTransformedVerts) {
if (PointInTri({vert.x, vert.y}, tri[0], tri[1], tri[2])) {
return true;
}
}
}
return false;
}
- 结束与重置
c++
// GameOver,修改标识符
void Level::GameOver() {
HZ_INFO("Level::GameOver()");
m_GameOver = true;
}
// 重置时,重置m_PillarTarget和m_PillarIndex值
void Level::Reset() {
m_GameOver = false;
m_Player.Reset();
m_PillarTarget = 30.0f;
m_PillarIndex = 0;
for (int i = 0; i < 5; i++) {
CreatePillar(i, i * 10.0f);
}
}
下面看看主角Player的实现了。
Player
Player有向前的速度,会一直向前,直到碰撞GameOver。
Player需要注意几点:
- 类似Flppy Bird,默认有重力,有向下的加速度
- 点击空格键,会增加向上的速度,使得小汽车不会掉下去
- 点击空格,会周期性的喷色火焰(粒子)
- 周期性的释放白色烟雾(粒子)
Sandbox/src/Player.h
c++
#pragma once
#include "Timestep.h"
#include "ParticleSystem.h"
#include <glm/gtc/matrix_transform.hpp>
class Player {
public:
Player();
void LoadAssets();
void OnUpdate(Hazel::Timestep timestep);
void OnRender();
void OnImGuiRender();
void Reset();
// 注意小汽车图片默认是朝上的,所以-90°摆过来。
// m_Velocity.y * 4.0f,用向下的速度来计算旋转角度,这个速度越大,越向下倾斜的厉害
float GetRotation(){return m_Velocity.y * 4.0f - 90.0;}
const glm::vec2& GetPosition() const { return m_Position;}
uint32_t GetScore() const { return (uint32_t)(m_Position.x + 10.f) / 10.0f; };
private:
// 起始位置在-10处,即往后偏移10,保证车头在0处
glm::vec2 m_Position = {-10.0f, 0.0f};
// 车有向前的速度
glm::vec2 m_Velocity = {5.0f, 0.0f};
// 点击一次空格键,会增加小汽车向上0.5的速度
float m_EnginePower = 0.5f;
// 重力加速度为0.4,即车的速度会默认向下降
float m_Gravity = 0.4f;
float m_Time = 0.0f;
// 喷烟雾的间隔
float m_SmokeEmitInterval = 0.4f;
// 下次喷烟雾的时间,0.4秒喷一次
float m_SmokeNextEmitTime = m_SmokeEmitInterval;
// 烟雾、火焰的粒子属性
ParticleProps m_SmokeParticle, m_EngineParticle;
ParticleSystem m_ParticleSystem;
// 小汽车的纹理
Hazel::Ref<Hazel::Texture2D> m_ShipTexture;
};
Sandbox/src/Player.cpp
c++
#include "Player.h"
#include <imgui.h>
#include <glm/gtc/matrix_transform.hpp>
using namespace Hazel;
Player::Player() {
// Smoke
m_SmokeParticle.Position = {0.0f, 0.0f};
m_SmokeParticle.Velocity = {-2.0f, 0.0f}, m_SmokeParticle.VelocityVariation = {4.0f, 2.0f};
m_SmokeParticle.SizeBegin = 0.35f, m_SmokeParticle.SizeEnd = 0.0f, m_SmokeParticle.SizeVariation = 0.15f;
m_SmokeParticle.ColorBegin = {0.8f, 0.8f, 0.8f, 1.0f};
m_SmokeParticle.ColorEnd = {0.6f, 0.6f, 0.6f, 1.0f};
m_SmokeParticle.LifeTime = 4.0f;
// Flames
m_EngineParticle.Position = {0.0f, 0.0f};
m_EngineParticle.Velocity = {-2.0f, 0.0f}, m_EngineParticle.VelocityVariation = {3.0f, 2.0f};
m_EngineParticle.SizeBegin = 0.5f, m_EngineParticle.SizeEnd = 0.0f, m_EngineParticle.SizeVariation = 0.3f;
m_EngineParticle.ColorBegin = {254.f/255.0f, 109/255.0f, 41/255.0f, 1.0f};
m_EngineParticle.ColorEnd = {254/255.0f, 212/255.0f, 123/255.f, 1.0f};
m_EngineParticle.LifeTime = 1.0f;
}
void Player::LoadAssets() {
m_ShipTexture = Texture2D::Create("../assets/textures/Ship.png");
}
void Player::OnUpdate(Hazel::Timestep ts) {
m_Time += ts;
if (Input::IsKeyPressed(HZ_KEY_SPACE)) {
m_Velocity.y += m_EnginePower;
if (m_Velocity.y < 0.0f) {
m_Velocity.y += m_EnginePower * 2.0f;
}
// Flames
glm::vec2 emissionPoint = {0.0f, -0.6f};
float rotation = glm::radians(GetRotation());
glm::vec4 rotated = glm::rotate(glm::mat4(1.0f), rotation, {0.0f, 0.0f, 1.0f}) * glm::vec4(emissionPoint, 0.0f, 1.0f);
m_EngineParticle.Position = m_Position + glm::vec2{rotated.x, rotated.y};
m_EngineParticle.Velocity.y = -m_Velocity.y * 0.2f - 0.2f;
m_ParticleSystem.Emit(m_EngineParticle);
} else {
m_Velocity.y -= m_Gravity;
}
m_Velocity.y = glm::clamp(m_Velocity.y, -20.0f, 20.0f);
m_Position += m_Velocity * (float)ts;
// Particles
// 周期性的喷射烟雾
if (m_Time > m_SmokeNextEmitTime) {
m_SmokeParticle.Position = m_Position;
m_ParticleSystem.Emit(m_SmokeParticle);
m_SmokeNextEmitTime += m_SmokeEmitInterval;
}
// 粒子更新
m_ParticleSystem.OnUpdate(ts);
}
void Player::OnRender() {
m_ParticleSystem.OnRender();
Renderer2D::DrawQuad({m_Position.x, m_Position.y, 0.5f}, {1.0f, 1.3f}, glm::radians(GetRotation()), m_ShipTexture);
}
// 暂时有bug,事件有冲突,先注释掉
void Player::OnImGuiRender() {
// ImGui::DragFloat("Engine Power", &m_EnginePower, 0.1f);
// ImGui::DragFloat("Gravity", &m_Gravity, 0.1f);
}
void Player::Reset() {
m_Position = {-10.0f, 0.0f};
m_Velocity = {5.0f, 0.0f};
}
粒子系统(烟雾、火焰)
这里实现一个简单的粒子系统,注意一下几点对着代码看也很好理解:
-
预先创建粒子缓存池std::vector<Particle> m_ParticlePool,发射粒子时从缓存中按照m_PoolIndex取一个并更新属性。每个粒子也是一个带颜色的小方块。
-
m_PoolIndex是uint32_t类型的,无符号。减到<0时,编译器不会解释成负数,而是会变成一个超大值,所以--m_PoolIndex一直自减是没问题的。
-
之后在update中更新粒子的状态,直到粒子的运行时间超过生命的长度,状态改成非激活。
-
粒子的初始旋转角度、xy两个方向的速度都是随机的,以形成离散的粒子状态。
-
粒子的颜色是根据生命值差值出来的,逐渐变淡,接近ColorEnd的颜色。
Sandbox/src/ParticleSystem.h
c++
#pragma once
#include <Hazel.h>
struct ParticleProps {
glm::vec2 Position;
glm::vec2 Velocity, VelocityVariation;
glm::vec4 ColorBegin, ColorEnd;
float SizeBegin, SizeEnd, SizeVariation;
float LifeTime = 1.0f;
};
class ParticleSystem {
public:
ParticleSystem();
void Emit(const ParticleProps& particleProps);
void OnUpdate(Hazel::Timestep ts);
void OnRender();
private:
struct Particle {
glm::vec2 Position;
glm::vec2 Velocity;
glm::vec4 ColorBegin, ColorEnd;
float Rotation = 0.0f;
float SizeBegin, SizeEnd;
float LifeTime = 1.0f;
float LifeRemaining = 0.0f;
bool Active = false;
};
std::vector<Particle> m_ParticlePool;
uint32_t m_PoolIndex = 999;
};
Sandbox/src/ParticleSystem.cpp
c++
#include "ParticleSystem.h"
#include "Random.h"
#define GLM_ENABLE_EXPERIMENTAL
#include <glm/gtx/compatibility.hpp>
ParticleSystem::ParticleSystem()
{
m_ParticlePool.resize(1000);
}
void ParticleSystem::Emit(const ParticleProps &particleProps) {
Particle& particle = m_ParticlePool[m_PoolIndex];
particle.Active = true;
particle.Position = particleProps.Position;
particle.Rotation = Random::Float() * 2.0f * glm::pi<float>();
// Velocity
particle.Velocity = particleProps.Velocity;
particle.Velocity.x += particleProps.VelocityVariation.x * (Random::Float() - 0.5f);
particle.Velocity.y += particleProps.VelocityVariation.y * (Random::Float() - 0.5f);
// Color
particle.ColorBegin = particleProps.ColorBegin;
particle.ColorEnd = particleProps.ColorEnd;
// Size
particle.SizeBegin = particleProps.SizeBegin + particleProps.SizeVariation * (Random::Float() - 0.5f);
particle.SizeEnd = particleProps.SizeEnd;
// Life
particle.LifeTime = particleProps.LifeTime;
particle.LifeRemaining = particleProps.LifeTime;
//
m_PoolIndex = --m_PoolIndex % m_ParticlePool.size();
}
void ParticleSystem::OnUpdate(Hazel::Timestep ts) {
for(auto& particle : m_ParticlePool) {
if (!particle.Active) {
continue;
}
if(particle.LifeRemaining <= 0.0f) {
particle.Active = false;
continue;
}
particle.LifeRemaining -= ts;
particle.Position += particle.Velocity * (float)ts;
particle.Rotation += 0.01f * ts;
}
}
void ParticleSystem::OnRender() {
for(auto& particle : m_ParticlePool)
{
if (!particle.Active) {
continue;
}
float life = particle.LifeRemaining / particle.LifeTime;
glm::vec4 color = glm::lerp(particle.ColorEnd, particle.ColorBegin, life);
color.a = color.a * life;
float size = glm::lerp(particle.SizeEnd, particle.SizeBegin, life);
Hazel::Renderer2D::DrawQuad(particle.Position, {size, size}, particle.Rotation, color);
}
}
代码 & 总结
本次代码
总结
这个小游戏看着简单,只有2D,不涉及复杂的3D计算,也没有后处理、光照,但是用户体验是完整的,麻雀虽小,五脏俱全。
真要从零自己设计、实现,调试的没有bug,还是有点挑战的。我自己写完、调试好差不多用了3个小时。是一次非常好的、难度适中的游戏实践,理解好了,对以后使用商业引擎,或者阅读开源引擎代码都是很有帮助的。
最后,还留一个作业给读者,游戏画面中间有一个接近圆形的由眀变暗的渐变,你知道是怎么实现的吗?
在shader中,这个游戏我们使用了新的shader资源,GameTexture.glsl,在目录assets/shaders下,你可以看看这个glsl的代码思考答案。