C++游戏开发之旅 26
问题概述
实现存档的功能,目前游戏,玩家进入新的关卡后状态清零,比如血量回满了。
实现目标,当玩家在第一关扣血了,进入第二关依然是第一关结尾时候的血量状态
解决方案:共享数据

核心思想:
- 创建独立的 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); // 赋值操作
}
而我们初始化列表的本质:跳过默认构造 + 赋值,直接用参数构造目标对象,这样既省性能,又保证对象从创建开始就是期望的状态

浙公网安备 33010602011771号