Blog
Asset management
A critical part of video game development that is rarely discussed in βclassicβ programming courses is the management of assets, i.e. game data (levels, images, sounds, etc.).
Alright, in general, we do know how to read files in C++, but here I want to talk about having an automated and cross-platform mechanism: you don't want the player to have to specify, at each launch, where the assets are ...
API
I already briefly talked about it in the second part of my Android
porting article, but we don't use the
βclassicβ C++ IO functions (via std::ifstream
), nor even those from
C (via FILE*
): instead, we use dedicated functions of the SDL.
The advantage is that these functions will transparently βplugβ into
the particular asset management of Android (or other systems) which
does not use a traditional file system, while switching back to the C
base API FILE*
when it is usable. But all that is done by the SDL,
so we don't have to worry about that, and that's neat!
Of course, the image or sound loading functions (SDL_LoadImage
,
Mix_LoadWAV
, etc.) rely on this mechanism, but if we also want to
load other types of files (the description files of our levels,
example), lower level functions are provided.
SDL IO functions usually start with SDL_RW
(for read/write):
SDL_RWFromFile
: opens a regular file;SDL_RWFromConstMem
: allows a section of RAM to be opened as if it were a file. I'll talk about that in another post, but it makes it possible to preload files into memory and interpret them at another time;SDL_RWread
: reads a number of bytes;SDL_RWtell
andSDL_RWseek
, respectively to know where in the file you are and to go to a specific place in the file;SDL_RWclose
to close the file.
There are others, I won't go into detail, the API documentation is available.
The initialization file
Well, as I said, reading files in C/C++, we know how to do it: here, the API is a little different from the standard APIs, but not that much, and it is used quite intuitively. Now, how does it actually work on the code side?
As game data may vary (remember that the game engine is βcontent
agnosticβ, and you could theoretically create a completely different
point'n'click adventure game from Superfluous Returnz without
changing a line of code ), we need a fixed base: this base will be a
normalized initialization file that we simply call data/init.yaml
.
It's quite long, so here are a few excerpts:
version: 1.0.0
name: "Superfluous Returnz"
icon: "icon"
We start by giving the minimum version of the engine to use: as the engine can evolve (especially in the development phase), the data can become incompatible and I prefer to have a check at launch.
Then, of course, we give the name of the game (which will appear as
the name of the program in the taskbar, among other places) and the
path of the icon: as I use a normalized hierarchy, I only give the
name of the file isolated, but the engine will deduce it has to fetch
images/interface/icon.png
.
debug_font: "DejaVuSansMono"
interface_font: "PTN77F"
interface_light_font: "PTN57F"
dialog_font: "TovariSans"
inventory_arrows: [ "left", "right" ]
click_sound: "click"
This section tells the engine what fonts to load as well as some
images (the arrows to navigate the inventory) and sounds (like the
interface βclickβ sound). Likewise, the full paths are deduced
(DejaVuSansMono
becomes fonts/DejaVuSansMono.ttf
, click
becomes
sounds/effects/click.ogg
, etc.).
text:
- { id: "continue", value: "Reprendre" }
- { id: "new_game", value: "Nouvelle partie", icon: "nouvelle_partie" }
- { id: "settings", value: "PrΓ©fΓ©rences", icon: "preferences" }
- { id: "phone", value: "TΓ©lΓ©phone", icon: "telephone" }
- { id: "gps", value: "GPS", icon: "gps" }
- { id: "credits", value: "CrΓ©dits", icon: "credits" }
- { id: "save_and_quit", value: "Sauver & quitter", icon: "quitter" }
We have the menu items with, each time, their title in French (my native tongue and the original language of the game) and their possible icon. I will talk in a future article about how we manage translations (in English, in particular).
Finally, we have this element:
load: [ "cutscene_intro", "intro" ]
Which tells us which level (or, in this case, which cutscene) to load
when launching the game. This line can optionally be ignored if a save
file exists, but again, I'll talk about that another time. The second
parameter ("intro"
) selects which "origin" of the level to use: in
the case of a cutscenes, there is only one, but if you imagine a room
with several entrances/exits (the main door and the garden door, for
example), this is how we choose at which entrance/exit we want our
character to appear.
I won't go into detail, I've already mentioned the level descriptions in a dedicated article.
The tree structure
The data/init.yaml
file is given with respect to the root of our
data tree: all other file paths are relative to this root.
In order to easily find my way around my assets and to be able to use
short references as explained in the previous section (icon
instead
of images/interface/icon.png
), folder hierarchy and file formats are
fixed and standardized:
[racine]
βββ data/ # DATA USING YAML FORMAT
β βββ actions/ # Descriptions of action sequences
β βββ characters/ # How a character is "built" (images, colors, coordinates, etc.)
β βββ codes/ # Secrets codes that have to be solved
β βββ dialogs/ # Dialog trees
β βββ objects/ # Objects and possibles interactions
β βββ rooms/ # Game levels
β βββ hints.yaml # A fil that contains hints accessible using help
β βββ init.yaml # The initialization file
β βββ locale.yaml # A file for translations
βββ fonts/ # FONTS USING TTF FORMAT
βββ images/ # IMAGES USING PNG FORMAT
β βββ animations/ # Animated images
β βββ backgrounds/ # The background images of levels
β βββ characters/ # Character images (heads, eyes, bodies, walking animations, etc.)
β βββ interface/ # Icons, buttons, etc.
β βββ inventory/ # Objects displayed in the inventory
β βββ objets/ # Objects that you can interact with
β βββ scenery/ # Objects that are just scenery, with no interaction
β βββ windows/ # Codes and overlay windows
βββ sounds/ # SOUNDS USING OGG FORMAT
βββ effects/ # Sound effects played occasionally
βββ musics/ # Level musics played in the background
Using this tree structure, we can easily create YAML
levels that
will load everything they need. For example :
- the
init
file callsload: ["office", "entrance"]
; - the engine will thus fetch
data/rooms/office.yaml
and load it; - each object in the room will have a βskinβ, for example
skin: "plant.png"
which will then start loadingdata/objects/plant.png
; - we will read and execute the instructions in the
input
section inoffice.yaml
. These instructions can be stored in a separate file, in which case we'll fetchdata/actions/entrance.yaml
; - imagine that when entering a room, a dialogue should start, we will
have
trigger: ["dialog_start"]
which will cause the loading ofdata/dialogs/dialog_start.yaml
; - etc.
Of course, we have to be careful that each referenced asset exists,
otherwise the engine will raise an error: in order not to have to
systematically launch the game to flush out the errors concealed in my
YAML
files, I wrote a Python script that browses all these files and
checks, in addition to the validity of the syntax and the operations
performed, that each referenced file does indeed exist.
For the moment, all the images are stored in their folder (for example
images/animations/clouds.png
) but I might introduce new sub-folders
if it becomes too messy over time (for example
images/animations/part1/clouds.png
). This would just mean, in YAML
files, replacing clouds
with part1/clouds
.
Installation
The big question remains: how will the program know where to find this data? We need to have a fixed path since, again, the person playing should not need to indicate this path to the program.
The problem is that depending on where the program is going to be installed (and the operating system), the path will still vary!
Good news, the SDL helps us again a lot by providing
SDL_GetBasePath()
which gives us the path where the executable is
located (unless it's a MacOS bundle, but I refer you to the doc for
more info). This is very
handy, because we can therefore normalize the relative path to the
executable and refer to this function in the code.
Since this function allocates memory from the string, we encapsulate that in a higher level C++ function:
namespace SDL_file
{
std::string base_path()
{
char* bp = SDL_GetBasePath();
std::string out = bp;
free(bp);
return out;
}
}
Thus, on Linuxs, we will typically use the standard tree structure and therefore place:
- the executable in
/usr/bin/
(or/usr/local/bin
for local installation only) - the data in
/usr/share/superfluous-returnz/
(or/usr/local/share/superfluous-returnz/
)
(Some Linux distributions prefer /usr/game/
instead of /usr/bin/
for games, but let's keep it simple.)
Well, suddenly, we know that to access our data, we can use
SDL_file::base_path() + "../share/" + game_id + "/"
(game_id
being
a variable containing the identifier of the game, here
superfluous-returnz
, but remember that this can change!).
On Windows, typically, the data is in a subfolder of the executable
folder, so you can just use SDL_file::base_path() + "data"
for
example.
Then, we just need to copy the data to the right place via the CMake installation procedure, and we're good to go:
if (CMAKE_SYSTEM_NAME STREQUAL Linux)
target_compile_options(${SOSAGE_EXE_NAME} PUBLIC -DSOSAGE_INSTALL_DATA_FOLDER="../share/${SOSAGE_EXE_NAME}/")
install(TARGETS ${SOSAGE_EXE_NAME} DESTINATION bin)
install(DIRECTORY ${SOSAGE_DATA_FOLDER}/data/ DESTINATION share/${SOSAGE_EXE_NAME})
elseif (CMAKE_SYSTEM_NAME STREQUAL Windows)
target_compile_options(${SOSAGE_EXE_NAME} PUBLIC -DSOSAGE_INSTALL_DATA_FOLDER="data/")
install(TARGETS ${SOSAGE_EXE_NAME} DESTINATION ".")
install(DIRECTORY ${SOSAGE_DATA_FOLDER}/data/ DESTINATION "./data")
endif ()
In the code, we just have to retrieve the SOSAGE_INSTALL_DATA_FOLDER
constant which will vary depending on the OS:
sosage.run(SDL_file::base_path() + SOSAGE_INSTALL_DATA_FOLDER);
Note that a function similar to SDL_GetBasePath()
named
SDL_GetPrefPath()
provides access to a directory where we can store
game files (save files, settings, etc.). I will talk about that
another time as well.
Conclusion
This is how I handle assets in Superfluous Returnz. There are probably other ways to do it, but this is what I found to be the most flexible without being too much of a hassle.
One detail: in memory management, disk accesses are known to be extremely slow compared to RAM (especially if you have an old-school HDD and not a SSD). For this reason, we generally avoid accessing disk too frequently: thus, instead of loading the images one by one, we rather load a large block of images and then interpret them one by one in RAM.
In a future article, I will explain how we can use archives and also take advantage of compression algorithms to speed up this process. See you!