保存和加载游戏
如何使用 Qt 的 JSON 或 CBOR 类保存和加载游戏。
许多游戏都提供了保存功能,以便将玩家的游戏进度保存下来,以便日后加载。保存游戏的过程通常涉及将每个游戏对象的成员变量序列化到文件中。为此可以使用多种格式,其中之一就是 JSON。 借助QJsonDocument ,您还可以将文档序列化为CBOR格式。如果您不希望保存文件易于被读取(但请参阅“解析和显示CBOR数据”以了解如何读取),或者需要控制文件大小,这将是一个绝佳的选择。
在本示例中,我们将演示如何将一个简单游戏保存为 JSON 和二进制格式,以及如何从这些格式中加载该游戏。
角色类
Character 类代表游戏中的非玩家角色(NPC),并存储该角色的名称、等级和职业类型。
它提供了静态函数 fromJson() 和非静态函数 toJson() 来对自身进行序列化。
注意:这种 模式(fromJson()/toJson())之所以可行,是因为 QJsonObject 可以独立于拥有它的 `QJsonDocument` 进行构造,并且此处进行(反)序列化的数据类型是值类型,因此可以被复制。 当序列化为其他格式时——例如 XML 或QDataStream ,这些格式需要传递类似文档的对象——或者当对象的身份很重要时(例如QObject 的子类),其他模式可能更合适。 请参阅dombookmarks示例(针对 XML),以及QListWidgetItem::read() 和QListWidgetItem::write() 的实现(用于符合惯例的QDataStream 序列化)。本示例中的print() 函数是QTextStream 序列化的良好范例,尽管它们当然缺少反序列化部分。
class Character
{
Q_GADGET
public:
enum ClassType { Warrior, Mage, Archer };
Q_ENUM(ClassType)
Character();
Character(const QString &name, int level, ClassType classType);
QString name() const;
void setName(const QString &name);
int level() const;
void setLevel(int level);
ClassType classType() const;
void setClassType(ClassType classType);
static Character fromJson(const QJsonObject &json);
QJsonObject toJson() const;
void print(QTextStream &s, int indentation = 0) const;
private:
QString mName;
int mLevel = 0;
ClassType mClassType = Warrior;
};我们特别关注的是 fromJson() 和 toJson() 函数的实现:
Character Character::fromJson(const QJsonObject &json)
{
Character result;
if (const QJsonValue v = json["name"]; v.isString())
result.mName = v.toString();
if (const QJsonValue v = json["level"]; v.isDouble())
result.mLevel = v.toInt();
if (const QJsonValue v = json["classType"]; v.isDouble())
result.mClassType = ClassType(v.toInt());
return result;
}QJsonValue::Undefined在 fromJson() 函数中,我们创建了一个本地 `result ` 字符对象,并根据 `QJsonObject ` 参数为 `result` 的成员赋值。 您可以使用QJsonObject::operator[]() 或QJsonObject::value() 来访问 JSON 对象中的值;这两个都是 const 函数,如果键无效,则返回QJsonValue::Undefined 。特别是,is... 函数(例如QJsonValue::isString()、QJsonValue::isDouble())在键不存在时会返回false ,因此我们可以在一次查找中同时检查键的存在性及其类型是否正确。
如果某个值在 JSON 对象中不存在,或者类型不正确,我们也不会向相应的result 成员写入数据,从而保留默认构造函数可能设置的任何值。这意味着默认值在单一位置(默认构造函数)集中定义,无需在序列化代码中重复(DRY)。
请注意这里使用了C++17的if-with-initializer语句,将变量v 的作用域限定与检查分离。这意味着我们可以保持变量名简短,因为其作用域是有限的。
将其与使用QJsonObject::contains() 的简单粗暴方法进行对比:
if (json.contains("name") && json["name"].isString())
result.mName = json["name"].toString();这种写法不仅可读性较差,还总共需要进行三次查找(不,编译器不会将其优化为一次),因此速度慢三倍,并且重复了三次"name" (违反了DRY原则)。
QJsonObject Character::toJson() const
{
QJsonObject json;
json["name"] = mName;
json["level"] = mLevel;
json["classType"] = mClassType;
return json;
}在 toJson() 函数中,我们执行与 fromJson() 函数相反的操作:将 Character 对象中的值赋给一个新的 JSON 对象,然后将其返回。与访问值类似,在QJsonObject 上设置值有两种方法:QJsonObject::operator[]() 和QJsonObject::insert()。这两种方法都会覆盖给定键下的任何现有值。
Level 类
class Level
{
public:
Level() = default;
explicit Level(const QString &name);
QString name() const;
QList<Character> npcs() const;
void setNpcs(const QList<Character> &npcs);
static Level fromJson(const QJsonObject &json);
QJsonObject toJson() const;
void print(QTextStream &s, int indentation = 0) const;
private:
QString mName;
QList<Character> mNpcs;
};我们希望游戏中的每个关卡都包含多个NPC,因此我们维护一个由Character对象组成的QList 。我们还提供了大家熟悉的fromJson()和toJson()函数。
Level Level::fromJson(const QJsonObject &json)
{
Level result;
if (const QJsonValue v = json["name"]; v.isString())
result.mName = v.toString();
if (const QJsonValue v = json["npcs"]; v.isArray()) {
const QJsonArray npcs = v.toArray();
result.mNpcs.reserve(npcs.size());
for (const QJsonValue &npc : npcs)
result.mNpcs.append(Character::fromJson(npc.toObject()));
}
return result;
}可以通过QJsonArray 将数据写入或从JSON中读取。在我们的示例中,我们根据键"npcs" 对应的值构建一个QJsonArray 。然后,对于数组中的每个QJsonValue 元素,我们调用toObject()来获取该Character的JSON对象。 随后,Character::fromJson() 可以将该 QJSonObject 转换为 Character 对象,并将其追加到我们的 NPC 数组中。
注意: 关联容器可以通过在每个值对象中存储键(如果尚未存储)来实现。采用这种方法,容器将作为常规的对象数组存储,但在回读时,每个元素的索引将作为键用于构建容器。
QJsonObject Level::toJson() const
{
QJsonObject json;
json["name"] = mName;
QJsonArray npcArray;
for (const Character &npc : mNpcs)
npcArray.append(npc.toJson());
json["npcs"] = npcArray;
return json;
}同样,toJson() 函数与 fromJson() 函数类似,只是操作顺序相反。
Game 类
在定义了 Character 和 Level 类之后,我们可以继续定义 Game 类:
class Game
{
public:
enum SaveFormat { Json, Binary };
Character player() const;
QList<Level> levels() const;
void newGame();
bool loadGame(SaveFormat saveFormat);
bool saveGame(SaveFormat saveFormat) const;
void read(const QJsonObject &json);
QJsonObject toJson() const;
void print(QTextStream &s, int indentation = 0) const;
private:
Character mPlayer;
QList<Level> mLevels;
};首先,我们定义SaveFormat 枚举。这将允许我们指定游戏的保存格式:Json 或Binary 。
接下来,我们为玩家和关卡提供访问器。然后,我们公开三个函数:newGame()、saveGame() 和 loadGame()。
read() 和 toJson() 函数由 saveGame() 和 loadGame() 调用。
注意:尽管 `Game ` 是一个值类,但我们假设作者希望游戏具有“身份”,就像你的主窗口一样。 因此,我们不使用会创建新对象的静态 fromJson() 函数,而是使用可以对现有对象调用的 read() 函数。read() 和 fromJson() 之间存在 1:1 的对应关系,即一个可以基于另一个来实现:
void read(const QJsonObject &json) { *this = fromJson(json); }
static Game fromObject(const QJsonObject &json) { Game g; g.read(json); return g; }我们只是根据函数调用者的实际需求,选择更方便的方式。
void Game::newGame()
{
mPlayer = Character();
mPlayer.setName("Hero"_L1);
mPlayer.setClassType(Character::Archer);
mPlayer.setLevel(QRandomGenerator::global()->bounded(15, 21));
mLevels.clear();
mLevels.reserve(2);
Level village("Village"_L1);
QList<Character> villageNpcs;
villageNpcs.reserve(2);
villageNpcs.append(Character("Barry the Blacksmith"_L1,
QRandomGenerator::global()->bounded(8, 11), Character::Warrior));
villageNpcs.append(Character("Terry the Trader"_L1,
QRandomGenerator::global()->bounded(6, 8), Character::Warrior));
village.setNpcs(villageNpcs);
mLevels.append(village);
Level dungeon("Dungeon"_L1);
QList<Character> dungeonNpcs;
dungeonNpcs.reserve(3);
dungeonNpcs.append(Character("Eric the Evil"_L1,
QRandomGenerator::global()->bounded(18, 26), Character::Mage));
dungeonNpcs.append(Character("Eric's Left Minion"_L1,
QRandomGenerator::global()->bounded(5, 7), Character::Warrior));
dungeonNpcs.append(Character("Eric's Right Minion"_L1,
QRandomGenerator::global()->bounded(4, 9), Character::Warrior));
dungeon.setNpcs(dungeonNpcs);
mLevels.append(dungeon);
}要设置一个新游戏,我们会创建玩家,并填充关卡及其NPC。
void Game::read(const QJsonObject &json)
{
if (const QJsonValue v = json["player"]; v.isObject())
mPlayer = Character::fromJson(v.toObject());
if (const QJsonValue v = json["levels"]; v.isArray()) {
const QJsonArray levels = v.toArray();
mLevels.clear();
mLevels.reserve(levels.size());
for (const QJsonValue &level : levels)
mLevels.append(Level::fromJson(level.toObject()));
}
}read() 函数首先将当前玩家替换为从 JSON 中读取的玩家。然后我们调用 clear() 清空关卡数组,这样当对同一个 Game 对象两次调用 loadGame() 时,就不会残留旧的关卡。
接着,我们通过从QJsonArray 中读取每个Level对象,来填充关卡数组。
QJsonObject Game::toJson() const
{
QJsonObject json;
json["player"] = mPlayer.toJson();
QJsonArray levels;
for (const Level &level : mLevels)
levels.append(level.toJson());
json["levels"] = levels;
return json;
}将游戏写入 JSON 的过程与写入关卡类似。
boolGame::loadGame(Game::SaveFormat saveFormat)
{
QFile loadFile(saveFormat==Json? "save.json"_L1 :"save.dat"_L1);
if(!loadFile.open(QIODevice::ReadOnly)) {
qWarning("Couldn't open save file.");
return false;
}
QByteArray saveData=loadFile.readAll();
QJsonDocument loadDoc(saveFormat==Json
?QJsonDocument::fromJson(saveData)
: QJsonDocument(QCborValue::fromCbor(saveData).toMap().toJsonObject()));
read(loadDoc.object());
QTextStream(stdout)<< "已加载 " <<loadDoc["player"]["name"].toString()
<< " 的存档 ,使用 " <<(saveFormat!=Json? "CBOR":"JSON")<< "...\n";
return true;
}在 loadGame() 中加载存档时,我们首先根据存档的存储格式打开存档文件;如果是 JSON 格式,则使用 `"save.json" `;如果是 CBOR 格式,则使用 `"save.dat" `。如果无法打开文件,我们会输出警告并返回 `false `。
由于QJsonDocument::fromJson() 和QCborValue::fromCbor() 都接受QByteArray 作为参数,因此无论保存格式如何,我们都可以将保存文件的全部内容读取到该对象中。
构建好QJsonDocument 对象后,我们指示 Game 对象读取自身,然后返回true 来表示操作成功。
boolGame::saveGame(Game::SaveFormat saveFormat)const
{
QFile saveFile(saveFormat==Json? "save.json"_L1 :"save.dat"_L1);
if(!saveFile.open(QIODevice::WriteOnly)) {
qWarning("Couldn't open save file.");
return false;
}
QJsonObject gameObject=toJson();
saveFile.write(saveFormat==Json?QJsonDocument(gameObject).toJson()
: QCborValue::fromJsonValue(gameObject).toCbor());
return true;
}不出所料,saveGame() 与 loadGame() 非常相似。我们根据格式确定文件扩展名,若打开文件失败,则输出警告并返回 `false `。 随后,我们将 Game 对象写入QJsonObject 。为了按指定的格式保存游戏,我们会将 JSON 对象转换为QJsonDocument (以便后续调用QJsonDocument::toJson()),或者转换为QCborValue (以便调用QCborValue::toCbor())。
整合所有内容
现在我们可以进入 main() 函数了:
int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
const QStringList args = QCoreApplication::arguments();
const bool newGame
= args.size() <= 1 || QString::compare(args[1], "load"_L1, Qt::CaseInsensitive) != 0;
const bool json
= args.size() <= 2 || QString::compare(args[2], "binary"_L1, Qt::CaseInsensitive) != 0;
Game game;
if (newGame)
game.newGame();
else if (!game.loadGame(json ? Game::Json : Game::Binary))
return 1;
// Game is played; changes are made...由于我们仅旨在演示使用JSON对游戏进行序列化,因此这款游戏实际上无法运行。因此,我们只需QCoreApplication ,且无需事件循环。在应用程序启动时,我们会解析命令行参数以决定如何启动游戏。 对于第一个参数,可选选项为“new”(默认)和“load”。指定“new”时将生成新游戏;指定“load”时将加载之前保存的游戏。 对于第二个参数,可选选项为“json”(默认)和“binary”。该参数将决定从哪个文件加载数据以及/或将数据保存到哪个文件。随后,我们假设玩家玩得很尽兴并取得了很大进展,从而更改了 Character、Level 和 Game 对象的内部状态。
QTextStream s(stdout);
s << "Game ended in the following state:\n";
game.print(s);
if (!game.saveGame(json ? Game::Json : Game::Binary))
return 1;
return 0;
}当玩家结束游戏时,我们会保存其游戏进度。 为了演示目的,我们可以将数据序列化为 JSON 或 CBOR 格式。你可以在可执行文件所在的同一目录中查看文件内容(或者重新运行示例,并确保同时指定“load”选项),不过二进制存档文件中会包含一些垃圾字符(这是正常的)。
至此,本示例结束。如您所见,使用 Qt 的 JSON 类进行序列化非常简单且方便。例如,与QDataStream 相比,使用QJsonDocument 及其相关函数的优势在于:您不仅可以获得可供人类阅读的 JSON 文件,而且在需要时还可以选择使用二进制格式,而无需重写任何代码。
另请参阅 Qt 中的 JSON 支持、Qt 中的 CBOR 支持以及数据输入输出。
© 2026 The Qt Company Ltd. Documentation contributions included herein are the copyrights of their respective owners. The documentation provided herein is licensed under the terms of the GNU Free Documentation License version 1.3 as published by the Free Software Foundation. Qt and respective logos are trademarks of The Qt Company Ltd. in Finland and/or other countries worldwide. All other trademarks are property of their respective owners.