C++游戏开发之旅 26

问题概述

实现存档的功能,目前游戏,玩家进入新的关卡后状态清零,比如血量回满了。

实现目标,当玩家在第一关扣血了,进入第二关依然是第一关结尾时候的血量状态

解决方案:共享数据

image-20260305154134429

核心思想

  • 创建独立的 SessionData 类存储游戏状态
  • 使用 shared_ptr 在场景间共享数据
  • 数据生命周期独立于场景
  • 实现存档/读档功能
数据共享架构:
┌──────────────────┐
│  SessionData     │ ← 独立的数据对象
│ • current_health │   生命周期长于场景
│ • current_score  │
│ • map_path       │
└────────┬─────────┘
         │ shared_ptr
    ┌────┴────┐
    │         │
    ↓         ↓
┌────────┐┌────────┐
│Level 1 ││Level 2 │ ← GameScene 实例
│Scene   ││Scene   │   共享同一数据
└────────┘└────────┘

章节目标

  • 创建 SessionData 类 --- 存储跨场景的游戏状态
  • 架构重构 --- GameScene 依赖外部数据
  • 使用 shared_ptr --- 安全地共享数据对象
  • 实现存档/读档 --- JSON 序列化功能

第一部分:问题分析与架构设计

之前的游戏设计,我们的生命值和得分等信息是和场景的生命周期绑定的,场景清理后,这些数据自然就没了,而进入一个新场景后,又会创建新对象,玩家的生命值,得分又重置了。

所以为了解决这个问题,我们就需要将玩家的生命值和得分数据从场景中剥离,让其生命周期独立于任何游戏场景。

1.SessionData 类

创建一个新的类,在src/game/data/文件目录下创建SessionData类。

src\game\data\session_data.h

#pragma once
#include <string>
#include <memory>

namespace game::data{
/**
 * @brief 管理不同游戏场景之间的游戏状态
 */
class SessionData{
private:
    int current_health_ = 3;
    int max_health_ = 3;
    int current_score_ = 0;
    int high_score_ = 0;

    // 进入关卡时的状态(用于读/存档)
    int level_health_ = 3;
    int level_score_ = 0;
    std::string map_path_ = "assets/maps/level1.tmj";

public:
    SessionData() = default;
    ~SessionData() = default;

    // 删除复制和移动操作
    SessionData(const SessionData&) = delete;
    SessionData& operator=(const SessionData&) = delete;
    SessionData(SessionData&&) = delete;
    SessionData& operator=(SessionData&&) = delete;

    // get set
    int getCurrentHealth() const { return current_health_; }
    void setCurrentHealth(int health);

    int getMaxHealth() const { return max_health_; }
    void setMaxHealth(int health);

    int getCurrentScore() const { return current_score_; }
    void setCurrentScore(int score) { current_score_ = score; }

    int getHighScore() const { return high_score_; }
    void setHighScore(int score) { high_score_ = score; }

    int getLevelHealth() const { return level_health_; }
    void setLevelHealth(int health) { level_health_ = health; }

    int getLevelScore() const { return level_score_; }
    void setLevelScore(int score) { level_score_ = score; }

    const std::string& getMapPath() const { return map_path_; }
    void setMapPath(const std::string& path) { map_path_ = path; }

    void addScore(int score);
    
    void reset(); // 重置游戏,保存最高分,开始新游戏
    void setNextLevel(const std::string& path); // 设置下一场景信息 地图、关卡开始时的得分生命
    void saveToFile(const std::string& path); // 保存档案
    void loadFromFile(const std::string& path); // 读档

};


}

简单看一下成员变量:

int current_health_ = 3;  // 当前生命值
int level_health_ = 3; // 关卡初始生命值

容易理解,在切换关卡的时候会记录一下生命值快照

src\game\data\session_data.cpp

#include "session_data.h"
#include <glm/common.hpp>
#include <spdlog/spdlog.h>
#include <nlohmann/json.hpp>
#include <fstream>

namespace game::data {
    void SessionData::setCurrentHealth(int health)
    {
        current_health_ = glm::clamp(health, 0, max_health_);
        spdlog::info("Current health set to {}", current_health_);
    }

    void SessionData::setMaxHealth(int health)
    {
        max_health_ = health;
        setCurrentHealth(current_health_); // 确保当前生命值不超过最大生命值
        spdlog::info("Max health set to {}", max_health_);
    }

    void SessionData::addScore(int score)
    {
        current_score_ += score;
        if(current_score_ > high_score_){
            high_score_ = current_score_;
        }
    }

    void SessionData::reset()
    {
        current_health_ = max_health_;
        current_score_ = 0;

        level_health_ = current_health_;
        level_score_ = 0;

        map_path_ = "assets/maps/level1.tmj";
        spdlog::info("SessionData reset.");
    }

    void SessionData::setNextLevel(const std::string &path)
    {
        map_path_ = path;
        // 快照当前状态,作为下一个关卡的初始状态
        level_health_ = current_health_;
        level_score_ = current_score_;
    }

    bool SessionData::saveToFile(const std::string &file_name)
    {
        // 存档函数 我们需要序列化当前状态到json文件中
        nlohmann::json json_data;
        try {
            json_data["current_health"] = current_health_;
            json_data["max_health"] = max_health_;
            json_data["current_score"] = current_score_;
            json_data["high_score"] = high_score_;
            json_data["map_path"] = map_path_;

            // 将json数据写入文件
            std::ofstream file(file_name);
            if (!file.is_open()) {
                spdlog::error("无法打开文件 {} 进行写入");
                return false;
            }
            file << json_data.dump(4); // 使用缩进为4的格式化输出
            file.close();
            spdlog::info("SessionData saved to file {}", file_name);
            return true;
        } catch (const std::exception &e) {
            spdlog::error("保存SessionData到文件时发生错误: {}", e.what());
            return false;
        }
    }

    bool SessionData::loadFromFile(const std::string &file_name)
    {
        // 从文件中加载存档
        std::ifstream file(file_name);
        if (!file.is_open()) {
            spdlog::error("无法打开文件 {} 进行读取", file_name);
            return false;
        }
        nlohmann::json json_data;
        file >> json_data;
        file.close();
        try {
            current_health_ = json_data["current_health"];
            max_health_ = json_data["max_health"];
            current_score_ = json_data["current_score"];
            high_score_ = json_data["high_score"];
            map_path_ = json_data["map_path"];

            spdlog::info("SessionData loaded from file {}", file_name);
            return true;
        } catch (const std::exception &e) {
            spdlog::error("加载SessionData从文件时发生错误: {}", e.what());
            reset();
            return false;
        }
    }
    
}

核心方法解析

setNextLevel 方法
setNextLevel 工作流程:
    ↓
1. 更新地图路径
    map_path_ = "level2.tmj"
    ↓
2. 快照当前状态
    level_health_ = current_health_
    level_score_ = current_score_
    ↓
3. 这些值成为下一关的初始值
    下一关从这个状态开始
存档功能
saveToFile 流程:
    ↓
1. 创建 JSON 对象
    nlohmann::json j
    ↓
2. 填充数据
    j["level_health"] = level_health_
    j["level_score"] = level_score_
    ...
    ↓
3. 格式化输出
    j.dump(4)  ← 4空格缩进
    ↓
4. 写入文件
    ofstream → save.json
读档功能
loadFromFile 流程:
    ↓
1. 打开文件
    ifstream(filename)
    ↓
2. 解析 JSON
    ifs >> j
    ↓
3. 读取数据(带默认值)
    j.value("level_health", 3)
    ↓
4. 同步 current 和 level
    current_health_ = level_health_
    ↓
数据恢复完成

第二部分:架构重构

现在我们需要让GameScene使用这个共享数据对象。

3.改造GameScene

现在,GameScene会传入一个SessionData对象,因为场景对象间是可以同时指向这个会话对象的,所以std::shared_ptr是我们的最佳选择。

src/game/scene/game_scene.h

#include <memory> // For std::shared_ptr

namespace game::data {
    class SessionData;
}

class GameScene final: public engine::scene::Scene {
    // 场景间共享数据,因此用shared_ptr
    std::shared_ptr<game::data::SessionData> game_session_data_;
    // ...
public:
    // 构造函数不再需要 name,而是接收一个 SessionData 智能指针
    GameScene(engine::core::Context& context, 
              engine::scene::SceneManager& scene_manager, 
              std::shared_ptr<game::data::SessionData> data = nullptr);
    // ...
};

// game_scene.cpp
GameScene::GameScene(engine::core::Context &context, engine::scene::SceneManager &sceneManager, std::shared_ptr<data::SessionData> data)
    : engine::scene::Scene("GameScene", context, sceneManager), game_session_data_(std::move(data))
    {
        if(game_session_data_ == nullptr){
            game_session_data_ = std::make_shared<data::SessionData>();
            spdlog::info("未提供 SessionData,使用默认值");
        }
        spdlog::trace("GameScene 构造完成");
    }

当第一次也就是在GameApp中创建对象的时候,我们没有传入data,那么,其就会在构造函数中自己创建一个,这时引用计数为1,等到切换场景的时候,创建了新场景,引用计数为2,下一帧销毁场景时,引用计数减1,数据安全保留。这样实现了数据对象在场景切换中的幸存。

智能指针 所有权 适用场景
unique_ptr 独占 单一所有者
shared_ptr 共享 多个所有者(跨场景数据)
weak_ptr 观察 避免循环引用

4.SessionData

现在,我们吃到宝石可以加分,就可以调用game_session_data_addscore函数,切换场景的时候可以通过setMapPath设置其地图文件,initLevel加载地图的时候getMapPath获得路径。

这样我们一个简单的保存存档就完成了。

场景切换

void GameScene::toNextLevel(engine::object::GameObject *trigger)
    {
        auto scene_name = trigger->getName();
        // 保存数据
        auto map_path = levelNameToPath(scene_name);
        game_session_data_->setMapPath(map_path);

        // 加载下一关
        auto scene = std::make_unique<game::scene::GameScene>(context_, sceneManager_, game_session_data_);
        sceneManager_.requestReplaceScene(std::move(scene));
    }

编译运行

数据在关卡间正确保留。

章节总结

这节我们定义了一个数据共享架构,SessionData,其有独立的生命周期,通过共享指针实现跨场景传输数据。

遇到的问题

1.共享指针的优化,这其实并不算是问题,只是在学的时候突然遇到了,所以讲一下加强理解

这节中我们有一个SessionData类用于共享游戏数据,我们在GameScene中定义了共享指针shared_ptr,这使得可以由多个场景指向这个类,我们可以将其充当变量用于创建场景,这是其构造函数:

GameScene::GameScene(engine::core::Context &context, engine::scene::SceneManager &sceneManager, std::shared_ptr<data::SessionData> data)
    : engine::scene::Scene("GameScene", context, sceneManager), game_session_data_(std::move(data))
    {
        if(game_session_data_ == nullptr){
            game_session_data_ = std::make_shared<data::SessionData>();
            spdlog::info("未提供 SessionData,使用默认值");
        }
        spdlog::trace("GameScene 构造完成");
    }
auto scene = std::make_unique<game::scene::GameScene>(context_, sceneManager_, game_session_data_);
sceneManager_.requestReplaceScene(std::move(scene));

我们在调用的时候是进行的值传递,所以会先拷贝一份,使得当前的引用计数加1,变为2,而再进行构造函数的时候呢,会进行移动,移交所有权,引用计数不变,这使得当前引用计数依然为2,在执行完构造函数后,data形参析构,因为其指向是空的,所以引用计数不会减少,依然是2,直到等到下一帧确实移除了场景后,共享指针减1,最终变为1。

这种传值+std::move很安全。

考虑一下引用+拷贝,传参是别名,计数不变,构造是拷贝构造,加1,下一帧销毁场景减1,计数最终为1;引用+std::move 这可能就会出问题,传参时,计数不变,构造是移动构造,旧的置空了,新的指向直接把旧的拿过来了,计数不变,销毁场景不变,因为里面是nullptr,不减计数,还是1,但是销毁是在下一帧,这一帧内可能会有风险。

2.关于初始化列表的一些思考

这个问题遗留的其实挺久的,这里做一个说明补充,其实在课程刚开始我有很多地方不了解,当时甚至连前向声明这块都不熟悉,我依稀记得因为SDL3的render、window是结构体struct而不是class类,我还半天找不到问题出在哪里?而这个类构造函数初始化列表我也一知半解。C++类成员的 初始化赋值 是两回事

初始化(初始化列表):对象 从无到有

  • 时机:类对象创建时,先执行初始化列表,再执行构造函数体;
  • 本质:为成员变量「分配内存并设置初始值」,是成员变量的「第一次初始化」;
  • 例子:session_data_(std::move(data)) → 直接用std::move(data)作为「原材料」,创建session_data_这个shared_ptr对象。

赋值(构造函数体):对象「从有到改」

  • 时机:初始化列表执行完毕后,在构造函数体内部;
  • 本质:成员变量已经创建完成,只是「修改它的值」;
  • 例子:
// 错误示范(赋值):先默认构造session_data_,再赋值
GameScene::GameScene(..., shared_ptr<SessionData> data) {
    session_data_ = std::move(data); // 赋值操作
}

而我们初始化列表的本质:跳过默认构造 + 赋值,直接用参数构造目标对象,这样既省性能,又保证对象从创建开始就是期望的状态

posted @ 2026-03-07 21:35  wenyiGamecpp  阅读(42)  评论(0)    收藏  举报