ゲームの保存と読み込み

QtのJSONやCBORクラスを使ってゲームをセーブしたりロードしたりする方法です。

多くのゲームにはセーブ機能があり、プレイヤーのゲームの進行状況を保存し、後でロードすることができます。ゲームを保存するプロセスでは、一般的に各ゲームオブジェクトのメンバ変数をファイルにシリアライズします。この目的のために多くのフォーマットを使用することができ、その1つはJSONです。QJsonDocument では、CBORフォーマットでドキュメントをシリアライズすることもできます。これは、保存ファイルを読みやすくしたくない場合(ただし、CBORデータを読み込む方法についてはCBORデータの解析と表示を参照してください)や、ファイルサイズを小さくする必要がある場合に便利です。

この例では、簡単なゲームをJSON形式とバイナリ形式でセーブしたりロードしたりする方法を示します。

キャラクタークラス

Character クラスは、ゲーム内のノンプレイヤー キャラクター (NPC) を表し、プレイヤーの名前、レベル、およびクラス タイプを保存します。

このクラスは、静的な fromJson() 関数と非静的な toJson() 関数を提供し、自身をシリアライズします。

注: このパターン(fromJson()/toJson())が機能するのは、QJsonObject が所有する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 Characterオブジェクトを構築し、result'のメンバの値をQJsonObject 引数から代入します。JSONオブジェクト内の値にアクセスするには、QJsonObject::operator[] ()またはQJsonObject::value ()のいずれかを使用できます。どちらもconst関数で、キーが無効な場合はQJsonValue::Undefined 。特に、is... 関数(例えば、QJsonValue::isString()、QJsonValue::isDouble())は、QJsonValue::Undefined に対してfalse を返すので、1回の検索で、正しい型だけでなく、存在もチェックできます。

値がJSONオブジェクトに存在しない場合、または型が正しくない場合、対応するresult メンバにも書き込まれないため、デフォルトコンストラクタが設定した値は保持されます。これは、デフォルト値が1つの場所(デフォルト コンストラクタ)で一元的に定義され、シリアライズ コードで繰り返す必要がないことを意味します(DRY)。

C++17 の if-with-initializerを使用して、変数のスコープとチェックを分離していることに注目してください(v )。これは、変数のスコープが限定されているため、変数名を短く保つことができることを意味します。

QJsonObject::contains() を使ったナイーブなアプローチと比較してみてください:

if (json.contains("name") && json["name"].isString())
    result.mName = json["name"].toString();

これは可読性が低いだけでなく、合計3回のルックアップが必要で(コンパイラーはこれらを1回に最適化しません)、3倍遅くなり、"name" を3回繰り返します(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() の2つの方法があります。どちらも、指定されたキーの既存の値を上書きします。

レベルクラス

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 に書き込んだり、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()関数に似ています。

ゲーム・クラス

キャラクター・クラスとレベル・クラスができたので、次はゲーム・クラスです:

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 enumを定義します。これにより、ゲームの保存形式を指定することができます。Json またはBinary

次に、プレイヤーとレベル用のアクセサを提供する。次に、newGame()、saveGame()、loadGame() の 3 つの関数を公開します。

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から読み込んだものと置き換えることから始めます。次に、同じ Game オブジェクトで loadGame() を 2 回呼び出しても古いレベルがうろつかないように、レベル配列を clear() します。

そして、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へのゲームの書き込みは、レベルの書き込みと似ている。

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;
}

"save.json" "save.dat" loadGame()で保存されたゲームをロードするとき、最初に行うことは、保存されたフォーマットに基づいて保存ファイルを開くことです。ファイルを開けなかった場合は警告を表示し、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 に変換して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 "を指定できます。この引数によって、どのファイルに保存するか、あるいはどのファイルからロードするかを決定する。続いて、プレイヤーがとても楽しんで、たくさん進んだと仮定して、キャラクター、レベル、ゲームオブジェクトの内部状態を変更します。

    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 The Qt Company Ltd. ここに含まれるドキュメントの著作権は、それぞれの所有者に帰属します。 本書で提供されるドキュメントは、Free Software Foundation が発行したGNU Free Documentation License version 1.3に基づいてライセンスされています。 Qtおよびそれぞれのロゴは、フィンランドおよびその他の国におけるThe Qt Company Ltd.の 商標です。その他すべての商標は、それぞれの所有者に帰属します。