保存和加载游戏
如何使用 Qt 的 JSON 或 CBOR 类保存和加载游戏。
许多游戏都提供保存功能,这样玩家的游戏进度就可以保存下来,并在以后加载。保存游戏的过程一般包括将每个游戏对象的成员变量序列化到文件中。为此可以使用多种格式,JSON 就是其中之一。通过QJsonDocument ,您还可以将文件序列化为CBOR格式,如果您不希望保存文件易于读取(但请参阅解析和显示 CBOR 数据以了解如何读取CBOR 数据),或者您需要减小文件大小,那么CBOR格式是个不错的选择。
在本例中,我们将演示如何将一个简单的游戏保存为 JSON 格式和二进制格式,并将其载入。
字符类
角色类代表游戏中的非玩家角色(NPC),并存储玩家的姓名、等级和等级类型。
它提供静态 fromJson() 和非静态 toJson() 函数来序列化自己。
注: 这种模式(fromJson()/toJson())之所以有效,是因为 QJsonObjects 可以独立于拥有的QJsonDocument 构建,而且这里(去)序列化的数据类型是值类型,因此可以复制。当序列化为另一种格式时(例如 XML 或QDataStream ,需要传递类似文档的对象),或者当对象标识很重要时(例如QObject 子类),其他模式可能更适合。有关 XML,请参阅dombookmarks示例,有关惯用的QDataStream 序列化,请参阅QListWidgetItem::read() 和QListWidgetItem::write() 的实现。本示例中的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; }
在 fromJson() 函数中,我们构建了一个本地result
字符对象,并为QJsonObject 参数中的result
成员赋值。您可以使用QJsonObject::operator[]() 或QJsonObject::value() 访问 JSON 对象中的值,这两个函数都是常量函数,如果键无效,则返回QJsonValue::Undefined 。尤其是is...
函数(例如QJsonValue::isString(),QJsonValue::isDouble()) 会返回false
,而QJsonValue::Undefined 会返回 ,因此我们只需一次查询就能检查是否存在以及类型是否正确。
如果某个值不存在于 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() 函数相反的操作;将字符对象中的值赋值给一个新的 JSON 对象,然后返回。与访问值一样,在QJsonObject 上设置值也有两种方法:QJsonObject::operator[]() 和QJsonObject::insert()。这两种方法都将覆盖给定键上的任何现有值。
级别类
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,因此我们保留了一个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() 来获取字符的 JSON 对象。然后,Character::fromJson() 就能将 QJSonObject 转换成字符对象,并追加到我们的 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() 函数类似,只是相反。
游戏类
在建立了角色类和关卡类之后,我们就可以继续游戏类了:
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()。
saveGame() 和 loadGame() 会使用 read() 和 toJson() 函数。
注:尽管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 中读取的玩家。然后我们清空()关卡数组,这样在同一个游戏对象上调用两次 loadGame() 就不会导致旧的关卡被挂起。
然后,我们通过从QJsonArray 读取每个关卡来填充关卡数组。
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) { QFileloadFile(saveFormat==Json? "save.json"_L1 : "save.dat"_L1);if(!loadFile.open(QIODevice::ReadOnly)) { qWarning("Couldn't open save file."); return false; } QByteArraysaveData=loadFile.readAll(); QJsonDocumentloadDoc(saveFormat==Json?QJsonDocument::fromJson(saveData) : QJsonDocument(QCborValue::fromCbor(saveData).toMap().toJsonObject())); read(loadDoc.object()); QTextStream(stdout)<< "Loaded save for "<<loadDoc["player"]["name"].toString()<< " using "<<(saveFormat!=Json? "CBOR":"JSON")<< "...\n";return true; }
在 loadGame() 中加载保存的游戏时,我们要做的第一件事是根据保存文件的格式打开它;"save.json"
表示 JSON 格式,"save.dat"
表示 CBOR 格式。如果文件无法打开,我们会打印警告并返回false
。
由于QJsonDocument::fromJson() 和QCborValue::fromCbor() 都使用QByteArray ,因此无论保存格式如何,我们都可以将保存文件的全部内容读入其中。
构建QJsonDocument 后,我们会指示 Game 对象读取自身内容,然后返回true
表示成功。
boolGame::saveGame(Game::SaveFormat saveFormat)const{ QFilesaveFile(saveFormat==Json? "save.json"_L1 : "save.dat"_L1);if(!saveFile.open(QIODevice::WriteOnly)) { qWarning("Couldn't open save file."); return false; } QJsonObjectgameObject=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"(默认)和 "二进制"。该参数将决定保存和/或加载的文件。然后我们继续前进,假定玩家玩得很开心并取得了很多进展,改变了角色、关卡和游戏对象的内部状态。
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。您可以检查与可执行文件位于同一目录下的文件内容(或重新运行示例,确保同时指定 "加载 "选项),尽管二进制保存文件会包含一些乱码(这是正常现象)。
示例到此结束。正如您所看到的,使用 Qt 的 JSON 类进行序列化非常简单方便。与QDataStream 等类相比,使用QJsonDocument 等类的优势在于,您不仅可以获得人类可读的 JSON 文件,还可以根据需要选择使用二进制格式,而无需重写任何代码。
另请参阅 Qt 中的 JSON 支持、Qt 中的 CBOR 支持和数据输入输出。
© 2025 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.