JSON Save Game Example▲
Many games provide save functionality, so that the player's progress through the game can be saved and loaded at a later time. The process of saving a game generally involves serializing each game object's member variables to a file. Many formats can be used for this purpose, one of which is JSON. With QJsonDocument, you also have the ability to serialize a document in a CBOR format, which is great if you don't want the save file to be readable, or if you need to keep the file size down.
In this example, we'll demonstrate how to save and load a simple game to and from JSON and binary formats.
The Character Class▲
The Character class represents a non-player character (NPC) in our game, and stores the player's name, level, and class type.
It provides read() and write() functions to serialise its member variables.
class
Character
{
Q_GADGET
public
:
enum
ClassType {
Warrior, Mage, Archer
}
;
Q_ENUM(ClassType)
Character();
Character(const
QString &
amp;name, int
level, ClassType classType);
QString name() const
;
void
setName(const
QString &
amp;name);
int
level() const
;
void
setLevel(int
level);
ClassType classType() const
;
void
setClassType(ClassType classType);
void
read(const
QJsonObject &
amp;json);
void
write(QJsonObject &
amp;json) const
;
void
print(int
indentation =
0
) const
;
private
:
QString mName;
int
mLevel =
0
;
ClassType mClassType =
Warrior;
}
;
Of particular interest to us are the read and write function implementations:
void
Character::
read(const
QJsonObject &
amp;json)
{
if
(json.contains("name"
) &
amp;&
amp; json["name"
].isString())
mName =
json["name"
].toString();
if
(json.contains("level"
) &
amp;&
amp; json["level"
].isDouble())
mLevel =
json["level"
].toInt();
if
(json.contains("classType"
) &
amp;&
amp; json["classType"
].isDouble())
mClassType =
ClassType(json["classType"
].toInt());
}
In the read() function, we assign Character's members values from the QJsonObject argument. You can use either QJsonObject::operator[]() or QJsonObject::value() to access values within the JSON object; both are const functions and return QJsonValue::Undefined if the key is invalid. We check if the keys are valid before attempting to read them with QJsonObject::contains().
void
Character::
write(QJsonObject &
amp;json) const
{
json["name"
] =
mName;
json["level"
] =
mLevel;
json["classType"
] =
mClassType;
}
In the write() function, we do the reverse of the read() function; assign values from the Character object to the JSON object. As with accessing values, there are two ways to set values on a QJsonObject: QJsonObject::operator[]() and QJsonObject::insert(). Both will override any existing value at the given key.
Next up is the Level class:
class
Level
{
public
:
Level() =
default
;
explicit
Level(const
QString &
amp;name);
QString name() const
;
QList&
lt;Character&
gt; npcs() const
;
void
setNpcs(const
QList&
lt;Character&
gt; &
amp;npcs);
void
read(const
QJsonObject &
amp;json);
void
write(QJsonObject &
amp;json) const
;
void
print(int
indentation =
0
) const
;
private
:
QString mName;
QList&
lt;Character&
gt; mNpcs;
}
;
We want to have several levels in our game, each with several NPCs, so we keep a QList of Character objects. We also provide the familiar read() and write() functions.
void
Level::
read(const
QJsonObject &
amp;json)
{
if
(json.contains("name"
) &
amp;&
amp; json["name"
].isString())
mName =
json["name"
].toString();
if
(json.contains("npcs"
) &
amp;&
amp; json["npcs"
].isArray()) {
QJsonArray npcArray =
json["npcs"
].toArray();
mNpcs.clear();
mNpcs.reserve(npcArray.size());
for
(const
QJsonValue &
amp;v : npcArray) {
QJsonObject npcObject =
v.toObject();
Character npc;
npc.read(npcObject);
mNpcs.append(npc);
}
}
}
Containers can be written and read to and from JSON using QJsonArray. In our case, we construct a QJsonArray from the value associated with the key "npcs". Then, for each QJsonValue element in the array, we call toObject() to get the Character's JSON object. The Character object can then read their JSON and be appended to our NPC array.
Associate containers can be written by storing the key in each value object (if it's not already). With this approach, the container is stored as a regular array of objects, but the index of each element is used as the key to construct the container when reading it back in.
void
Level::
write(QJsonObject &
amp;json) const
{
json["name"
] =
mName;
QJsonArray npcArray;
for
(const
Character &
amp;npc : mNpcs) {
QJsonObject npcObject;
npc.write(npcObject);
npcArray.append(npcObject);
}
json["npcs"
] =
npcArray;
}
Again, the write() function is similar to the read() function, except reversed.
Having established the Character and Level classes, we can move on to the Game class:
class
Game
{
public
:
enum
SaveFormat {
Json, Binary
}
;
Character player() const
;
QList&
lt;Level&
gt; levels() const
;
void
newGame();
bool
loadGame(SaveFormat saveFormat);
bool
saveGame(SaveFormat saveFormat) const
;
void
read(const
QJsonObject &
amp;json);
void
write(QJsonObject &
amp;json) const
;
void
print(int
indentation =
0
) const
;
private
:
Character mPlayer;
QList&
lt;Level&
gt; mLevels;
}
;
First of all, we define the SaveFormat enum. This will allow us to specify the format in which the game should be saved: Json or Binary.
Next, we provide accessors for the player and levels. We then expose three functions: newGame(), saveGame() and loadGame().
The read() and write() functions are used by saveGame() and loadGame().
void
Game::
newGame()
{
mPlayer =
Character();
mPlayer.setName(QStringLiteral("Hero"
));
mPlayer.setClassType(Character::
Archer);
mPlayer.setLevel(QRandomGenerator::
global()-&
gt;bounded(15
, 21
));
mLevels.clear();
mLevels.reserve(2
);
Level village(QStringLiteral("Village"
));
QList&
lt;Character&
gt; villageNpcs;
villageNpcs.reserve(2
);
villageNpcs.append(Character(QStringLiteral("Barry the Blacksmith"
),
QRandomGenerator::
global()-&
gt;bounded(8
, 11
),
Character::
Warrior));
villageNpcs.append(Character(QStringLiteral("Terry the Trader"
),
QRandomGenerator::
global()-&
gt;bounded(6
, 8
),
Character::
Warrior));
village.setNpcs(villageNpcs);
mLevels.append(village);
Level dungeon(QStringLiteral("Dungeon"
));
QList&
lt;Character&
gt; dungeonNpcs;
dungeonNpcs.reserve(3
);
dungeonNpcs.append(Character(QStringLiteral("Eric the Evil"
),
QRandomGenerator::
global()-&
gt;bounded(18
, 26
),
Character::
Mage));
dungeonNpcs.append(Character(QStringLiteral("Eric's Left Minion"
),
QRandomGenerator::
global()-&
gt;bounded(5
, 7
),
Character::
Warrior));
dungeonNpcs.append(Character(QStringLiteral("Eric's Right Minion"
),
QRandomGenerator::
global()-&
gt;bounded(4
, 9
),
Character::
Warrior));
dungeon.setNpcs(dungeonNpcs);
mLevels.append(dungeon);
}
To setup a new game, we create the player and populate the levels and their NPCs.
void
Game::
read(const
QJsonObject &
amp;json)
{
if
(json.contains("player"
) &
amp;&
amp; json["player"
].isObject())
mPlayer.read(json["player"
].toObject());
if
(json.contains("levels"
) &
amp;&
amp; json["levels"
].isArray()) {
QJsonArray levelArray =
json["levels"
].toArray();
mLevels.clear();
mLevels.reserve(levelArray.size());
for
(const
QJsonValue &
amp;v : levelArray) {
QJsonObject levelObject =
v.toObject();
Level level;
level.read(levelObject);
mLevels.append(level);
}
}
}
The first thing we do in the read() function is tell the player to read itself. We then clear the level array so that calling loadGame() on the same Game object twice doesn't result in old levels hanging around.
We then populate the level array by reading each Level from a QJsonArray.
void
Game::
write(QJsonObject &
amp;json) const
{
QJsonObject playerObject;
mPlayer.write(playerObject);
json["player"
] =
playerObject;
QJsonArray levelArray;
for
(const
Level &
amp;level : mLevels) {
QJsonObject levelObject;
level.write(levelObject);
levelArray.append(levelObject);
}
json["levels"
] =
levelArray;
}
We write the game to JSON similarly to how we write Level.
bool
Game::
loadGame(Game::
SaveFormat saveFormat)
{
QFile loadFile(saveFormat ==
Json
? QStringLiteral("save.json"
)
:
QStringLiteral("save.dat"
));
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) &
lt;&
lt; "Loaded save for "
&
lt;&
lt; loadDoc["player"
]["name"
].toString()
&
lt;&
lt; " using "
&
lt;&
lt; (saveFormat !=
Json ? "CBOR"
: "JSON"
) &
lt;&
lt; "...
\n
"
;
return
true
;
}
When loading a saved game in loadGame(), the first thing we do is open the save file based on which format it was saved to; "save.json" for JSON, and "save.dat" for CBOR. We print a warning and return false if the file couldn't be opened.
Since QJsonDocument::fromJson() and QCborValue::fromCbor() both take a QByteArray, we can read the entire contents of the save file into one, regardless of the save format.
After constructing the QJsonDocument, we instruct the Game object to read itself and then return true to indicate success.
bool
Game::
saveGame(Game::
SaveFormat saveFormat) const
{
QFile saveFile(saveFormat ==
Json
? QStringLiteral("save.json"
)
:
QStringLiteral("save.dat"
));
if
(!
saveFile.open(QIODevice::
WriteOnly)) {
qWarning("Couldn't open save file."
);
return
false
;
}
QJsonObject gameObject;
write(gameObject);
saveFile.write(saveFormat ==
Json
? QJsonDocument(gameObject).toJson()
:
QCborValue::
fromJsonValue(gameObject).toCbor());
return
true
;
}
Not surprisingly, saveGame() looks very much like loadGame(). We determine the file extension based on the format, print a warning and return false if the opening of the file fails. We then write the Game object to a QJsonDocument, and call either QJsonDocument::toJson() or to QJsonDocument::toBinaryData() to save the game, depending on which format was specified.
We are now ready to enter main():
int
main(int
argc, char
*
argv[])
{
QCoreApplication app(argc, argv);
QStringList args =
QCoreApplication::
arguments();
bool
newGame =
true
;
if
(args.length() &
gt; 1
)
newGame =
(args[1
].toLower() !=
QStringLiteral("load"
));
bool
json =
true
;
if
(args.length() &
gt; 2
)
json =
(args[2
].toLower() !=
QStringLiteral("binary"
));
Game game;
if
(newGame)
game.newGame();
else
if
(!
game.loadGame(json ? Game::
Json : Game::
Binary))
return
1
;
// Game is played; changes are made...
Since we're only interested in demonstrating serialization of a game with JSON, our game is not actually playable. Therefore, we only need QCoreApplication and have no event loop. On application start-up we parse the command-line arguments to decide how to start the game. For the first argument the options "new" (default) and "load" are available. When "new" is specified a new game will be generated, and when "load" is specified a previously saved game will be loaded in. For the second argument "json" (default) and "binary" are available as options. This argument will decide which file is saved to and/or loaded from. We then move ahead and assume that the player had a great time and made lots of progress, altering the internal state of our Character, Level and Game objects.
QTextStream(stdout) &
lt;&
lt; "Game ended in the following state:
\n
"
;
game.print();
if
(!
game.saveGame(json ? Game::
Json : Game::
Binary))
return
1
;
return
0
;
}
When the player has finished, we save their game. For demonstration purposes, we can serialize to either JSON or CBOR. You can examine the contents of the files in the same directory as the executable (or re-run the example, making sure to also specify the "load" option), although the binary save file will contain some garbage characters (which is normal).
That concludes our example. As you can see, serialization with Qt's JSON classes is very simple and convenient. The advantages of using QJsonDocument and friends over QDataStream, for example, is that you not only get human-readable JSON files, but you also have the option to use a binary format if it's required, without rewriting any code.