游戏引擎从零开始(34)-1小时开发2D游戏

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

游戏场景简单,但是很完整,有开始结束、得分、游戏层级、粒子系统、碰撞检测。对新手而言是个非常友好的练习项目,甚至你可以发挥想象,基于这个简单的Demo拓展出更复杂的玩法。

准备好了么~~开始我们的游戏之旅了

游戏概览

如下图所示,Bird我们换成了一个小车,柱子障碍物我们用三角形代替,另外增加了尾部火焰和烟尘的粒子效果。

玩起来的效果如下:

实现思路

界面元素分析

如下图所示,整个游戏一共有6种元素:

其中状态提示和得分是文字,基于Imgui实现,小车和三角形用到了素材,即整个游戏的素材只用到了1个字体库,两张图片。

游戏工作流

所有的逻辑封装在GameLayer中。游戏启动后,将GameLayer入栈,之后在while循环中,依次执行:

  • OnEvent():处理事件,鼠标键盘点击、界面窗口缩放等
  • OnUpdate():更新游戏元素的状态,游戏是否结束、碰撞检测
  • OnRender():绘制游戏元素

模块划分

参考上图,罗列了最终要的模块属性。

  1. GameLayer:类似photoShop中的图层的概念。负责计算这个层级的Camera,游戏状态。
  2. Level:游戏场景,很多游戏引擎中都有这个概念,可以理解为游戏中的Scene,游戏玩家、地形、robot、房屋都是Level中的元素,由Level统一管理、更新。碰撞也在Level中计算。
  3. Player:游戏中最重要的对象,由用户的输入控制移动。
  4. ParticleSystem:粒子系统,理论上可以挂载于任何一个游戏元素,这个游戏中只有小车能喷火焰和尾气,所以只有Player中有ParticleSystem实例。

代码实现

准备素材

将需要的素材,字体、图片等copy到assets文件加下,文件从github上down下来:

  1. Sandbox/assets/OpenSans-Regular.ttf
  2. Sandbox/assets/textures/Ship.png
  3. 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

  1. 构造函数中创建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);
}
  1. 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() {
}
  1. 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();
}
  1. 绘制提示文案 用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;
        }

    }
}
  1. 处理事件

这个游戏中,我们只需要处理鼠标事件和窗口缩放事件,游戏为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

  1. 实现工具方法: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};
}
  1. 实现工具方法:判断点是否在三角形内

计算点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的主流程

  1. 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);
    }
}
  1. 更新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;
    }
}
  1. 创建障碍物柱子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;
}
  1. 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();
}
  1. 碰撞检测

判断小车是否撞到障碍物了。依次作为依据结束游戏。

碰撞检测的流程:

  • 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;
}
  1. 结束与重置
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};
}

粒子系统(烟雾、火焰)

这里实现一个简单的粒子系统,注意一下几点对着代码看也很好理解:

  1. 预先创建粒子缓存池std::vector<Particle> m_ParticlePool,发射粒子时从缓存中按照m_PoolIndex取一个并更新属性。每个粒子也是一个带颜色的小方块。

  2. m_PoolIndex是uint32_t类型的,无符号。减到<0时,编译器不会解释成负数,而是会变成一个超大值,所以--m_PoolIndex一直自减是没问题的。

  3. 之后在update中更新粒子的状态,直到粒子的运行时间超过生命的长度,状态改成非激活。

  4. 粒子的初始旋转角度、xy两个方向的速度都是随机的,以形成离散的粒子状态。

  5. 粒子的颜色是根据生命值差值出来的,逐渐变淡,接近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);
    }
}

代码 & 总结

本次代码

github.com/summer-go/H...

总结

这个小游戏看着简单,只有2D,不涉及复杂的3D计算,也没有后处理、光照,但是用户体验是完整的,麻雀虽小,五脏俱全。

真要从零自己设计、实现,调试的没有bug,还是有点挑战的。我自己写完、调试好差不多用了3个小时。是一次非常好的、难度适中的游戏实践,理解好了,对以后使用商业引擎,或者阅读开源引擎代码都是很有帮助的。

最后,还留一个作业给读者,游戏画面中间有一个接近圆形的由眀变暗的渐变,你知道是怎么实现的吗?

在shader中,这个游戏我们使用了新的shader资源,GameTexture.glsl,在目录assets/shaders下,你可以看看这个glsl的代码思考答案。

相关推荐
德先生&赛先生12 分钟前
深入理解c语言中的static
c++
景彡先生37 分钟前
C++ 中的 iostream 库:cin/cout 基本用法
开发语言·c++
SundayBear3 小时前
适合有C基础后快速上手C++
开发语言·c++
顾三殇3 小时前
【自考】《计算机信息管理课程实验(课程代码:11393)》华师自考实践考核题型解析说明:C++ 与 mysql 实践题型与工具实践题分析
c++·mysql·华师自考实践考核题型分析
newki4 小时前
【NDK】项目演示-Android串口的封装工具库以及集成的几种思路
android·c++·app
whoarethenext4 小时前
使用 C++ 和 OpenCV 构建智能答题卡识别系统
开发语言·c++·opencv
Epiphany.5564 小时前
堆排序code
数据结构·c++·算法
Dfreedom.5 小时前
Excel文件数据的读取和处理方法——C++
c++·数据分析·excel·数据预处理
looklight6 小时前
7. 整数反转
c++·算法·leetcode·职场和发展
位东风6 小时前
【凌智视觉模块】rv1106 部署 ppocrv4 检测模型 rknn 推理
c++·人工智能·嵌入式硬件