C++游戏开发之旅 4
问题背景:
随着引擎功能的完善,问题也凸显出来了:目前我们的许多核心参数,比如窗口大小、标题、目标帧率等,都是直接写在代码里的,这种硬编码的方式极大的限制了灵活性,我们想要修改某些参数都要修改代码然后重新编译。
为了解决这个问题,我们需要设计一个配置管理系统。创建一个Config类,负责从外部的config.json文件中读取所有配置。这不仅让调整参数变得轻而易举,也可以让玩家自定义游戏设置(如画质、音量、键位)。

1.config.json
首先,我们在assets目录下创建一个名为config.json的新文件,我们需要通过这个文件对引擎的一些参数进行设置。json文件很简单:其实就是一堆数据,其数据结构是以键值对(key: value)组成,支持嵌套,内容用{}或[]包裹(对象或数组)
{
"window": {
"title": "SunnyLand",
"width": 1280,
"height": 720,
"resizable": true
},
"graphics": {
"vsync": true
},
"performance": {
"target_fps": 60
},
"auido": {
"music_volume": 0.5,
"sound_volume": 0.5
},
"input_mappings": {
"move_up": [
"W",
"Up"
],
"move_left": [
"A",
"Left"
],
"move_down": [
"S",
"Down"
],
"move_right": [
"D",
"Right"
],
"pause": [
"P",
"Escape"
],
"attack": [
"K",
"MouseLeft"
],
"jump": [
"J",
"Space"
]
}
}
配置文件包含内容
看看这个配置文件中都有什么?
- 窗口window : 标题、尺寸、是否可调整大小
- 图形选项 : 垂直同步(VSync)
- 性能设置 : 音乐音量、音效音量
- 输入映射 : 未来按键绑定配置
2.Config类
在engine/core目录中创建一个新的Config类。
核心功能
- 程序启动时,让其加载
config.json文件 - 将JSON中的数据解析并存储到类的成员变量中
- 提供了一个
saveToFlie方法,以便在需要时(玩家修改了设置)将当前配置写回文件
健壮性设计
如果
config.json不存在,它会自动用默认值创建一个,如果文件内容损坏或缺少某些字段,使用预设的默认值,保证程序的运行。
config.h
#pragma once
#include <string>
#include <vector>
#include <unordered_map>
#include <nlohmann/json_fwd.hpp>
namespace engine::core
{
class Config final {
public:
// 为了方便拓展,设置为public
// 窗口设置
std::string window_title_ = "SunnyLand";
int window_width_ = 1280;
int window_height_ = 720;
bool window_resizable_ = false;
// 图形设置
bool vsync_enabled_ = true; // 是否启用垂直同步
// 性能设置
int target_fps_ = 60; // 目标帧率
// 音频设置
float music_volume_ = 0.5f; // 音乐音量
float sound_volume_ = 0.5f; // 音效音量
std::unordered_map<std::string, std::vector<std::string>> input_mappings_ = {
{"move_left", {"A", "Left"}},
{"move_right", {"D", "Right"}},
{"move_up", {"W", "Up"}},
{"move_down", {"S", "Down"}},
{"jump", {"Space"}},
{"attack", {"J"}},
{"pause",{"P","Escape"}},
// 添加更多输入映射
};
explicit Config(const std::string& config_file_path); // 构造函数,从配置文件中加载设置
// 删除拷贝和移动语义
Config(const Config&) = delete;
Config& operator=(const Config&) = delete;
Config(Config&&) = delete;
Config& operator=(Config&&) = delete;
bool loadFromFile(const std::string& config_file_path); // 从json文件加载配置, 成功返回true
[[nodiscard]] bool saveToFile(const std::string& config_file_path); // 将配置保存到json文件, 成功返回true
private:
void fromJson(const nlohmann::json& json); // 从json对象中加载配置
nlohmann::ordered_json toJson() const; // 将配置保存到json对象中
};
} // namespace engine::core
Config类的头文件定义了相关可配置的选项作为公有成员变量,并赋予默认值,这样引擎的其他部分可以很直观的通过config->window_width_这样来访问配置项。
config.cpp
#include "config.h"
#include <fstream>
#include <spdlog/spdlog.h>
#include <nlohmann/json.hpp>
namespace engine::core {
Config::Config(const std::string &config_file_path)
{
loadFromFile(config_file_path);
}
bool Config::loadFromFile(const std::string &config_file_path)
{
std::ifstream file(config_file_path);
if (!file.is_open()){
spdlog::warn("配置文件{}不存在,使用默认的设置进行创建配置文件.",config_file_path);
if(!saveToFile(config_file_path)){
spdlog::error("保存配置文件失败 {}.",config_file_path);
return false;
}
return false;
}
try {
nlohmann::json j;
file >> j;
fromJson(j);
} catch (const std::exception& e) {
spdlog::error("配置文件解析失败: {}", e.what());
return false;
}
return true;
}
bool Config::saveToFile(const std::string &config_file_path)
{
std::ofstream file(config_file_path);
if (!file.is_open()){
spdlog::error("无法打开配置文件 {}.",config_file_path);
return false;
}
try {
nlohmann::ordered_json j = toJson();
file << j.dump(4);
spdlog::info("配置文件 {} 保存成功.",config_file_path);
return true;
} catch (const std::exception& e) {
spdlog::error("配置文件保存失败: {}", e.what());
return false;
}
return false;
}
void Config::fromJson(const nlohmann::json &j)
{
// TODO: 解析json
if(j.contains("window")) {
const auto& window_config = j["window"];
window_title_ = window_config.value("title", window_title_);
window_width_ = window_config.value("width", window_width_);
window_height_ = window_config.value("height", window_height_);
window_resizable_ = window_config.value("resizable", window_resizable_);
}
if(j.contains("graphics")){
const auto& graphics_config = j["graphics"];
vsync_enabled_ = graphics_config.value("vsync", vsync_enabled_);
}
if(j.contains("performance")){
const auto& performance_config = j["performance"];
target_fps_ = performance_config.value("target_fps", target_fps_);
}
if(j.contains("audio")){
const auto& audio_config = j["audio"];
music_volume_ = audio_config.value("music_volume", music_volume_);
sound_volume_ = audio_config.value("sound_volume", sound_volume_);
}
if(j.contains("input_mappings") && j["input_mappings"].is_object()){
const auto& input_mappings_config = j["input_mappings"];
try {
// 直接尝试从 JSON 对象转换为unordered_map<string, std::vector<string>>
auto input_mappings = input_mappings_config.get<std::unordered_map<std::string, std::vector<std::string>>>();
input_mappings_ = std::move(input_mappings);
spdlog::trace("输入映射已加载");
} catch (const std::exception& e) {
spdlog::warn("输入映射解析失败: {}", e.what());
}
} else {
spdlog::warn("输入映射未找到或格式不正确,使用默认设置");
}
}
nlohmann::ordered_json Config::toJson() const
{
return nlohmann::ordered_json{
{"window", {
{"title", window_title_},
{"width", window_width_},
{"height", window_height_},
{"resizable", window_resizable_}
}},
{"graphics", {
{"vsync", vsync_enabled_}
}},
{"performance", {
{"target_fps", target_fps_}
}},
{"audio", {
{"music_volume", music_volume_},
{"sound_volume", sound_volume_}
}},
{"input_mappings", input_mappings_}
};
}
}
关键方法
-
loadFromFile() - 尝试打开并解析JSON文件,如果打不开,则调用
saveToFile()来生成一个包含默认设置的新配置文件。 -
saveToFile() - 调用
toJson()来将当前对象的状态序列化,然后写入文件 -
fromJson() - 核心解析逻辑。它使用
.value("key",default_value)这种安全的方式从JSON对象中提取数据。即使JSON文件中缺少某个字段,也能保证程序的正常运行(default_value默认初始值) -
toJson() - 将所有的默认成员变量打包成
nlohmann::ordered_json对象,以便以固定的顺序写入文件,增加可读性
3.集成到GameApp
现在我们可以将新的Config模块插入到主应用程序GameApp中。当然我们要注意初始化的顺序,因为initSDL()、initTime()、initCamera()都需要使用到相关的配置变量,所以我们刚开始就应该先初始化initConfig()。
新初始化流程
// game_app.h
class Config;
class GameApp final {
private:
std::unique_ptr<engine::core::Config> config_;
private:
[[nodiscard]] bool initConfig();
}
移除硬编码
// game_app.cpp
bool GameApp::init() {
if(!initConfig()) return false;
}
bool GameApp::initConfig()
{
try {
config_ = std::make_unique<engine::core::Config>("assets/config.json");
} catch (const std::exception& e){
spdlog::error("初始化Config失败: {}", e.what());
return false;
}
return true;
}
bool GameApp::initSDL()
{
// SDL初始化
if(!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO)){
spdlog::error("SDL初始化失败");
return false;
}
window_ = SDL_CreateWindow(config_->window_title_.c_str(), config_->window_width_,config_->window_height_, SDL_WINDOW_RESIZABLE);
if(!window_){
spdlog::error("SDL窗口创建失败");
return false;
}
sdl_renderer_ = SDL_CreateRenderer(window_, nullptr);
if(!sdl_renderer_){
spdlog::error("SDL渲染器创建失败");
return false;
}
// 设置 VSync (注意 vsync 开启时, 驱动程序可能会尝试将帧率限制到显示器刷新率,可能会覆盖我们手动设置的 target_fps)
int vsync_mode = config_->vsync_enabled_ ? SDL_RENDERER_VSYNC_ADAPTIVE : SDL_RENDERER_VSYNC_DISABLED;
SDL_SetRenderVSync( sdl_renderer_, vsync_mode );
spdlog::trace("VSync 设置为{}", config_->vsync_enabled_ ? "Enabled" : "Disabled");
// 设置渲染器逻辑分辨率为窗口的一半
SDL_SetRenderLogicalPresentation(sdl_renderer_, config_->window_width_/2, config_->window_height_/2, SDL_LOGICAL_PRESENTATION_LETTERBOX); // 设置渲染器逻辑分辨率
spdlog::trace("SDL初始化成功");
return true;
}
bool GameApp::initTime()
{
try {
time_ = std::make_unique<Time>();
}catch (const std::exception& e){
spdlog::error("初始化Time失败: {}", e.what());
return false;
}
time_->setTargetFPS(config_->target_fps_);
spdlog::trace("初始化Time成功");
return true;
}
bool GameApp::initCamera()
{
try {
camera_ = std::make_unique<engine::render::Camera>(glm::vec2(config_->window_width_/2, config_->window_height_/2));
} catch (const std::exception& e){
spdlog::error("初始化Camera失败: {}", e.what());
return false;
}
return true;
}
我们需要将原来硬编码的部分替换成配置文件中的变量,主要有:
-
initSDL() 中
SDL_CreateWindow需要config_->window_title、config_->window_width_,垂直同步开关config_->vsync_enabled_,逻辑分辨率SDL_SetRenderLogicalPresentation为窗口大小的一半 -
initTime(),
time_->setTargetFps()需要设置帧率 -
initCamera(),初始化构造函数需要用到
viewport_size,因为我们的视口大小和逻辑分辨率一样,以便适应
4.编译与测试
确保config.cpp添加到CMakeLists.txt中。
测试----->
1.输入一个不存在的配置 json 文件看是否会生成配置文件。
2.修改配置文件中的内容,然后观察生成的窗口变化
总结
这章使我们学会了不受制与死代码里的参数,极大提高了设置的灵活性
遇到的问题
-
在保存配置JSON文件时遇到了文件中的顺序并不是按顺序的,后面发现是在
saveToFile()中的问题,我在编写的时候是定义了一个nlohmann::json j = toJson(),而函数toJson()返回的是nlohmann::ordered_json,这样的话,会进行默认的转换,导致输出的配置文件顺序会不一致。 -
垂直同步相关内容,垂直同步(VSync)解决的是"显示器刷新"和"游戏出帧"不同步的问题。那什么是显示器刷新率?一般显示器不是"连续显示画面"的,是一帧一帧刷出来的,它差不多是这样,刷新一次 = 显示器从上到下扫描,把一整张画面更新一遍,比如60hz 显示器,大概16.67ms刷一次,而游戏的帧率FPS表示每秒能画出多少张新画面,比如我120FPS那就是8.33ms一张画面。这就有问题了,刷新率和帧率不一致了,这样就有问题了,比如游戏画的比屏幕快,就有画面撕裂(Tearing)(显示器正在从上往下画屏幕时,游戏突然把显示内容换成下一帧),屏幕上半部分是旧帧,下半是新帧,就有"断裂感",垂直同步 = 让“换帧显示”必须等到显示器刷新完成的那个瞬间再换。而我们代码中设置就是为了解决这个问题,
SDL_RENDERER_VSYNC_DISABLED(关闭 VSync)特点:不等显示器刷新,出一帧就立刻显示(能多快多快),延迟低(操作更跟手),容易撕裂SDL_RENDERER_VSYNC_ADAPTIVE(自适应 VSync),可以理解成:当你的 FPS ≥ 刷新率(比如能稳定 60+)
类似 “开启 VSync”,画面平滑不撕裂,当你的 FPS < 刷新率(比如只有 45),类似 “关闭 VSync”,让画面尽快显示,减少卡顿感。
总结起来就是不开:更跟手,但可能撕裂, 开:更干净,但可能更顿、更有延迟,因为撕裂感主要是换帧的时机不对,就算游戏帧率小于屏幕刷新也有可能造成。

浙公网安备 33010602011771号