Asset management | Blog | Superflous Returnz

Asset management | Blog | Superflous Returnz Asset management | Blog | Superflous Returnz
  • Steam
  • Nintendo Switch
  • Itch.io
  • Google Play
Superfluous and his assistant Sophie

Blog

Asset management

2022-05-04

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 and SDL_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 calls load: ["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 loading data/objects/plant.png;
  • we will read and execute the instructions in the input section in office.yaml. These instructions can be stored in a separate file, in which case we'll fetch data/actions/entrance.yaml;
  • imagine that when entering a room, a dialogue should start, we will have trigger: ["dialog_start"] which will cause the loading of data/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!

i