C++游戏开发之旅 1
这个游戏参考的是ziyu老师的教程,老师的教学很棒,非常适合新人学习,如果你也对游戏开发有兴趣,我很建议你去跟老师学习。这是老师的B站https://space.bilibili.com/3546810402474894
因为我也不是科班出身,基础有点弱,写这个的也是为了加强记忆和理解。其实ziyu老师也有一个web网站,里面的教程文档也是很详细的https://cppgamedev.top/
一起加油!
主要框架搭建
我们创建一个GameApp类来对游戏进行管理。这将是整个应用程序的管理者,封装所有SDL初始化、窗口管理和主循环相关逻辑。
game_app.h
#pragma once
struct SDL_Window;
struct SDL_Renderer; // 向前声明,加快编译速度
namespace engine::core{
/**
* @brief 主游戏应用程序类,初始化SDL,管理游戏循环
*/
class GameApp final {
private:
SDL_Window* window_ = nullptr;
SDL_Renderer* sdl_render_ = nullptr;
bool is_running_ = false;
public:
GameApp();
~GameApp();
/**
* @breif 运行游戏时,先调用init()初始化,然后进入主循环,离开时自动调用close()
*/
void run();
// 拷贝和移动禁止
GameApp(const GameApp&) = delete;
GameApp& operator=(const GameApp&) = delete;
GameApp(GameApp&&) = delete;
GameApp& operator=(GameApp&&) = delete;
private:
[[nodiscard]] bool init();
void handleEvents();
void update();
void render();
void close();
};
}
关键要点
- 前向声明(Forward Declaration)-使用
struct SDL_Window;来前向声明SDL的类型,而不是直接包含<SDL3/SDL.h>。这样做可以减少头文件的依赖,加快编译速度 - 命名空间(Namespace)-所有引擎代码放在
engine命名空间下,core是其子命名空间,有助于避免命名冲突 - 禁止拷贝/移动-通过
=delete,明确禁止了GameApp对象的拷贝和移动。程序中应该只存在一个GameApp实例 - 声明周期方法-
init,handleEvents,update,render,close这些私有方法定义游戏循环的各个阶段
game_app.cpp
#include "game_app.h"
#include <spdlog/spdlog.h>
#include <SDL3/SDL.h>
namespace engine::core
{
GameApp::GameApp() = default;
GameApp::~GameApp()
{
if(is_running_){
spdlog::warn("GameApp销毁时没有显式关闭.现在关闭...");
close();
}
}
void GameApp::run()
{
if(!init()){
spdlog::error("GameApp初始化失败,无法进行游戏");
return;
}
while(is_running_){
float delta_time = 0.016f;
handleEvents();
update(delta_time);
render();
}
close();
}
bool GameApp::init()
{
// SDL初始化
if(!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO)){
spdlog::error("SDL初始化失败");
return false;
}
window_ = SDL_CreateWindow("SunnyLand", 1280, 720, SDL_WINDOW_RESIZABLE);
if(!window_){
spdlog::error("SDL窗口创建失败");
return false;
}
sdl_renderer_ = SDL_CreateRenderer(window_, nullptr);
if(!sdl_renderer_){
spdlog::error("SDL渲染器创建失败");
return false;
}
is_running_ = true;
return true;
}
void GameApp::handleEvents()
{
SDL_Event event;
while(SDL_PollEvent(&event)){
switch(event.type){
case SDL_EVENT_QUIT:
is_running_ = false;
break;
default:
break;
}
}
}
void GameApp::update(float)
{
}
void GameApp::render()
{
SDL_RenderClear(sdl_renderer_);
SDL_RenderPresent(sdl_renderer_);
}
void GameApp::close()
{
if(sdl_renderer_){
SDL_DestroyRenderer(sdl_renderer_);
sdl_renderer_ = nullptr;
}
if(window_){
SDL_DestroyWindow(window_);
window_ = nullptr;
}
SDL_Quit();
is_running_ = false;
}
} // namespace engine::core
核心方法
- 构造/析构函数-遵循RAII(Resource Acquisition Is Initialization)原则。构造函数什么都不做,但析构函数检查游戏是否运行,如果是,就调用
close()确保资源正确释放,防止内存泄漏 - run()方法-类的公共入口点。其首先调用
init()进行初始化,如果成功,则进入while(is_running_)主循环。循环结束,自动调用close()进行清理 - init()方法-负责所有初始化工作。初始化SDL的视频和音频子系统,创建窗口(SDL_CreateWindow)和渲染器(SDL_CreateRenderer)。每一步操作都需要进行错误检查,并通过spdlog输出错误信息
- handleEvents()方法-使用
SDL_PollEvent来轮询事件队列。目前,只处理SDL_EVENT_QUIT事件(例如点击关闭),接受事件后,is_running_设置为false,从而终止主循环 - update()和render()方法- 目前是空的,主要对游戏逻辑进行更新和渲染
- close()方法-负责释放所有在
init()中申请的资源,顺序于申请时相反:先销毁渲染器,再销毁窗口,最后调用SDL_Quit()关闭整个SDL库
程序入口和编译配置
main.cpp 程序的入口很简单,唯一职责就是创建GameApp对象并调用run()方法
#include "engine/core/game_app.h"
int main(int, char**){
engine::core::GameApp app;
app.run();
return 0;
}
CMakeLists.txt这个文件告诉CMake如何编译我们的项目。它设置了C++标准,找到了所有依赖库(SDL3,spdlog等),然后将我们的源文件编译成一个可执行文件,并把所有库链接进去。
cmake_minimum_required(VERSION 3.10.0)
project(Fox_20260104 VERSION 0.1.0 LANGUAGES C CXX) #项目名称
# 设置c++标准
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED True)
# 设置编译选项
if(MSVC)
add_compile_options(/W4)
else()
add_compile_options(-Wall -Wextra -Werror)
endif()
# 设置编译输出目录
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG ${CMAKE_BINARY_DIR})
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY_RELEASE ${CMAKE_BINARY_DIR})
set(TARGET ${PROJECT_NAME}-${CMAKE_SYSTEM_NAME})
#查找并加载Cmake预设
find_package(SDL3 REQUIRED)
find_package(SDL3_image REQUIRED)
find_package(SDL3_mixer REQUIRED)
find_package(SDL3_ttf REQUIRED)
find_package(glm REQUIRED)
find_package(nlohmann_json REQUIRED)
find_package(spdlog REQUIRED)
# 添加可执行文件
add_executable(${TARGET}
src/main.cpp
src/engine/core/game_app.cpp
)
# 链接库
target_link_libraries(${TARGET}
${SDL3_LIBRARIES}
SDL3_image::SDL3_image
SDL3_mixer::SDL3_mixer
SDL3_ttf::SDL3_ttf
glm::glm
nlohmann_json::nlohmann_json
spdlog::spdlog
)
运行
现在配置好CMake并编译项目。出现窗口

实现帧率控制
问题:前面我们定义了一个固定的时间,delta_time为0.016f。我们希望不同电脑性能如何,都可以每帧花费固定的时间。但是却会导致不同电脑运行速度不同。
我们希望可以有一个与帧率无关的物理更新。要做到这一点,需要记录上一帧到当前帧所经过的时间,这个时间就是DeltaTime。
我们创建一个新的类Time。帧率稳定目标值60FPS。
Time类的实现
Time类负责所有与时间相关的计算。它会在每帧开始时被调用,计算出DeltaTime,并提供一个选项来将游戏帧率稳定在一个目标值
time.h
#pragma once
#include <SDL3/SDL_stdinc.h>
namespace engine::core
{
class Time final {
private:
Uint64 last_time_ = 0; // 上一帧时间戳
Uint64 frame_start_time_ = 0; // 当前帧开始时间戳
double delta_time_ = 0; // 当前帧时间差
double time_scale_ = 1.0; // 时间缩放因子
// 帧率相关
int target_fps_ = 0;
double target_frame_time_ = 0.0;
public:
Time();
// 拷贝移动禁止
Time(const Time&) = delete;
Time& operator=(const Time&) = delete;
Time(Time&&) = delete;
Time& operator=(Time&&) = delete;
/**
* @brief 每帧开始时调用,更新内部时间状态并计算 DeltaTime
*/
void update();
/**
* @brief 获取经时间缩放的当前帧的时间差
* @return float 时间差
*/
float getDeltaTime() const { return time_scale_ * delta_time_; }
/**
* @brief 获取未经时间缩放的当前帧的时间差
* @return float 时间差
*/
float getUnscaledDeltaTime() const { return delta_time_; }
/**
* @brief 设置时间缩放因子
* @param scale 时间缩放因子, 默认为 1.0
*/
void setTimeScale(float scale) { time_scale_ = scale; }
/**
* @brief 获取时间缩放因子
*/
float getTimeScale() const { return time_scale_; }
/**
* @brief 设置目标帧率
*/
void setTargetFPS(int fps) { target_fps_ = fps; target_frame_time_ = 1.0 / target_fps_; }
/**
* @brief 获取目标帧率
*/
int getTargetFPS() const { return target_fps_; }
private:
/**
* @brief 限制帧率, update中调用,用于限制帧率。如果设置target_fps_ > 0,则限制帧率,否则不限制
* @param current_delta_time 当前帧时间差
*/
void limitFrameRate(float current_delta_time);
};
} // namespace engine::core
- 记录上一帧和当前帧的时间戳,计算
delta_time_ - 提供
getDeltaTime()和getUnscaledDeltaTime(),前者受time_scale_影响(实现慢动作/快进),后者则返回原始的帧耗时 - 提供
setTargetFps()设置目标帧率,如果为0,表示不设置
time.cpp
#include "time.h"
#include <spdlog/spdlog.h>
#include <SDL3/SDL_timer.h>
namespace engine::core{
Time::Time()
{
spdlog::info("Time initialized with fps: {}", target_fps_);
// 初始化last_time_ 和 frame_start_time_ 防止第一帧 Delta_time过大
last_time_ = SDL_GetTicksNS();
frame_start_time_ = last_time_;
}
void Time::update()
{
frame_start_time_ = SDL_GetTicksNS();
double current_delta_time = (frame_start_time_ - last_time_) / 1e9;
if(target_frame_time_ > 0.0){ // 若设置目标帧率,则限制帧率;否则delta_time_ = current_delta_time;
limitFrameRate(current_delta_time);
}else {
delta_time_ = current_delta_time;
}
last_time_ = SDL_GetTicksNS(); // 记录离开 update 时的时间戳
}
void Time::limitFrameRate(float current_delta_time)
{
if(current_delta_time < target_frame_time_){
double time_to_wait = target_frame_time_ - current_delta_time;
Uint64 time_to_wait_ns = static_cast<Uint64>(time_to_wait * 1e9);
SDL_DelayNS(time_to_wait_ns);
delta_time_ = (SDL_GetTicksNS() - last_time_) / 1e9;
} else {
delta_time_ = target_frame_time_;
}
}
}
使用SDL_GetTicksNS(),返回SDL初始化以来的纳秒数(Uint64),这为我们提供了非常高的计时精度。
-
update()逻辑
- 计算当前帧的时间戳和上一帧离开update()的时间戳的差值current_delta_time
- 如果设置了帧率,就进行对比,如果current_delta_time < target_frame_time,进行delay
-
limitFrameRate()逻辑
- 比较当前帧的耗时
current_delta_time和目标耗时target_frame_time - 如果当前帧耗时更短,就用
SDL_DelayNS()“暂停”程序,等待补足剩余时间 - 等待结束,重新计算最终的
delta_time_,此时会很接近target_frame_time - 如果耗时更长,就让
delta_time=current_delta_time
- 比较当前帧的耗时
集成Time到GameApp中
game_app.h修改
在GameApp中添加一个std::unique_ptr<engine::core::Time> time_;成员。使用智能指针unique_ptr可以确保Time对象的生命周期由GameApp独占管理,并在GameApp销毁时自动释放内存。
- 在
GameApp的构造函数中,创建Time的实例:time_ = std::make_unique<Time>(); - 在
run()方法的主循环中,用以下代替
time_->update();
float delta_time = time_->getDeltaTime();
在run()临时添加time_->setTargetFPS(60);来测试帧率限制
最后别忘记更新CMakeLists.txt
运行测试

可以看到60帧时间差不多是1/60 = 0.01666s
目前我们完成了引擎的两个部分,主循环和精确控制帧率。

浙公网安备 33010602011771号