C++游戏开发之旅 4

问题背景:

随着引擎功能的完善,问题也凸显出来了:目前我们的许多核心参数,比如窗口大小、标题、目标帧率等,都是直接写在代码里的,这种硬编码的方式极大的限制了灵活性,我们想要修改某些参数都要修改代码然后重新编译。

为了解决这个问题,我们需要设计一个配置管理系统。创建一个Config类,负责从外部的config.json文件中读取所有配置。这不仅让调整参数变得轻而易举,也可以让玩家自定义游戏设置(如画质、音量、键位)。


image-20260118145139706


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_titleconfig_->window_width_,垂直同步开关config_->vsync_enabled_,逻辑分辨率SDL_SetRenderLogicalPresentation为窗口大小的一半

  • initTime()time_->setTargetFps()需要设置帧率

  • initCamera(),初始化构造函数需要用到viewport_size,因为我们的视口大小和逻辑分辨率一样,以便适应

4.编译与测试

确保config.cpp添加到CMakeLists.txt中。

测试----->

1.输入一个不存在的配置 json 文件看是否会生成配置文件。

2.修改配置文件中的内容,然后观察生成的窗口变化

总结

这章使我们学会了不受制与死代码里的参数,极大提高了设置的灵活性

遇到的问题

  1. 在保存配置JSON文件时遇到了文件中的顺序并不是按顺序的,后面发现是在saveToFile()中的问题,我在编写的时候是定义了一个nlohmann::json j = toJson(),而函数toJson()返回的是nlohmann::ordered_json,这样的话,会进行默认的转换,导致输出的配置文件顺序会不一致。

  2. 垂直同步相关内容,垂直同步(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”,让画面尽快显示,减少卡顿感。

总结起来就是不开:更跟手,但可能撕裂开:更干净,但可能更顿、更有延迟,因为撕裂感主要是换帧的时机不对,就算游戏帧率小于屏幕刷新也有可能造成。

posted @ 2026-01-25 10:36  wenyiGamecpp  阅读(63)  评论(0)    收藏  举报