保存和加载游戏

如何使用 Qt 的 JSON 或 CBOR 类保存和加载游戏。

许多游戏提供保存功能,以便玩家在游戏中的进度可以在稍后时间保存和加载。保存游戏的过程通常涉及将每个游戏对象的成员变量序列化到文件中。许多格式可以用于此目的,其中一个就是 JSON。使用 QJsonDocument,您还可以将文档序列化为 CBOR 格式,这对于希望保存文件不易被阅读(但请参见 解析和显示 CBOR 数据 了解如何 可以 阅读它)或需要减小文件大小的情况非常有用。

在这个示例中,我们将展示如何将简单游戏保存到 JSON 和二进制格式,并将其加载到这些格式中。

角色类

角色类表示我们游戏中的一个非玩家角色(NPC),并存储玩家的名字、等级和类类型。

它提供来自 Json 的静态 fromJson() 和非静态 toJson() 函数来自动序列化自身。

注意: 这种模式(fromJson()/toJson())之所以有效,是因为 QJsonObjects 可以独立于拥有 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;
}

在 fromJson() 函数中,我们构造一个本地的 result Character 对象,并将其成员的值赋给来自 QJsonObject 参数。您可以使用 QJsonObject::operator[]() 或 QJsonObject::value() 来访问 JSON 对象中的值;这两个都是 const 函数,如果键无效,则返回 QJsonValue::Undefined。特别是,is... 函数(例如 QJsonValue::isString(),QJsonValue::isDouble())对于 QJsonValue::Undefined 返回 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中的值有两种方式:QJsonObjectoperator[]()和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枚举。这将允许我们指定游戏应保存的格式:JsonBinary

然后,我们提供对玩家和等级的访问器。然后公开三个函数:newGame(),saveGame()和loadGame()。

read()和toJson()函数由saveGame()和loadGame()使用。

注意:尽管Game是一个值类,但我们假设作者希望游戏具有唯一性,就像主窗口一样。因此,我们不使用静态fromJson()函数(它将创建一个新的对象),而是使用我们可以对现有对象调用的read()函数。read()和fromJson()之间存在一一对应关系,其中一个可以通过另一个实现

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读取的玩家。然后清空level数组,这样在相同的Game对象上两次调用loadGame()时,就不会有旧的水平存在。

然后通过读取每个Level来填充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的过程与写入一个水平类似。

bool Game::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) << "Loaded save for " << loadDoc["player"]["name"].toString()
                        << " using " << (saveFormat != Json ? "CBOR" : "JSON") << "...\n";
    return true;
}

在loadGame()中加载已保存的游戏时,我们首先根据保存的格式打开保存文件;对于JSON是"save.json",对于CBOR是"save.dat"。如果文件不能打开,我们打印警告并返回false。

由于QJsonDocument::fromJson()和QCborValue::fromCbor()都接受一个QByteArray,我们可以将保存文件的全部内容读入一个中,无论保存格式如何。

在构造QJsonDocument后,我们指示Game对象读取自身,并返回true以表示成功。

bool Game::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::toJson()调用的QJsonDocument,或用于QCborValue::toCbor()的QCborValue

整合所有内容

我们现在准备好进入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文件,而且还有使用二进制格式(如果需要的话)的选项,而无需重写任何代码。

示例项目 @ code.qt.io

另请参阅 Qt中的JSON支持Qt中的CBOR支持,以及数据输入输出

© 2024 Qt公司有限公司。本文件中的文档贡献均为各自所有者的版权。本文件中的文档是根据自由软件基金会发布的自由文档许可协议第1.3版进行许可的。GNU自由文档许可协议第1.3版。Qt及其相关标志是Qt公司在芬兰和/或世界其他国家的商标。所有其他商标均为其各自所有者的财产。