In part 1 of this article series, we created our database and used it to display the values of each variable on the GUI. We only covered how to add basic data types as well as enums and other objects. This time, we will do some modifications to the original database, and work on including data structures. Because of the nature and size of data structures, we will not be displaying the values on the screen as last time. Instead, we will serialize them into a text file with our custom format, demonstrating a second useful case where we can use this library.
Before we begin, I suggest you download this file (Object Introspection Part 2 Project Files), and select the one called ObjectIntrospection_Part2_Start.yyz, which I will use as our starting point. It’s pretty much what we ended up with after following part 1 of the article, but with a couple of name changes, and some tweaks that had to be made to allow for data structures to be added. I also included three objects with several cases to stress test our program at the end.
When you run the game, this is what you’ll get:
Let's get started.
As mentioned above, I changed a few things to make the code more readable and make some new things possible. These changes are:
We added a new enum in our oOI_Manager object called eOI_VarInfo. It is used to address the 2D array in our definitions more clearly, by specifying which part of the data to grab, between the name, type (or types array), and extra data we may need.
The type section in our definition can now be an array instead of just an enum value. We will cover later why this change is important.
The objects we are using have different values in them as I changed my tests. Also note that all types are passed in as arrays even if it’s just one element. This will make sense later. Know that some lines are commented out in oPlayer and oAlly, but we will uncomment them when we implement the corresponding functions for testing.
Everything else should be the same. I removed all our Inspect functions since we won’t use them this time around.
The format we will use is not a standard one like JSON or XML (but you can definitely implement it to mimic those if you want). The general structure follows this form: the variable name, followed by a colon, then the type of the variable, and last we have an equal sign followed by the value of the variable. With data structures, the equal sign is followed by an opening bracket and then a list of the values using the same format. Here are two examples:
Variable Name : Type (Basic) = Value
Variable Name : Type (Data Structure) =
{
Index0 : Type = Value
Index1 : Type = Value
Index2 : Type = Value
}
Now we know what our end goal is, let’s add some things to the manager to make our life easier later on.
To serialize data structures and objects, we need to go deeper each time. Keeping track of variables when you are inside a recursive call to a function can be tricky. Luckily, we have a global manager that can store that information for us every step of the way. It also makes our function calls a lot cleaner since we don’t have to keep passing the same values around all the time (such as the filename or object ids). At the bottom of your oOI_Manager’s create event, these should be your variables:
// oOI_Manager’s Create Event
_currObject = noone; // Keeps track of the object we are working on
_currInstance = noone; // Keeps track of the object instance we are working on
_currIndex = 0; // Keeps track of the variable index for the current object
_currDepth = 0; // Keeps track of the current variable depth when using recursion
_currExtra = 0; // Stores the extra data information
_currTypes = 0; // Stores the types array
_currValue = 0; // Stores the value of the current variable
_indentLvl = 0; // Indenting level used for serialization
_openFile = 0; // Reference to the open file
Before we write our actual serialize functions, we will write three utility scripts to help with the formatting of the file.
The first of these scripts is called OI_Indent and, as the name implies, it will help us indent to the correct column depending on how deep we are in a data structure or object. The script looks like this:
// OI_Indent Script
// Get the manager
var manager = global.OI_Manager;
repeat(manager._indentLvl)
file_text_write_string(manager._openFile, "\t");
We grab a handle to the manager and then add white space in the form of a tab for every indent level we are in. Notice we are using repeat for our loop. The reason is that we don’t need the index for this operation, so it’s slightly faster to use repeat than a for or while loops.
Next we are creating a script called OI_OpenBlock to specify how we open objects and data structures (in our case, we use an opening bracket ({) on a new line. This is the script:
// OI_OpenBlock Script
// Get the manager handle
var manager = global.OI_Manager;
// Open Block
OI_Indent();
file_text_write_string(manager._openFile, "{\n");
++manager._indentLvl;
After we grab the handle to the manager, we indent using the script we wrote, then we place an opening bracket, and go to a new line. We also increase the indent level.
Analog to the previous script, we will now create one last script called OI_CloseBlock, to specify how we want to finish a block for an object or data structure, like this:
// Close Block Script
// Get the manager handle
var manager = global.OI_Manager;
// Open Block
--manager._indentLvl;
OI_Indent();
file_text_write_string(manager._openFile, "}\n");
Same idea as before, but we do the process backwards, decreasing the indent level first, then calling our OI_Indent script, and placing our closing bracket with a new line.
Now we set up these scripts, we are ready to serialize our object.
Last time we had one Inspect function that would do all the legwork. This time though, we will have a special function that will set all the initial values and start the (possibly) recursive call. I called this script OI_Serialize, which looks like this:
// OI_Serialize Script
// Get the parameters
var fileName = argument0;
var instance = argument1;
// Get a handle to the manager
var manager = global.OI_Manager;
// File handling
var file = file_text_open_write(fileName);
// Initialize variables
manager._currValue = instance;
manager._currObject = instance.object_index;
manager._openFile = file;
manager._currIndex = 0;
manager._currDepth = 0;
manager._currTypes = 0;
manager._currExtra = 0;
// Start the recursive serialization
OI_SerializeObject();
// Reset variables
manager._currObject = noone;
manager._openFile = 0;
manager._currIndex = 0;
manager._currDepth = 0;
manager._currTypes = 0;
manager._currExtra = 0;
manager._currValue = 0;
// Add extra line and close the file
file_text_writeln(file);
file_text_close(file);
We are taking two parameters from the user. The first is the name of the file to write into, and the second is the id of the instance we want to serialize. Next, we open the file to write into it (note that when you open as write it will overwrite another file with the same name in that directory, so if you want to keep adding to the same file, open as append, but this works for the goals of this article). Later we set the initial values to all the variables we created in the manager object. We then call our script to serialize the object which is the one that will do most of the work. Last, we reset our variable values, and close the file.
Now let’s implement that Serialize Object script and some basic types.
This script will be similar to our OI_Inspect one from the previous part. It’s by far the longest one, so instead of posting the entire script and explaining it like before, I will post it in chunks, but realize the code is in the same file when you implement it yourself.
// OI_SerializeObject Script - 1/3
// Get a handle to the manager
var manager = global.OI_Manager;
// Grab the object, instance, and definition
var instance = manager._currValue;
var object = instance.object_index;
var definition = manager._objDefinitions[? object];
var file = manager._openFile;
// Error handling
if (!is_array(definition))
{
show_debug_message("Warning: The object hasn't been defined yet.");
return;
}
// Save these variables to use later
var prevExtra = manager._currExtra;
var prevTypes = manager._currTypes;
var prevDepth = manager._currDepth;
// Reset depth
manager._currDepth = 0;
// Print object name, instance ID, and open the block of variables
file_text_write_string(file, object_get_name(object) + " = " + string(instance) + "\n");
OI_OpenBlock();
Just like in our other scripts, we start by grabbing a handle to the manager. We then grab a few variables we will use throughout (instance, object, definition, and file handle). After we do some error handling, we save the current extra data, types array, and depth. This is so we can restore the values if they are changed when going into the recursive calls. After, we set the current depth to zero. Realize that the depth value is unique per object, even if it’s an object within an object which is why we saved the previous one above (in case this is a nested case). Finally, we write into our file the name of our object and the instance id, followed by a call to OI_OpenBlock to have some nice formatting.
In the next section, we iterate through every variable in the object and serialize each one in this way:
// OI_SerializeObject Script 2/3
// Print each variable and value
for (var i = 0; i < array_height_2d(definition); ++i)
{
// Variable name
OI_Indent();
var varName = definition[i, eOI_VarInfo.Name];
file_text_write_string(file, varName + " : ");
// Get the value, or give a warning if the variable doesn't exist in the object
if (variable_instance_exists(instance, varName))
manager._currValue = variable_instance_get(instance, varName);
else
{
show_debug_message("Warning: The variable \"" + varName + "\" does not exist in the " + object_get_name(object) + " object.");
return;
}
// Get the current types and extra
manager._currExtra = definition[i, eOI_VarInfo.Extra];
manager._currTypes = definition[i, eOI_VarInfo.Type];
// Pick the correct serialize script depending on the variable type
OI_SerializePicker(_currTypes[0]);
}
We loop into our definition array, indenting first before writing our variable name. We check that the variable is valid (it exists in our object) before proceeding to avoid any issues. We then save in the manager object the types and extra arrays for this variable. Finally, we have a helper script (which I’ll show below) that picks the correct type serialization function depending on the type. Since we explained that objects are always at their own depth zero, we call it for the first element in the types array. The OI_SerializePicker script looks like this:
// OI_SerializePicker Script
// Get the parameter
var type= argument0;
switch(type)
{
case eOI_VarTypes.Int: OI_SerializeInt(); break;
case eOI_VarTypes.Float: OI_SerializeFloat(); break;
case eOI_VarTypes.Bool: OI_SerializeBool(); break;
case eOI_VarTypes.String: OI_SerializeString(); break;
case eOI_VarTypes.Enum: OI_SerializeEnum(); break;
case eOI_VarTypes.Object: OI_SerializeObject(); break;
}
This is simply a switch statement that calls the appropriate script depending on the type passed in. When you add your custom data structures or variable types, this is a script you need to come into and add the new type, and adding it to the eOI_VarTypes enumerator.
Back in the OI_SerializeObject script, we close it off with a few lines:
// OI_SerializeObject Script - 3/3
// Close current block
OI_CloseBlock();
// Return variables to their initial state
manager._currExtra = prevExtra;
manager._currTypes = prevTypes;
manager._currDepth = prevDepth;
We close the block by placing an ending bracket, and we restore the previous values for the extra and type arrays, alongside with the depth. That’s it for this script! We will now quickly show the scripts for all the basic types before jumping into the ones for data structures.
OI_SerializeInt
// Get the manager
var manager = global.OI_Manager;
file_text_write_string(manager._openFile, "int = " + string(floor(manager._currValue)) + "\n");
OI_SerializeFloat
// Get the manager
var manager = global.OI_Manager;
file_text_write_string(manager._openFile, "float = " + string_format(manager._currValue, 1, 5) + "\n");
OI_SerializeBool
// Get the manager
var manager = global.OI_Manager;
file_text_write_string(manager._openFile, "bool = " + (manager._currValue ? "True" : "False") + "\n");
OI_SerializeString
// Get the manager
var manager = global.OI_Manager;
file_text_write_string(manager._openFile, "string = \"" + manager._currValue + "\"\n");
OI_SerializeEnum
// Get the manager
var manager = global.OI_Manager;
file_text_write_string(manager._openFile, "enum = " + manager._currExtra[@ manager._currValue] + "\n");
At this point, we should test our progress. In the oPlayer object’s step event, add this little piece of code:
Then run the game and press the spacebar to create the output. The location GameMaker uses to output files by default is:
C:\Users\YOUR_USERNAME\AppData\Local\PROJECT_NAME
Your output should look like this:
We are now ready to try our hand at data structures.
The idea to serialize data structures is the same, but one level deeper into our types array. We will loop through every element and call the OI_SerializePicker script for every one.
Note: due to the way our database is structured, the objects inside the data structures have to be of the same type. Yes, GameMaker allows for data structure elements to be of any type, but since we need to know the type of the element to call the function, it is a limitation.
We will start with the most commonly used data structure: arrays.
First, remember to add an array entry into the eOI_VarTypes enumerator inside the oOI_Manager object. Also, add an extra line in our OI_SerializePicker script that will call OI_SerializeArray when the type is array.
Inside that script, we will write:
// OI_Serialize Array Script
// Get a handle to the manager
var manager = global.OI_Manager;
// Get the definition
var definition = manager._objDefinitions[? manager._currObject];
// Open list block
file_text_write_string(manager._openFile, "array =\n");
OI_OpenBlock();
++manager._currDepth;
var array = manager._currValue;
for (var i = 0; i < array_length_2d(array, 0); ++i)
{
OI_Indent();
file_text_write_string(manager._openFile, "a" + string(i) + " : ");
manager._currValue = array[@ i];
OI_SerializePicker(manager._currTypes[@ manager._currDepth]);
}
--manager._currDepth;
OI_CloseBlock();
As always, we get a handle to the manager to simplify our variable names. We get our definition, write the name of the type (in this case array) and open the block. The depth is now increased by one, which will get us the type of each element in our data structure. The current value is set to the array so that we don’t have to pass it to every script in the script. Next, we iterate through every element in the array by using the array_lenght_2d function. This is a little trick that works because every array in GameMaker is treated as a two-dimensional array internally, so this will work for 1D and 2D arrays.
For each element, we indent it and since each element in an array is not named, we identify them by writing a lowercase a and the index number next to it. We then get the value of the element at that index and we call our OI_SerializePicker function, passing as our type the index in our types array corresponding to the recursion depth we are at. After all the elements are processed recursively, we reduce our depth by one and close the block.
We can now uncomment the line in our oPlayer object that has the array so we can test it. The array part should now be displayed like this:
Also, with what we have, we can also uncomment the line that holds the 2D array in our player object (the one with the variable named _minimap) and it should work as expected, since the recursion is working already. That one is longer so I won’t show an image here, but check your file and it should be there now.
The last thing we will do in this article is repeat the process for the ds_list and the ds_map, and you see that the scripts are similar.
As before, remember to add lists and maps to your enumerator and to the OI_SerializePicker script. Then, create two scripts called OI_SerializeList and OI_SerializeMap respectively. The one for lists looks like this:
// OI_SerializeList Script
// Get a handle to the manager
var manager = global.OI_Manager;
// Get the definition
var definition = manager._objDefinitions[? manager._currObject];
// Open list block
file_text_write_string(manager._openFile, "list =\n");
OI_OpenBlock();
++manager._currDepth;
var list = manager._currValue;
for (var i = 0; i < ds_list_size(list); ++i)
{
OI_Indent();
file_text_write_string(manager._openFile, "l" + string(i) + " : ");
manager._currValue = list[| i];
OI_SerializePicker(manager._currTypes[@ manager._currDepth]);
}
--manager._currDepth;
OI_CloseBlock();
Pretty much the same as the array one. The only differences are the name of the type we write, how we get the size of the data structure, the use of a lowercase l for our indexes, and the use of the proper accessor for lists.
Moving on to the script for the map, we have:
// OI_SerializeMap Script
// Get a handle to the manager
var manager = global.OI_Manager;
// Get the definition
var definition = manager._objDefinitions[? manager._currObject];
// Open list block
file_text_write_string(manager._openFile, "map =\n");
OI_OpenBlock();
++manager._currDepth;
var map = manager._currValue;
var currKey = ds_map_find_first(map);
repeat(ds_map_size(map))
{
OI_Indent();
file_text_write_string(manager._openFile, currKey + " : ");
manager._currValue = map[? currKey];
OI_SerializePicker(manager._currTypes[manager._currDepth]);
currKey = ds_map_find_next(map, currKey);
}
--manager._currDepth;
OI_CloseBlock();
Same as the list, but the one thing that may trip you up is how we traverse the map. Because maps are not sequential and we don’t control the order of the elements, GameMaker kindly provided us with some functions (ds_map_find_first and ds_map_find_next) to allow us to travel through every element as if they were in a sequence. We also take advantage of the fact that maps have keys for each element, so we use that key as our name for each element.
We are now free to uncomment all the lines in the definitions of our three objects (oPlayer, oAlly, and oEnemy). Once we do that, we can run the project again, press space, and see if everything is working correctly. You can also download the final version of the project if you get stuck somewhere along the way.
Note: I created a good variety of tests to show how you can nest data structures together in as many ways as possible. Theoretically it will work with any combination and however deep you’d like to go, but if there are any issues, please let me know.
If you liked the result we got so far and want to take it further, here are some ideas of things you can add to solidify the knowledge and improve your library:
Add the ds_grid: You may have noticed that we didn’t implement a way to serialize a ds_grid. If you are using those in your project, feel free to add it. The function will be like that of the array.
Custom data structures: If you implemented your own data structure into the game (like a binary tree or a slotmap), it would be a nice challenge to implement a serialization function for those.
GameMaker assets: in the oEnemy object, we serialized some of the built-in variables of the object (X and Y positions). Since we know it’s possible, it would be a good idea to add other ones, such as the sprite used by the object. If you are not entirely sure how, read the documentation on these functions: sprite_get_name, asset_get_index, and asset_get_type. It can even work for music, sound effects, tilesets, anything really.
Write a deserializer: We can now save files, but we still can’t read them in. Creating a deserializer shouldn’t be too hard to implement (just do the same we did, but backwards) for every variable type.
Use the database for other purposes: A great way to use this database is to create a nice editor where you can click an object in the scene and it creates a window with all the variables, showing you the values and hopefully letting you edit them. I recommend using the ImGUIGML extension to do this easier, but it can also be done with a custom UI.
Change the serializer so it uses JSON format: If you prefer to use a well-known serializing format like JSON or XML, feel free to modify it to resemble those using the same strategies.
Object introspection part 2 project files
Inside you will find two .yyz files, one you should use if you want to follow along with the article (called ObjectIntrospection_Part2_Start.yyz), and another one with the final version of the project, if you want to see the source code or get stuck while following along (called ObjectIntrospection_Part2_Final.yyz).
This can be an advanced topic, but a super powerful one that can help you solve complicated problems and automate them to save time. If you have any suggestions or questions, you can contact me on Twitter (@AleHitti). Thank you for reading. Until next time!